Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
489 lines
18 KiB
Go
489 lines
18 KiB
Go
package mobileremote
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/httpx"
|
|
mobileremotebiz "github.com/tutti-os/tutti/services/tuttid/biz/mobileremote"
|
|
)
|
|
|
|
const (
|
|
DefaultControlPlaneBaseURL = "https://tutti.sh/api/desktop/v1"
|
|
maxControlPlaneResponse = 1 << 20
|
|
)
|
|
|
|
type ControlPlane interface {
|
|
RegisterDevice(context.Context, string, RegisterDeviceInput) (RegisteredDevice, error)
|
|
CreateChallenge(context.Context, string, string) (CreateChallengeResult, error)
|
|
GetChallenge(context.Context, string, string) (mobileremotebiz.PairingChallenge, error)
|
|
ConfirmChallenge(context.Context, string, string, string, []byte) (ConfirmChallengeResult, error)
|
|
ListPairings(context.Context, string) ([]mobileremotebiz.DevicePairing, error)
|
|
RevokePairing(context.Context, string, string) (mobileremotebiz.DevicePairing, error)
|
|
ListDeviceLinkAttempts(context.Context, string, string, string, []byte) ([]DeviceLinkAttempt, error)
|
|
UpdateDeviceLinkParticipant(context.Context, string, string, string, string, DeviceLinkParticipantInput) (DeviceLinkAttempt, error)
|
|
}
|
|
|
|
type RegisterDeviceInput struct {
|
|
DeviceID string
|
|
ReportedName string
|
|
Platform string
|
|
Arch string
|
|
ClientVersion string
|
|
Algorithm string
|
|
PublicKey []byte
|
|
Proof []byte
|
|
}
|
|
|
|
type RegisteredDevice struct {
|
|
UserDeviceID string
|
|
DeviceID string
|
|
}
|
|
|
|
type DeviceLinkICEParams struct {
|
|
Ufrag string `json:"ufrag"`
|
|
Pwd string `json:"pwd"`
|
|
Candidates []string `json:"candidates"`
|
|
}
|
|
|
|
type DeviceLinkAttempt struct {
|
|
AttemptID string `json:"attemptId"`
|
|
PairingID string `json:"scopeId"`
|
|
CallerDeviceID string `json:"callerDeviceId"`
|
|
CallerFingerprint string `json:"callerFingerprint"`
|
|
CallerICE *DeviceLinkICEParams `json:"callerIce"`
|
|
CallerProtocolVersion int `json:"callerProtocolVersion"`
|
|
OwnerDeviceID string `json:"ownerDeviceId"`
|
|
OwnerFingerprint string `json:"ownerFingerprint"`
|
|
OwnerICE *DeviceLinkICEParams `json:"ownerIce"`
|
|
OwnerProtocolVersion int `json:"ownerProtocolVersion"`
|
|
State string `json:"state"`
|
|
STUNEndpoints []string `json:"stunEndpoints"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
}
|
|
|
|
type DeviceLinkParticipantInput struct {
|
|
Fingerprint string
|
|
ProtocolVersion int
|
|
ICE DeviceLinkICEParams
|
|
IdentitySignature []byte
|
|
}
|
|
|
|
type CreateChallengeResult struct {
|
|
Challenge mobileremotebiz.PairingChallenge
|
|
Secret string
|
|
}
|
|
|
|
type ConfirmChallengeResult struct {
|
|
Pairing mobileremotebiz.DevicePairing
|
|
Challenge mobileremotebiz.PairingChallenge
|
|
}
|
|
|
|
type HTTPControlPlane struct {
|
|
BaseURL string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
type ControlPlaneError struct {
|
|
StatusCode int
|
|
Code string
|
|
Reason string
|
|
}
|
|
|
|
func (e *ControlPlaneError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
detail := strings.TrimSpace(e.Reason)
|
|
if detail == "" {
|
|
detail = strings.TrimSpace(e.Code)
|
|
}
|
|
if detail == "" {
|
|
detail = "request rejected"
|
|
}
|
|
return fmt.Sprintf("mobile remote control-plane request failed (%d): %s", e.StatusCode, detail)
|
|
}
|
|
|
|
type userDeviceRegistrationWire struct {
|
|
DeviceID string `json:"deviceId"`
|
|
ReportedName string `json:"reportedName"`
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
ClientVersion string `json:"clientVersion"`
|
|
PublicIdentity devicePublicIdentityWire `json:"publicIdentity"`
|
|
}
|
|
|
|
type devicePublicIdentityWire struct {
|
|
Algorithm string `json:"algorithm"`
|
|
PublicKey []byte `json:"publicKey"`
|
|
Proof []byte `json:"proof"`
|
|
}
|
|
|
|
type registeredDeviceWire struct {
|
|
UserDeviceID string `json:"userDeviceId"`
|
|
DeviceID string `json:"deviceId"`
|
|
}
|
|
|
|
type pairingChallengeWire struct {
|
|
ChallengeID string `json:"challengeId"`
|
|
TargetUserDeviceID string `json:"targetUserDeviceId"`
|
|
ControllerUserDeviceID string `json:"controllerUserDeviceId"`
|
|
State string `json:"state"`
|
|
PairingID string `json:"pairingId"`
|
|
Revision uint64 `json:"revision,string"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
}
|
|
|
|
type pairingWire struct {
|
|
PairingID string `json:"pairingId"`
|
|
ControllerUserDeviceID string `json:"controllerUserDeviceId"`
|
|
TargetUserDeviceID string `json:"targetUserDeviceId"`
|
|
State string `json:"state"`
|
|
Revision uint64 `json:"revision,string"`
|
|
ConfirmedAt string `json:"confirmedAt"`
|
|
RevokedAt *string `json:"revokedAt"`
|
|
}
|
|
|
|
func (c *HTTPControlPlane) RegisterDevice(ctx context.Context, cookie string, input RegisterDeviceInput) (RegisteredDevice, error) {
|
|
request := userDeviceRegistrationWire{
|
|
DeviceID: strings.TrimSpace(input.DeviceID), ReportedName: strings.TrimSpace(input.ReportedName),
|
|
Platform: strings.TrimSpace(input.Platform), Arch: strings.TrimSpace(input.Arch),
|
|
ClientVersion: strings.TrimSpace(input.ClientVersion),
|
|
PublicIdentity: devicePublicIdentityWire{
|
|
Algorithm: strings.TrimSpace(input.Algorithm),
|
|
PublicKey: append([]byte(nil), input.PublicKey...),
|
|
Proof: append([]byte(nil), input.Proof...),
|
|
},
|
|
}
|
|
var response struct {
|
|
Device registeredDeviceWire `json:"device"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodPut, "/devices/current", cookie, request, &response); err != nil {
|
|
return RegisteredDevice{}, err
|
|
}
|
|
device := RegisteredDevice{
|
|
UserDeviceID: strings.TrimSpace(response.Device.UserDeviceID),
|
|
DeviceID: strings.TrimSpace(response.Device.DeviceID),
|
|
}
|
|
if device.UserDeviceID == "" || device.DeviceID != strings.TrimSpace(input.DeviceID) {
|
|
return RegisteredDevice{}, errors.New("control-plane registered device is incomplete")
|
|
}
|
|
return device, nil
|
|
}
|
|
|
|
func (c *HTTPControlPlane) CreateChallenge(ctx context.Context, cookie, targetDeviceID string) (CreateChallengeResult, error) {
|
|
var response struct {
|
|
Challenge pairingChallengeWire `json:"challenge"`
|
|
Secret string `json:"secret"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodPost, "/device-pairing-challenges", cookie,
|
|
map[string]string{"targetDeviceId": strings.TrimSpace(targetDeviceID)}, &response); err != nil {
|
|
return CreateChallengeResult{}, err
|
|
}
|
|
challenge, err := pairingChallengeFromWire(response.Challenge)
|
|
if err != nil {
|
|
return CreateChallengeResult{}, err
|
|
}
|
|
if strings.TrimSpace(response.Secret) == "" {
|
|
return CreateChallengeResult{}, fmt.Errorf("control-plane challenge secret is missing")
|
|
}
|
|
return CreateChallengeResult{Challenge: challenge, Secret: strings.TrimSpace(response.Secret)}, nil
|
|
}
|
|
|
|
func (c *HTTPControlPlane) GetChallenge(ctx context.Context, cookie, challengeID string) (mobileremotebiz.PairingChallenge, error) {
|
|
var response struct {
|
|
Challenge pairingChallengeWire `json:"challenge"`
|
|
}
|
|
path := "/device-pairing-challenges/" + url.PathEscape(strings.TrimSpace(challengeID))
|
|
if err := c.doJSON(ctx, http.MethodGet, path, cookie, nil, &response); err != nil {
|
|
return mobileremotebiz.PairingChallenge{}, err
|
|
}
|
|
return pairingChallengeFromWire(response.Challenge)
|
|
}
|
|
|
|
func (c *HTTPControlPlane) ConfirmChallenge(ctx context.Context, cookie, challengeID, secret string, signature []byte) (ConfirmChallengeResult, error) {
|
|
var response struct {
|
|
Pairing pairingWire `json:"pairing"`
|
|
Challenge pairingChallengeWire `json:"challenge"`
|
|
}
|
|
path := "/device-pairing-challenges/" + url.PathEscape(strings.TrimSpace(challengeID)) + "/confirm"
|
|
request := map[string]any{
|
|
"secret": strings.TrimSpace(secret),
|
|
"signature": append([]byte(nil), signature...),
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodPost, path, cookie, request, &response); err != nil {
|
|
return ConfirmChallengeResult{}, err
|
|
}
|
|
pairing, err := pairingFromWire(response.Pairing)
|
|
if err != nil {
|
|
return ConfirmChallengeResult{}, err
|
|
}
|
|
challenge, err := pairingChallengeFromWire(response.Challenge)
|
|
if err != nil {
|
|
return ConfirmChallengeResult{}, err
|
|
}
|
|
return ConfirmChallengeResult{Pairing: pairing, Challenge: challenge}, nil
|
|
}
|
|
|
|
func (c *HTTPControlPlane) ListPairings(ctx context.Context, cookie string) ([]mobileremotebiz.DevicePairing, error) {
|
|
var response struct {
|
|
Pairings []pairingWire `json:"pairings"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodGet, "/device-pairings", cookie, nil, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
pairings := make([]mobileremotebiz.DevicePairing, 0, len(response.Pairings))
|
|
for _, wire := range response.Pairings {
|
|
pairing, err := pairingFromWire(wire)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pairings = append(pairings, pairing)
|
|
}
|
|
return pairings, nil
|
|
}
|
|
|
|
func (c *HTTPControlPlane) RevokePairing(ctx context.Context, cookie, pairingID string) (mobileremotebiz.DevicePairing, error) {
|
|
var response struct {
|
|
Pairing pairingWire `json:"pairing"`
|
|
}
|
|
path := "/device-pairings/" + url.PathEscape(strings.TrimSpace(pairingID))
|
|
if err := c.doJSON(ctx, http.MethodDelete, path, cookie, nil, &response); err != nil {
|
|
return mobileremotebiz.DevicePairing{}, err
|
|
}
|
|
return pairingFromWire(response.Pairing)
|
|
}
|
|
|
|
func (c *HTTPControlPlane) ListDeviceLinkAttempts(
|
|
ctx context.Context,
|
|
cookie string,
|
|
pairingID string,
|
|
deviceID string,
|
|
identitySignature []byte,
|
|
) ([]DeviceLinkAttempt, error) {
|
|
query := url.Values{}
|
|
query.Set("deviceId", strings.TrimSpace(deviceID))
|
|
query.Set("identitySignature", base64.RawURLEncoding.EncodeToString(identitySignature))
|
|
path := "/device-pairings/" + url.PathEscape(strings.TrimSpace(pairingID)) +
|
|
"/device-link-attempts?" + query.Encode()
|
|
var response struct {
|
|
Attempts []DeviceLinkAttempt `json:"attempts"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodGet, path, cookie, nil, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
attempts := make([]DeviceLinkAttempt, 0, len(response.Attempts))
|
|
for _, attempt := range response.Attempts {
|
|
normalized, err := normalizeDeviceLinkAttempt(attempt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attempts = append(attempts, normalized)
|
|
}
|
|
return attempts, nil
|
|
}
|
|
|
|
func (c *HTTPControlPlane) UpdateDeviceLinkParticipant(
|
|
ctx context.Context,
|
|
cookie string,
|
|
pairingID string,
|
|
attemptID string,
|
|
deviceID string,
|
|
input DeviceLinkParticipantInput,
|
|
) (DeviceLinkAttempt, error) {
|
|
query := url.Values{}
|
|
query.Set("deviceId", strings.TrimSpace(deviceID))
|
|
path := "/device-pairings/" + url.PathEscape(strings.TrimSpace(pairingID)) +
|
|
"/device-link-attempts/" + url.PathEscape(strings.TrimSpace(attemptID)) +
|
|
"/participant?" + query.Encode()
|
|
request := map[string]any{
|
|
"ephemeralFingerprint": strings.TrimSpace(input.Fingerprint),
|
|
"candidates": []any{},
|
|
"protocolVersion": input.ProtocolVersion,
|
|
"ice": input.ICE,
|
|
"identitySignature": append([]byte(nil), input.IdentitySignature...),
|
|
}
|
|
var response struct {
|
|
Attempt DeviceLinkAttempt `json:"attempt"`
|
|
}
|
|
if err := c.doJSON(ctx, http.MethodPost, path, cookie, request, &response); err != nil {
|
|
return DeviceLinkAttempt{}, err
|
|
}
|
|
return normalizeDeviceLinkAttempt(response.Attempt)
|
|
}
|
|
|
|
func (c *HTTPControlPlane) doJSON(ctx context.Context, method, path, cookie string, requestBody, responseBody any) error {
|
|
baseURL := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
|
|
if baseURL == "" {
|
|
baseURL = DefaultControlPlaneBaseURL
|
|
}
|
|
var body io.Reader
|
|
if requestBody != nil {
|
|
raw, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return fmt.Errorf("encode mobile remote control-plane request: %w", err)
|
|
}
|
|
body = bytes.NewReader(raw)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, baseURL+path, body)
|
|
if err != nil {
|
|
return fmt.Errorf("create mobile remote control-plane request: %w", err)
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
if requestBody != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
request.Header.Set("Cookie", strings.TrimSpace(cookie))
|
|
client := c.HTTPClient
|
|
if client == nil {
|
|
client = httpx.NewClient(15 * time.Second)
|
|
}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return sanitizedControlPlaneTransportError(err)
|
|
}
|
|
defer response.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, maxControlPlaneResponse+1))
|
|
if err != nil {
|
|
return fmt.Errorf("read mobile remote control-plane response: %w", err)
|
|
}
|
|
if len(raw) > maxControlPlaneResponse {
|
|
return fmt.Errorf("mobile remote control-plane response exceeds %d bytes", maxControlPlaneResponse)
|
|
}
|
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
|
return controlPlaneError(response.StatusCode, raw)
|
|
}
|
|
if responseBody == nil || len(bytes.TrimSpace(raw)) == 0 {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(raw, responseBody); err != nil {
|
|
return fmt.Errorf("decode mobile remote control-plane response: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sanitizedControlPlaneTransportError preserves retry-relevant error traits
|
|
// without retaining net/http's URL string. Some DeviceLink requests carry a
|
|
// short-lived identity signature in the query, so the original *url.Error must
|
|
// never reach diagnostics or logs.
|
|
func sanitizedControlPlaneTransportError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
for {
|
|
requestErr, ok := err.(*url.Error)
|
|
if !ok {
|
|
break
|
|
}
|
|
err = requestErr.Err
|
|
}
|
|
return &controlPlaneTransportError{err: err}
|
|
}
|
|
|
|
type controlPlaneTransportError struct {
|
|
err error
|
|
}
|
|
|
|
func (e *controlPlaneTransportError) Error() string {
|
|
return "send mobile remote control-plane request: " + e.err.Error()
|
|
}
|
|
|
|
func (e *controlPlaneTransportError) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func (e *controlPlaneTransportError) Timeout() bool {
|
|
var networkErr net.Error
|
|
return errors.As(e.err, &networkErr) && networkErr.Timeout()
|
|
}
|
|
|
|
func controlPlaneError(statusCode int, raw []byte) error {
|
|
var response struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Reason string `json:"reason"`
|
|
} `json:"error"`
|
|
}
|
|
_ = json.Unmarshal(raw, &response)
|
|
return &ControlPlaneError{
|
|
StatusCode: statusCode,
|
|
Code: strings.TrimSpace(response.Error.Code),
|
|
Reason: strings.TrimSpace(response.Error.Reason),
|
|
}
|
|
}
|
|
|
|
func pairingChallengeFromWire(wire pairingChallengeWire) (mobileremotebiz.PairingChallenge, error) {
|
|
expiresAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(wire.ExpiresAt))
|
|
if err != nil {
|
|
return mobileremotebiz.PairingChallenge{}, fmt.Errorf("parse pairing challenge expiry: %w", err)
|
|
}
|
|
challenge := mobileremotebiz.PairingChallenge{
|
|
ChallengeID: strings.TrimSpace(wire.ChallengeID), TargetUserDeviceID: strings.TrimSpace(wire.TargetUserDeviceID),
|
|
ControllerUserDeviceID: strings.TrimSpace(wire.ControllerUserDeviceID), State: strings.TrimSpace(wire.State),
|
|
PairingID: strings.TrimSpace(wire.PairingID), Revision: wire.Revision, ExpiresAt: expiresAt.UTC(),
|
|
}
|
|
if challenge.ChallengeID == "" || challenge.TargetUserDeviceID == "" || challenge.State == "" {
|
|
return mobileremotebiz.PairingChallenge{}, fmt.Errorf("control-plane pairing challenge is incomplete")
|
|
}
|
|
return challenge, nil
|
|
}
|
|
|
|
func pairingFromWire(wire pairingWire) (mobileremotebiz.DevicePairing, error) {
|
|
confirmedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(wire.ConfirmedAt))
|
|
if err != nil {
|
|
return mobileremotebiz.DevicePairing{}, fmt.Errorf("parse device pairing confirmation time: %w", err)
|
|
}
|
|
var revokedAt *time.Time
|
|
if wire.RevokedAt != nil && strings.TrimSpace(*wire.RevokedAt) != "" {
|
|
value, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(*wire.RevokedAt))
|
|
if err != nil {
|
|
return mobileremotebiz.DevicePairing{}, fmt.Errorf("parse device pairing revocation time: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
revokedAt = &value
|
|
}
|
|
pairing := mobileremotebiz.DevicePairing{
|
|
PairingID: strings.TrimSpace(wire.PairingID), ControllerUserDeviceID: strings.TrimSpace(wire.ControllerUserDeviceID),
|
|
TargetUserDeviceID: strings.TrimSpace(wire.TargetUserDeviceID), State: strings.TrimSpace(wire.State),
|
|
Revision: wire.Revision, ConfirmedAt: confirmedAt.UTC(), RevokedAt: revokedAt,
|
|
}
|
|
if pairing.PairingID == "" || pairing.ControllerUserDeviceID == "" || pairing.TargetUserDeviceID == "" || pairing.State == "" {
|
|
return mobileremotebiz.DevicePairing{}, fmt.Errorf("control-plane device pairing is incomplete")
|
|
}
|
|
return pairing, nil
|
|
}
|
|
|
|
func normalizeDeviceLinkAttempt(attempt DeviceLinkAttempt) (DeviceLinkAttempt, error) {
|
|
attempt.AttemptID = strings.TrimSpace(attempt.AttemptID)
|
|
attempt.PairingID = strings.TrimSpace(attempt.PairingID)
|
|
attempt.CallerDeviceID = strings.TrimSpace(attempt.CallerDeviceID)
|
|
attempt.CallerFingerprint = strings.TrimSpace(attempt.CallerFingerprint)
|
|
attempt.OwnerDeviceID = strings.TrimSpace(attempt.OwnerDeviceID)
|
|
attempt.OwnerFingerprint = strings.TrimSpace(attempt.OwnerFingerprint)
|
|
attempt.State = strings.TrimSpace(attempt.State)
|
|
attempt.ExpiresAt = strings.TrimSpace(attempt.ExpiresAt)
|
|
if attempt.AttemptID == "" || attempt.PairingID == "" || attempt.CallerDeviceID == "" ||
|
|
attempt.OwnerDeviceID == "" || attempt.State == "" || attempt.CallerICE == nil || attempt.ExpiresAt == "" {
|
|
return DeviceLinkAttempt{}, errors.New("control-plane device-link attempt is incomplete")
|
|
}
|
|
if _, err := time.Parse(time.RFC3339Nano, attempt.ExpiresAt); err != nil {
|
|
return DeviceLinkAttempt{}, fmt.Errorf("parse control-plane device-link attempt expiry: %w", err)
|
|
}
|
|
attempt.STUNEndpoints = append([]string(nil), attempt.STUNEndpoints...)
|
|
attempt.CallerICE.Candidates = append([]string(nil), attempt.CallerICE.Candidates...)
|
|
if attempt.OwnerICE != nil {
|
|
attempt.OwnerICE.Candidates = append([]string(nil), attempt.OwnerICE.Candidates...)
|
|
}
|
|
return attempt, nil
|
|
}
|