From 731b5a794218295a56ba3110753e0febc9896d7b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 11:23:43 -0500 Subject: [PATCH] Add companion pairing contracts and stubs - Add schemas and WS methods for companion pairing bundles and paired devices - Stub server handlers for the new companion RPCs - Update mobile parsing tests and pairing link copy --- apps/mobile/src/mobilePairing.test.ts | 69 +++++++++++++- apps/mobile/src/mobilePairing.ts | 50 +++++++++++ apps/server/src/wsServer.ts | 26 ++++++ .../web/src/components/mobile/PairingLink.tsx | 3 +- packages/contracts/src/baseSchemas.ts | 6 ++ packages/contracts/src/server.ts | 89 ++++++++++++++++++- packages/contracts/src/ws.test.ts | 82 +++++++++++++++++ packages/contracts/src/ws.ts | 22 ++++- 8 files changed, 342 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/mobilePairing.test.ts b/apps/mobile/src/mobilePairing.test.ts index 5ea4fa317..03bde1384 100644 --- a/apps/mobile/src/mobilePairing.test.ts +++ b/apps/mobile/src/mobilePairing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createWsUrl, parseMobilePairingInput } from "./mobilePairing"; +import { createWsUrl, parseMobilePairingInput, tryParseCompanionBundle } from "./mobilePairing"; describe("mobilePairing", () => { it("parses okcode deep links", () => { @@ -40,3 +40,70 @@ describe("mobilePairing", () => { ); }); }); + +describe("tryParseCompanionBundle", () => { + it("parses a valid companion bundle", () => { + const bundle = { + pairingId: "pair-123", + bootstrapToken: "bootstrap-abc", + endpoints: [ + { kind: "lan", url: "http://192.168.1.10:3773", reachable: true }, + { kind: "tailscale", url: "http://100.64.0.1:3773", label: "macbook", reachable: true }, + ], + expiresAt: "2026-04-10T12:00:00Z", + passwordRequired: false, + }; + + expect(tryParseCompanionBundle(JSON.stringify(bundle))).toEqual({ + pairingId: "pair-123", + bootstrapToken: "bootstrap-abc", + endpoints: bundle.endpoints, + expiresAt: "2026-04-10T12:00:00Z", + passwordRequired: false, + passwordHint: undefined, + }); + }); + + it("parses a bundle with password required and hint", () => { + const bundle = { + pairingId: "pair-456", + bootstrapToken: "bootstrap-xyz", + endpoints: [{ kind: "manual", url: "http://mybox:3773", reachable: true }], + expiresAt: "2026-04-10T13:00:00Z", + passwordRequired: true, + passwordHint: "The usual one", + }; + + const result = tryParseCompanionBundle(JSON.stringify(bundle)); + expect(result).not.toBeNull(); + expect(result!.passwordRequired).toBe(true); + expect(result!.passwordHint).toBe("The usual one"); + }); + + it("returns null for non-JSON input", () => { + expect(tryParseCompanionBundle("okcode://pair?server=foo&token=bar")).toBeNull(); + }); + + it("returns null for JSON missing required fields", () => { + expect(tryParseCompanionBundle(JSON.stringify({ pairingId: "abc" }))).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(tryParseCompanionBundle("")).toBeNull(); + }); + + it("ignores unknown extra fields in the bundle", () => { + const bundle = { + pairingId: "pair-789", + bootstrapToken: "bootstrap-def", + endpoints: [], + expiresAt: "2026-04-10T14:00:00Z", + passwordRequired: false, + futureField: "should be ignored", + }; + + const result = tryParseCompanionBundle(JSON.stringify(bundle)); + expect(result).not.toBeNull(); + expect(result!.pairingId).toBe("pair-789"); + }); +}); diff --git a/apps/mobile/src/mobilePairing.ts b/apps/mobile/src/mobilePairing.ts index 2e6373cef..787fb5878 100644 --- a/apps/mobile/src/mobilePairing.ts +++ b/apps/mobile/src/mobilePairing.ts @@ -4,6 +4,20 @@ export interface ParsedMobilePairing { wsUrl: string; } +/** + * Parsed representation of the new companion pairing bundle. + * This shape is forward-compatible with Milestone 2 where the mobile + * client will exchange the bootstrap token for a device-scoped session. + */ +export interface ParsedCompanionBundle { + pairingId: string; + bootstrapToken: string; + endpoints: Array<{ kind: string; url: string; label?: string; reachable: boolean }>; + expiresAt: string; + passwordRequired: boolean; + passwordHint?: string; +} + const PAIRING_SCHEME = "okcode:"; function normalizeServerUrl(rawValue: string): URL { @@ -66,3 +80,39 @@ export function parseMobilePairingInput(input: string): ParsedMobilePairing { wsUrl: createWsUrl(normalizedServerUrl, token), }; } + +/** + * Attempt to parse a JSON companion pairing bundle. + * Returns `null` if the input is not valid JSON or does not match the + * expected shape, so callers can fall back to the legacy URL parser. + * + * This parser is intentionally lenient: it validates the minimal required + * fields and ignores unexpected properties so that older clients remain + * forward-compatible as the bundle schema evolves. + */ +export function tryParseCompanionBundle(input: string): ParsedCompanionBundle | null { + try { + const data = JSON.parse(input); + if ( + typeof data !== "object" || + data === null || + typeof data.pairingId !== "string" || + typeof data.bootstrapToken !== "string" || + !Array.isArray(data.endpoints) || + typeof data.expiresAt !== "string" + ) { + return null; + } + + return { + pairingId: data.pairingId, + bootstrapToken: data.bootstrapToken, + endpoints: data.endpoints, + expiresAt: data.expiresAt, + passwordRequired: data.passwordRequired === true, + passwordHint: typeof data.passwordHint === "string" ? data.passwordHint : undefined, + }; + } catch { + return null; + } +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8eafdfc4a..509c9b11f 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1671,6 +1671,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { tokens }; } + // ── Companion pairing (placeholder) ───────────────────────────── + // These handlers are wired for type-exhaustiveness but return + // stub responses until the full companion session manager is built. + + case WS_METHODS.serverGenerateCompanionPairingBundle: { + return yield* new RouteRequestError({ + message: "Companion pairing bundle generation is not yet implemented.", + }); + } + + case WS_METHODS.serverExchangeCompanionBootstrap: { + return yield* new RouteRequestError({ + message: "Companion bootstrap exchange is not yet implemented.", + }); + } + + case WS_METHODS.serverListPairedDevices: { + return { devices: [] }; + } + + case WS_METHODS.serverRevokePairedDevice: { + return yield* new RouteRequestError({ + message: "Companion device revocation is not yet implemented.", + }); + } + // ── OpenClaw gateway test ──────────────────────────────────────── case WS_METHODS.serverTestOpenclawGateway: { const body = stripRequestTag(request.body); diff --git a/apps/web/src/components/mobile/PairingLink.tsx b/apps/web/src/components/mobile/PairingLink.tsx index 08c1af2b6..2d9066aad 100644 --- a/apps/web/src/components/mobile/PairingLink.tsx +++ b/apps/web/src/components/mobile/PairingLink.tsx @@ -127,7 +127,8 @@ export function PairingLink() {

- Copy the pairing link and paste it into the mobile app. + Copy the pairing link and paste it into the mobile app. A new QR-based pairing flow is + coming soon; this link method will continue to work.

) : loading ? ( diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 8b9f54408..97ab91d5c 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -49,3 +49,9 @@ export const SmeDocumentId = makeEntityId("SmeDocumentId"); export type SmeDocumentId = typeof SmeDocumentId.Type; export const SmeMessageId = makeEntityId("SmeMessageId"); export type SmeMessageId = typeof SmeMessageId.Type; + +// ── Companion Pairing IDs ─────────────────────────────────────────── +export const PairingId = makeEntityId("PairingId"); +export type PairingId = typeof PairingId.Type; +export const DeviceId = makeEntityId("DeviceId"); +export type DeviceId = typeof DeviceId.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8b5e49c9e..ed8ef3ee3 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; +import { DeviceId, IsoDateTime, PairingId, TrimmedNonEmptyString } from "./baseSchemas"; import { BuildMetadata } from "./buildInfo"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; @@ -135,6 +135,93 @@ export const ListTokensResult = Schema.Struct({ }); export type ListTokensResult = typeof ListTokensResult.Type; +// ── Companion Pairing (new model) ────────────────────────────────── +// The companion pairing model replaces the single-token deep-link flow +// with endpoint-aware bundles and device-scoped sessions. The legacy +// `GeneratePairingLinkInput`/`GeneratePairingLinkResult` contracts above +// remain supported during rollout. + +export const CompanionEndpointKind = Schema.Literals(["tailscale", "lan", "manual"]); +export type CompanionEndpointKind = typeof CompanionEndpointKind.Type; + +export const CompanionEndpoint = Schema.Struct({ + kind: CompanionEndpointKind, + url: TrimmedNonEmptyString, + label: Schema.optional(TrimmedNonEmptyString), + reachable: Schema.Boolean, +}); +export type CompanionEndpoint = typeof CompanionEndpoint.Type; + +export const CompanionPairingBundle = Schema.Struct({ + pairingId: PairingId, + expiresAt: IsoDateTime, + endpoints: Schema.Array(CompanionEndpoint), + bootstrapToken: TrimmedNonEmptyString, + passwordRequired: Schema.Boolean, + passwordHint: Schema.optional(TrimmedNonEmptyString), +}); +export type CompanionPairingBundle = typeof CompanionPairingBundle.Type; + +export const PairedDeviceSession = Schema.Struct({ + deviceId: DeviceId, + deviceName: TrimmedNonEmptyString, + serverUrl: TrimmedNonEmptyString, + sessionToken: TrimmedNonEmptyString, + issuedAt: IsoDateTime, + expiresAt: Schema.NullOr(IsoDateTime), + lastSeenAt: Schema.NullOr(IsoDateTime), +}); +export type PairedDeviceSession = typeof PairedDeviceSession.Type; + +// ── Companion RPC Inputs/Outputs ─────────────────────────────────── + +export const GenerateCompanionPairingBundleInput = Schema.Struct({ + /** Lifetime in seconds for the bootstrap token. Defaults to 300 (5 min). */ + ttlSeconds: Schema.optional(Schema.Number), + /** Desktop-advertised endpoints to include in the bundle. */ + advertisedEndpoints: Schema.optional(Schema.Array(CompanionEndpoint)), +}); +export type GenerateCompanionPairingBundleInput = typeof GenerateCompanionPairingBundleInput.Type; + +export const GenerateCompanionPairingBundleResult = CompanionPairingBundle; +export type GenerateCompanionPairingBundleResult = typeof GenerateCompanionPairingBundleResult.Type; + +export const ExchangeCompanionBootstrapInput = Schema.Struct({ + bootstrapToken: TrimmedNonEmptyString, + endpointUrl: TrimmedNonEmptyString, + password: Schema.optional(Schema.String), + deviceName: TrimmedNonEmptyString, +}); +export type ExchangeCompanionBootstrapInput = typeof ExchangeCompanionBootstrapInput.Type; + +export const ExchangeCompanionBootstrapResult = PairedDeviceSession; +export type ExchangeCompanionBootstrapResult = typeof ExchangeCompanionBootstrapResult.Type; + +export const ListPairedDevicesResult = Schema.Struct({ + devices: Schema.Array( + Schema.Struct({ + deviceId: DeviceId, + deviceName: TrimmedNonEmptyString, + issuedAt: IsoDateTime, + lastSeenAt: Schema.NullOr(IsoDateTime), + endpointKind: Schema.optional(CompanionEndpointKind), + revoked: Schema.Boolean, + }), + ), +}); +export type ListPairedDevicesResult = typeof ListPairedDevicesResult.Type; + +export const RevokePairedDeviceInput = Schema.Struct({ + deviceId: DeviceId, +}); +export type RevokePairedDeviceInput = typeof RevokePairedDeviceInput.Type; + +export const RevokePairedDeviceResult = Schema.Struct({ + deviceId: DeviceId, + revoked: Schema.Boolean, +}); +export type RevokePairedDeviceResult = typeof RevokePairedDeviceResult.Type; + // ── OpenClaw Gateway Test ─────────────────────────────────────────── export const TestOpenclawGatewayInput = Schema.Struct({ diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 074b8ab8d..6a273c998 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -188,3 +188,85 @@ it.effect("rejects push envelopes when channel payload does not match the channe assert.strictEqual(result._tag, "Failure"); }), ); + +// ── Companion pairing contract tests ───────────────────────────────── + +it.effect("accepts generateCompanionPairingBundle requests", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-cpb-1", + body: { + _tag: WS_METHODS.serverGenerateCompanionPairingBundle, + ttlSeconds: 600, + advertisedEndpoints: [{ kind: "lan", url: "http://192.168.1.10:3773", reachable: true }], + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.serverGenerateCompanionPairingBundle); + }), +); + +it.effect("accepts generateCompanionPairingBundle with no optional fields", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-cpb-2", + body: { + _tag: WS_METHODS.serverGenerateCompanionPairingBundle, + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.serverGenerateCompanionPairingBundle); + }), +); + +it.effect("accepts exchangeCompanionBootstrap requests", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-ecb-1", + body: { + _tag: WS_METHODS.serverExchangeCompanionBootstrap, + bootstrapToken: "abc123", + endpointUrl: "http://192.168.1.10:3773", + deviceName: "My Phone", + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.serverExchangeCompanionBootstrap); + }), +); + +it.effect("accepts exchangeCompanionBootstrap with password", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-ecb-2", + body: { + _tag: WS_METHODS.serverExchangeCompanionBootstrap, + bootstrapToken: "abc123", + endpointUrl: "http://192.168.1.10:3773", + password: "hunter2", + deviceName: "My Phone", + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.serverExchangeCompanionBootstrap); + }), +); + +it.effect("accepts listPairedDevices requests", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-lpd-1", + body: { _tag: WS_METHODS.serverListPairedDevices }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.serverListPairedDevices); + }), +); + +it.effect("accepts revokePairedDevice requests", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: "req-rpd-1", + body: { + _tag: WS_METHODS.serverRevokePairedDevice, + deviceId: "device-abc", + }, + }); + assert.strictEqual(parsed.body._tag, WS_METHODS.serverRevokePairedDevice); + }), +); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 55a7e760f..68af83955 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -70,7 +70,10 @@ import { import { ProjectFileTreeChangedPayload } from "./project"; import { OpenInEditorInput, OpenPathInput } from "./editor"; import { + ExchangeCompanionBootstrapInput, + GenerateCompanionPairingBundleInput, GeneratePairingLinkInput, + RevokePairedDeviceInput, RevokeTokenInput, ServerConfigUpdatedPayload, TestOpenclawGatewayInput, @@ -189,12 +192,18 @@ export const WS_METHODS = { serverUpsertKeybinding: "server.upsertKeybinding", serverPickFolder: "server.pickFolder", - // Token management + // Token management (legacy) serverGeneratePairingLink: "server.generatePairingLink", serverRotateToken: "server.rotateToken", serverRevokeToken: "server.revokeToken", serverListTokens: "server.listTokens", + // Companion pairing + serverGenerateCompanionPairingBundle: "server.generateCompanionPairingBundle", + serverExchangeCompanionBootstrap: "server.exchangeCompanionBootstrap", + serverListPairedDevices: "server.listPairedDevices", + serverRevokePairedDevice: "server.revokePairedDevice", + // OpenClaw gateway serverTestOpenclawGateway: "server.testOpenclawGateway", @@ -352,12 +361,21 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), tagRequestBody(WS_METHODS.serverPickFolder, Schema.Struct({})), - // Token management + // Token management (legacy) tagRequestBody(WS_METHODS.serverGeneratePairingLink, GeneratePairingLinkInput), tagRequestBody(WS_METHODS.serverRotateToken, Schema.Struct({})), tagRequestBody(WS_METHODS.serverRevokeToken, RevokeTokenInput), tagRequestBody(WS_METHODS.serverListTokens, Schema.Struct({})), + // Companion pairing + tagRequestBody( + WS_METHODS.serverGenerateCompanionPairingBundle, + GenerateCompanionPairingBundleInput, + ), + tagRequestBody(WS_METHODS.serverExchangeCompanionBootstrap, ExchangeCompanionBootstrapInput), + tagRequestBody(WS_METHODS.serverListPairedDevices, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverRevokePairedDevice, RevokePairedDeviceInput), + // OpenClaw gateway tagRequestBody(WS_METHODS.serverTestOpenclawGateway, TestOpenclawGatewayInput),