diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index 7c6ac6a7eec..247ae936f06 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -2,6 +2,14 @@ import { Command } from "commander"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const callGateway = vi.fn(); +const buildGatewayConnectionDetails = vi.fn(() => ({ + url: "ws://127.0.0.1:18789", + urlSource: "local loopback", + message: "", +})); +const listDevicePairing = vi.fn(); +const approveDevicePairing = vi.fn(); +const summarizeDeviceTokens = vi.fn(); const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise) => await fn()); const runtime = { log: vi.fn(), @@ -11,12 +19,19 @@ const runtime = { vi.mock("../gateway/call.js", () => ({ callGateway, + buildGatewayConnectionDetails, })); vi.mock("./progress.js", () => ({ withProgress, })); +vi.mock("../infra/device-pairing.js", () => ({ + listDevicePairing, + approveDevicePairing, + summarizeDeviceTokens, +})); + vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); @@ -216,8 +231,73 @@ describe("devices cli tokens", () => { }); }); +describe("devices cli local fallback", () => { + const fallbackNotice = "Direct scope access failed; using local fallback."; + + it("falls back to local pairing list when gateway returns pairing required on loopback", async () => { + callGateway.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")); + listDevicePairing.mockResolvedValueOnce({ + pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk", ts: 1 }], + paired: [], + }); + summarizeDeviceTokens.mockReturnValue(undefined); + + await runDevicesCommand(["list"]); + + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ method: "device.pair.list" }), + ); + expect(listDevicePairing).toHaveBeenCalledTimes(1); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); + }); + + it("falls back to local approve when gateway returns pairing required on loopback", async () => { + callGateway + .mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")) + .mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")); + listDevicePairing.mockResolvedValueOnce({ + pending: [{ requestId: "req-latest", deviceId: "device-1", publicKey: "pk", ts: 2 }], + paired: [], + }); + approveDevicePairing.mockResolvedValueOnce({ + requestId: "req-latest", + device: { + deviceId: "device-1", + publicKey: "pk", + approvedAtMs: 1, + createdAtMs: 1, + }, + }); + summarizeDeviceTokens.mockReturnValue(undefined); + + await runDevicesApprove(["--latest"]); + + expect(approveDevicePairing).toHaveBeenCalledWith("req-latest"); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved")); + }); + + it("does not use local fallback when an explicit --url is provided", async () => { + callGateway.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")); + + await expect( + runDevicesCommand(["list", "--json", "--url", "ws://127.0.0.1:18789"]), + ).rejects.toThrow("pairing required"); + expect(listDevicePairing).not.toHaveBeenCalled(); + }); +}); + afterEach(() => { callGateway.mockReset(); + buildGatewayConnectionDetails.mockReset(); + buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "local loopback", + message: "", + }); + listDevicePairing.mockReset(); + approveDevicePairing.mockReset(); + summarizeDeviceTokens.mockReset(); withProgress.mockClear(); runtime.log.mockReset(); runtime.error.mockReset(); diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index b702c51824e..0344bf7967a 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -1,5 +1,12 @@ import type { Command } from "commander"; -import { callGateway } from "../gateway/call.js"; +import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { isLoopbackHost } from "../gateway/net.js"; +import { + approveDevicePairing, + listDevicePairing, + summarizeDeviceTokens, + type PairedDevice as InfraPairedDevice, +} from "../infra/device-pairing.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../runtime.js"; import { renderTable } from "../terminal/table.js"; @@ -53,6 +60,8 @@ type DevicePairingList = { paired?: PairedDevice[]; }; +const FALLBACK_NOTICE = "Direct scope access failed; using local fallback."; + const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) => cmd .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") @@ -81,6 +90,84 @@ const callGatewayCli = async (method: string, opts: DevicesRpcOpts, params?: unk }), ); +function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function shouldUseLocalPairingFallback(opts: DevicesRpcOpts, error: unknown): boolean { + const message = normalizeErrorMessage(error).toLowerCase(); + if (!message.includes("pairing required")) { + return false; + } + if (typeof opts.url === "string" && opts.url.trim().length > 0) { + // Explicit --url might point at a remote/tunneled gateway; never silently + // switch to local pairing files in that case. + return false; + } + const connection = buildGatewayConnectionDetails(); + if (connection.urlSource !== "local loopback") { + return false; + } + try { + return isLoopbackHost(new URL(connection.url).hostname); + } catch { + return false; + } +} + +function redactLocalPairedDevice(device: InfraPairedDevice): PairedDevice { + const { tokens, ...rest } = device; + return { + ...(rest as unknown as PairedDevice), + tokens: summarizeDeviceTokens(tokens) as DeviceTokenSummary[] | undefined, + }; +} + +async function listPairingWithFallback(opts: DevicesRpcOpts): Promise { + try { + return parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {})); + } catch (error) { + if (!shouldUseLocalPairingFallback(opts, error)) { + throw error; + } + if (opts.json !== true) { + defaultRuntime.log(theme.warn(FALLBACK_NOTICE)); + } + const local = await listDevicePairing(); + return { + pending: local.pending as PendingDevice[], + paired: local.paired.map((device) => redactLocalPairedDevice(device)), + }; + } +} + +async function approvePairingWithFallback( + opts: DevicesRpcOpts, + requestId: string, +): Promise | null> { + try { + return await callGatewayCli("device.pair.approve", opts, { requestId }); + } catch (error) { + if (!shouldUseLocalPairingFallback(opts, error)) { + throw error; + } + if (opts.json !== true) { + defaultRuntime.log(theme.warn(FALLBACK_NOTICE)); + } + const approved = await approveDevicePairing(requestId); + if (!approved) { + return null; + } + return { + requestId, + device: redactLocalPairedDevice(approved.device), + }; + } +} + function parseDevicePairingList(value: unknown): DevicePairingList { const obj = typeof value === "object" && value !== null ? (value as Record) : {}; return { @@ -131,8 +218,7 @@ export function registerDevicesCli(program: Command) { .command("list") .description("List pending and paired devices") .action(async (opts: DevicesRpcOpts) => { - const result = await callGatewayCli("device.pair.list", opts, {}); - const list = parseDevicePairingList(result); + const list = await listPairingWithFallback(opts); if (opts.json) { defaultRuntime.log(JSON.stringify(list, null, 2)); return; @@ -284,8 +370,7 @@ export function registerDevicesCli(program: Command) { .action(async (requestId: string | undefined, opts: DevicesRpcOpts) => { let resolvedRequestId = requestId?.trim(); if (!resolvedRequestId || opts.latest) { - const listResult = await callGatewayCli("device.pair.list", opts, {}); - const latest = selectLatestPendingRequest(parseDevicePairingList(listResult).pending); + const latest = selectLatestPendingRequest((await listPairingWithFallback(opts)).pending); resolvedRequestId = latest?.requestId?.trim(); } if (!resolvedRequestId) { @@ -293,9 +378,12 @@ export function registerDevicesCli(program: Command) { defaultRuntime.exit(1); return; } - const result = await callGatewayCli("device.pair.approve", opts, { - requestId: resolvedRequestId, - }); + const result = await approvePairingWithFallback(opts, resolvedRequestId); + if (!result) { + defaultRuntime.error("unknown requestId"); + defaultRuntime.exit(1); + return; + } if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return;