Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion apps/mobile/src/mobilePairing.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
50 changes: 50 additions & 0 deletions apps/mobile/src/mobilePairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
26 changes: 26 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/mobile/PairingLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ export function PairingLink() {
</Button>
</div>
<p className="max-w-xs text-center text-[11px] leading-relaxed text-muted-foreground/70">
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.
</p>
</>
) : loading ? (
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/baseSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
89 changes: 88 additions & 1 deletion packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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({
Expand Down
82 changes: 82 additions & 0 deletions packages/contracts/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
);
Loading
Loading