Skip to content

Commit a8d9c8e

Browse files
authored
Add companion pairing contracts and stubs (#403)
- 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
1 parent b5c4c7a commit a8d9c8e

8 files changed

Lines changed: 342 additions & 5 deletions

File tree

apps/mobile/src/mobilePairing.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { createWsUrl, parseMobilePairingInput } from "./mobilePairing";
3+
import { createWsUrl, parseMobilePairingInput, tryParseCompanionBundle } from "./mobilePairing";
44

55
describe("mobilePairing", () => {
66
it("parses okcode deep links", () => {
@@ -40,3 +40,70 @@ describe("mobilePairing", () => {
4040
);
4141
});
4242
});
43+
44+
describe("tryParseCompanionBundle", () => {
45+
it("parses a valid companion bundle", () => {
46+
const bundle = {
47+
pairingId: "pair-123",
48+
bootstrapToken: "bootstrap-abc",
49+
endpoints: [
50+
{ kind: "lan", url: "http://192.168.1.10:3773", reachable: true },
51+
{ kind: "tailscale", url: "http://100.64.0.1:3773", label: "macbook", reachable: true },
52+
],
53+
expiresAt: "2026-04-10T12:00:00Z",
54+
passwordRequired: false,
55+
};
56+
57+
expect(tryParseCompanionBundle(JSON.stringify(bundle))).toEqual({
58+
pairingId: "pair-123",
59+
bootstrapToken: "bootstrap-abc",
60+
endpoints: bundle.endpoints,
61+
expiresAt: "2026-04-10T12:00:00Z",
62+
passwordRequired: false,
63+
passwordHint: undefined,
64+
});
65+
});
66+
67+
it("parses a bundle with password required and hint", () => {
68+
const bundle = {
69+
pairingId: "pair-456",
70+
bootstrapToken: "bootstrap-xyz",
71+
endpoints: [{ kind: "manual", url: "http://mybox:3773", reachable: true }],
72+
expiresAt: "2026-04-10T13:00:00Z",
73+
passwordRequired: true,
74+
passwordHint: "The usual one",
75+
};
76+
77+
const result = tryParseCompanionBundle(JSON.stringify(bundle));
78+
expect(result).not.toBeNull();
79+
expect(result!.passwordRequired).toBe(true);
80+
expect(result!.passwordHint).toBe("The usual one");
81+
});
82+
83+
it("returns null for non-JSON input", () => {
84+
expect(tryParseCompanionBundle("okcode://pair?server=foo&token=bar")).toBeNull();
85+
});
86+
87+
it("returns null for JSON missing required fields", () => {
88+
expect(tryParseCompanionBundle(JSON.stringify({ pairingId: "abc" }))).toBeNull();
89+
});
90+
91+
it("returns null for empty input", () => {
92+
expect(tryParseCompanionBundle("")).toBeNull();
93+
});
94+
95+
it("ignores unknown extra fields in the bundle", () => {
96+
const bundle = {
97+
pairingId: "pair-789",
98+
bootstrapToken: "bootstrap-def",
99+
endpoints: [],
100+
expiresAt: "2026-04-10T14:00:00Z",
101+
passwordRequired: false,
102+
futureField: "should be ignored",
103+
};
104+
105+
const result = tryParseCompanionBundle(JSON.stringify(bundle));
106+
expect(result).not.toBeNull();
107+
expect(result!.pairingId).toBe("pair-789");
108+
});
109+
});

apps/mobile/src/mobilePairing.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ export interface ParsedMobilePairing {
44
wsUrl: string;
55
}
66

7+
/**
8+
* Parsed representation of the new companion pairing bundle.
9+
* This shape is forward-compatible with Milestone 2 where the mobile
10+
* client will exchange the bootstrap token for a device-scoped session.
11+
*/
12+
export interface ParsedCompanionBundle {
13+
pairingId: string;
14+
bootstrapToken: string;
15+
endpoints: Array<{ kind: string; url: string; label?: string; reachable: boolean }>;
16+
expiresAt: string;
17+
passwordRequired: boolean;
18+
passwordHint?: string;
19+
}
20+
721
const PAIRING_SCHEME = "okcode:";
822

923
function normalizeServerUrl(rawValue: string): URL {
@@ -66,3 +80,39 @@ export function parseMobilePairingInput(input: string): ParsedMobilePairing {
6680
wsUrl: createWsUrl(normalizedServerUrl, token),
6781
};
6882
}
83+
84+
/**
85+
* Attempt to parse a JSON companion pairing bundle.
86+
* Returns `null` if the input is not valid JSON or does not match the
87+
* expected shape, so callers can fall back to the legacy URL parser.
88+
*
89+
* This parser is intentionally lenient: it validates the minimal required
90+
* fields and ignores unexpected properties so that older clients remain
91+
* forward-compatible as the bundle schema evolves.
92+
*/
93+
export function tryParseCompanionBundle(input: string): ParsedCompanionBundle | null {
94+
try {
95+
const data = JSON.parse(input);
96+
if (
97+
typeof data !== "object" ||
98+
data === null ||
99+
typeof data.pairingId !== "string" ||
100+
typeof data.bootstrapToken !== "string" ||
101+
!Array.isArray(data.endpoints) ||
102+
typeof data.expiresAt !== "string"
103+
) {
104+
return null;
105+
}
106+
107+
return {
108+
pairingId: data.pairingId,
109+
bootstrapToken: data.bootstrapToken,
110+
endpoints: data.endpoints,
111+
expiresAt: data.expiresAt,
112+
passwordRequired: data.passwordRequired === true,
113+
passwordHint: typeof data.passwordHint === "string" ? data.passwordHint : undefined,
114+
};
115+
} catch {
116+
return null;
117+
}
118+
}

apps/server/src/wsServer.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1671,6 +1671,32 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
16711671
return { tokens };
16721672
}
16731673

1674+
// ── Companion pairing (placeholder) ─────────────────────────────
1675+
// These handlers are wired for type-exhaustiveness but return
1676+
// stub responses until the full companion session manager is built.
1677+
1678+
case WS_METHODS.serverGenerateCompanionPairingBundle: {
1679+
return yield* new RouteRequestError({
1680+
message: "Companion pairing bundle generation is not yet implemented.",
1681+
});
1682+
}
1683+
1684+
case WS_METHODS.serverExchangeCompanionBootstrap: {
1685+
return yield* new RouteRequestError({
1686+
message: "Companion bootstrap exchange is not yet implemented.",
1687+
});
1688+
}
1689+
1690+
case WS_METHODS.serverListPairedDevices: {
1691+
return { devices: [] };
1692+
}
1693+
1694+
case WS_METHODS.serverRevokePairedDevice: {
1695+
return yield* new RouteRequestError({
1696+
message: "Companion device revocation is not yet implemented.",
1697+
});
1698+
}
1699+
16741700
// ── OpenClaw gateway test ────────────────────────────────────────
16751701
case WS_METHODS.serverTestOpenclawGateway: {
16761702
const body = stripRequestTag(request.body);

apps/web/src/components/mobile/PairingLink.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@ export function PairingLink() {
127127
</Button>
128128
</div>
129129
<p className="max-w-xs text-center text-[11px] leading-relaxed text-muted-foreground/70">
130-
Copy the pairing link and paste it into the mobile app.
130+
Copy the pairing link and paste it into the mobile app. A new QR-based pairing flow is
131+
coming soon; this link method will continue to work.
131132
</p>
132133
</>
133134
) : loading ? (

packages/contracts/src/baseSchemas.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,9 @@ export const SmeDocumentId = makeEntityId("SmeDocumentId");
4949
export type SmeDocumentId = typeof SmeDocumentId.Type;
5050
export const SmeMessageId = makeEntityId("SmeMessageId");
5151
export type SmeMessageId = typeof SmeMessageId.Type;
52+
53+
// ── Companion Pairing IDs ───────────────────────────────────────────
54+
export const PairingId = makeEntityId("PairingId");
55+
export type PairingId = typeof PairingId.Type;
56+
export const DeviceId = makeEntityId("DeviceId");
57+
export type DeviceId = typeof DeviceId.Type;

packages/contracts/src/server.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Schema } from "effect";
2-
import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas";
2+
import { DeviceId, IsoDateTime, PairingId, TrimmedNonEmptyString } from "./baseSchemas";
33
import { BuildMetadata } from "./buildInfo";
44
import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
55
import { EditorId } from "./editor";
@@ -135,6 +135,93 @@ export const ListTokensResult = Schema.Struct({
135135
});
136136
export type ListTokensResult = typeof ListTokensResult.Type;
137137

138+
// ── Companion Pairing (new model) ──────────────────────────────────
139+
// The companion pairing model replaces the single-token deep-link flow
140+
// with endpoint-aware bundles and device-scoped sessions. The legacy
141+
// `GeneratePairingLinkInput`/`GeneratePairingLinkResult` contracts above
142+
// remain supported during rollout.
143+
144+
export const CompanionEndpointKind = Schema.Literals(["tailscale", "lan", "manual"]);
145+
export type CompanionEndpointKind = typeof CompanionEndpointKind.Type;
146+
147+
export const CompanionEndpoint = Schema.Struct({
148+
kind: CompanionEndpointKind,
149+
url: TrimmedNonEmptyString,
150+
label: Schema.optional(TrimmedNonEmptyString),
151+
reachable: Schema.Boolean,
152+
});
153+
export type CompanionEndpoint = typeof CompanionEndpoint.Type;
154+
155+
export const CompanionPairingBundle = Schema.Struct({
156+
pairingId: PairingId,
157+
expiresAt: IsoDateTime,
158+
endpoints: Schema.Array(CompanionEndpoint),
159+
bootstrapToken: TrimmedNonEmptyString,
160+
passwordRequired: Schema.Boolean,
161+
passwordHint: Schema.optional(TrimmedNonEmptyString),
162+
});
163+
export type CompanionPairingBundle = typeof CompanionPairingBundle.Type;
164+
165+
export const PairedDeviceSession = Schema.Struct({
166+
deviceId: DeviceId,
167+
deviceName: TrimmedNonEmptyString,
168+
serverUrl: TrimmedNonEmptyString,
169+
sessionToken: TrimmedNonEmptyString,
170+
issuedAt: IsoDateTime,
171+
expiresAt: Schema.NullOr(IsoDateTime),
172+
lastSeenAt: Schema.NullOr(IsoDateTime),
173+
});
174+
export type PairedDeviceSession = typeof PairedDeviceSession.Type;
175+
176+
// ── Companion RPC Inputs/Outputs ───────────────────────────────────
177+
178+
export const GenerateCompanionPairingBundleInput = Schema.Struct({
179+
/** Lifetime in seconds for the bootstrap token. Defaults to 300 (5 min). */
180+
ttlSeconds: Schema.optional(Schema.Number),
181+
/** Desktop-advertised endpoints to include in the bundle. */
182+
advertisedEndpoints: Schema.optional(Schema.Array(CompanionEndpoint)),
183+
});
184+
export type GenerateCompanionPairingBundleInput = typeof GenerateCompanionPairingBundleInput.Type;
185+
186+
export const GenerateCompanionPairingBundleResult = CompanionPairingBundle;
187+
export type GenerateCompanionPairingBundleResult = typeof GenerateCompanionPairingBundleResult.Type;
188+
189+
export const ExchangeCompanionBootstrapInput = Schema.Struct({
190+
bootstrapToken: TrimmedNonEmptyString,
191+
endpointUrl: TrimmedNonEmptyString,
192+
password: Schema.optional(Schema.String),
193+
deviceName: TrimmedNonEmptyString,
194+
});
195+
export type ExchangeCompanionBootstrapInput = typeof ExchangeCompanionBootstrapInput.Type;
196+
197+
export const ExchangeCompanionBootstrapResult = PairedDeviceSession;
198+
export type ExchangeCompanionBootstrapResult = typeof ExchangeCompanionBootstrapResult.Type;
199+
200+
export const ListPairedDevicesResult = Schema.Struct({
201+
devices: Schema.Array(
202+
Schema.Struct({
203+
deviceId: DeviceId,
204+
deviceName: TrimmedNonEmptyString,
205+
issuedAt: IsoDateTime,
206+
lastSeenAt: Schema.NullOr(IsoDateTime),
207+
endpointKind: Schema.optional(CompanionEndpointKind),
208+
revoked: Schema.Boolean,
209+
}),
210+
),
211+
});
212+
export type ListPairedDevicesResult = typeof ListPairedDevicesResult.Type;
213+
214+
export const RevokePairedDeviceInput = Schema.Struct({
215+
deviceId: DeviceId,
216+
});
217+
export type RevokePairedDeviceInput = typeof RevokePairedDeviceInput.Type;
218+
219+
export const RevokePairedDeviceResult = Schema.Struct({
220+
deviceId: DeviceId,
221+
revoked: Schema.Boolean,
222+
});
223+
export type RevokePairedDeviceResult = typeof RevokePairedDeviceResult.Type;
224+
138225
// ── OpenClaw Gateway Test ───────────────────────────────────────────
139226

140227
export const TestOpenclawGatewayInput = Schema.Struct({

packages/contracts/src/ws.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,85 @@ it.effect("rejects push envelopes when channel payload does not match the channe
188188
assert.strictEqual(result._tag, "Failure");
189189
}),
190190
);
191+
192+
// ── Companion pairing contract tests ─────────────────────────────────
193+
194+
it.effect("accepts generateCompanionPairingBundle requests", () =>
195+
Effect.gen(function* () {
196+
const parsed = yield* decodeWebSocketRequest({
197+
id: "req-cpb-1",
198+
body: {
199+
_tag: WS_METHODS.serverGenerateCompanionPairingBundle,
200+
ttlSeconds: 600,
201+
advertisedEndpoints: [{ kind: "lan", url: "http://192.168.1.10:3773", reachable: true }],
202+
},
203+
});
204+
assert.strictEqual(parsed.body._tag, WS_METHODS.serverGenerateCompanionPairingBundle);
205+
}),
206+
);
207+
208+
it.effect("accepts generateCompanionPairingBundle with no optional fields", () =>
209+
Effect.gen(function* () {
210+
const parsed = yield* decodeWebSocketRequest({
211+
id: "req-cpb-2",
212+
body: {
213+
_tag: WS_METHODS.serverGenerateCompanionPairingBundle,
214+
},
215+
});
216+
assert.strictEqual(parsed.body._tag, WS_METHODS.serverGenerateCompanionPairingBundle);
217+
}),
218+
);
219+
220+
it.effect("accepts exchangeCompanionBootstrap requests", () =>
221+
Effect.gen(function* () {
222+
const parsed = yield* decodeWebSocketRequest({
223+
id: "req-ecb-1",
224+
body: {
225+
_tag: WS_METHODS.serverExchangeCompanionBootstrap,
226+
bootstrapToken: "abc123",
227+
endpointUrl: "http://192.168.1.10:3773",
228+
deviceName: "My Phone",
229+
},
230+
});
231+
assert.strictEqual(parsed.body._tag, WS_METHODS.serverExchangeCompanionBootstrap);
232+
}),
233+
);
234+
235+
it.effect("accepts exchangeCompanionBootstrap with password", () =>
236+
Effect.gen(function* () {
237+
const parsed = yield* decodeWebSocketRequest({
238+
id: "req-ecb-2",
239+
body: {
240+
_tag: WS_METHODS.serverExchangeCompanionBootstrap,
241+
bootstrapToken: "abc123",
242+
endpointUrl: "http://192.168.1.10:3773",
243+
password: "hunter2",
244+
deviceName: "My Phone",
245+
},
246+
});
247+
assert.strictEqual(parsed.body._tag, WS_METHODS.serverExchangeCompanionBootstrap);
248+
}),
249+
);
250+
251+
it.effect("accepts listPairedDevices requests", () =>
252+
Effect.gen(function* () {
253+
const parsed = yield* decodeWebSocketRequest({
254+
id: "req-lpd-1",
255+
body: { _tag: WS_METHODS.serverListPairedDevices },
256+
});
257+
assert.strictEqual(parsed.body._tag, WS_METHODS.serverListPairedDevices);
258+
}),
259+
);
260+
261+
it.effect("accepts revokePairedDevice requests", () =>
262+
Effect.gen(function* () {
263+
const parsed = yield* decodeWebSocketRequest({
264+
id: "req-rpd-1",
265+
body: {
266+
_tag: WS_METHODS.serverRevokePairedDevice,
267+
deviceId: "device-abc",
268+
},
269+
});
270+
assert.strictEqual(parsed.body._tag, WS_METHODS.serverRevokePairedDevice);
271+
}),
272+
);

0 commit comments

Comments
 (0)