From b114c827016cc2f1c8979fb468209ee03bd788f4 Mon Sep 17 00:00:00 2001 From: Mariano Belinky Date: Tue, 17 Feb 2026 14:08:04 +0000 Subject: [PATCH] CLI: approve latest pending device request --- docs/cli/devices.md | 7 ++- src/cli/devices-cli.test.ts | 115 ++++++++++++++++++++++++++++++++++++ src/cli/devices-cli.ts | 36 +++++++++-- 3 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 src/cli/devices-cli.test.ts diff --git a/docs/cli/devices.md b/docs/cli/devices.md index 670960104df..edacf9a2876 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -21,12 +21,15 @@ openclaw devices list openclaw devices list --json ``` -### `openclaw devices approve ` +### `openclaw devices approve [requestId] [--latest]` -Approve a pending device pairing request. +Approve a pending device pairing request. If `requestId` is omitted, OpenClaw +automatically approves the most recent pending request. ``` +openclaw devices approve openclaw devices approve +openclaw devices approve --latest ``` ### `openclaw devices reject ` diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts new file mode 100644 index 00000000000..b6d96d06a59 --- /dev/null +++ b/src/cli/devices-cli.test.ts @@ -0,0 +1,115 @@ +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(); +const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise) => await fn()); +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +vi.mock("../gateway/call.js", () => ({ + callGateway, +})); + +vi.mock("./progress.js", () => ({ + withProgress, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: runtime, +})); + +async function runDevicesApprove(argv: string[]) { + const { registerDevicesCli } = await import("./devices-cli.js"); + const program = new Command(); + registerDevicesCli(program); + await program.parseAsync(["devices", "approve", ...argv], { from: "user" }); +} + +describe("devices cli approve", () => { + afterEach(() => { + callGateway.mockReset(); + withProgress.mockClear(); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); + }); + + it("approves an explicit request id without listing", async () => { + callGateway.mockResolvedValueOnce({ device: { deviceId: "device-1" } }); + + await runDevicesApprove(["req-123"]); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "device.pair.approve", + params: { requestId: "req-123" }, + }), + ); + }); + + it("auto-approves the latest pending request when id is omitted", async () => { + callGateway + .mockResolvedValueOnce({ + pending: [ + { requestId: "req-1", ts: 1000 }, + { requestId: "req-2", ts: 2000 }, + ], + }) + .mockResolvedValueOnce({ device: { deviceId: "device-2" } }); + + await runDevicesApprove([]); + + expect(callGateway).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: "device.pair.list" }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "device.pair.approve", + params: { requestId: "req-2" }, + }), + ); + }); + + it("uses latest pending request when --latest is passed", async () => { + callGateway + .mockResolvedValueOnce({ + pending: [ + { requestId: "req-2", ts: 2000 }, + { requestId: "req-3", ts: 3000 }, + ], + }) + .mockResolvedValueOnce({ device: { deviceId: "device-3" } }); + + await runDevicesApprove(["req-old", "--latest"]); + + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "device.pair.approve", + params: { requestId: "req-3" }, + }), + ); + }); + + it("prints an error and exits when no pending requests are available", async () => { + callGateway.mockResolvedValueOnce({ pending: [] }); + + await runDevicesApprove([]); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ method: "device.pair.list" }), + ); + expect(runtime.error).toHaveBeenCalledWith("No pending device pairing requests to approve"); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(callGateway).not.toHaveBeenCalledWith( + expect.objectContaining({ method: "device.pair.approve" }), + ); + }); +}); diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 9635ce27114..0ac721a424f 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -13,6 +13,7 @@ type DevicesRpcOpts = { password?: string; timeout?: string; json?: boolean; + latest?: boolean; device?: string; role?: string; scope?: string[]; @@ -86,6 +87,17 @@ function parseDevicePairingList(value: unknown): DevicePairingList { }; } +function selectLatestPendingRequest(pending: PendingDevice[] | undefined) { + if (!pending?.length) { + return null; + } + return pending.reduce((latest, current) => { + const latestTs = typeof latest.ts === "number" ? latest.ts : 0; + const currentTs = typeof current.ts === "number" ? current.ts : 0; + return currentTs > latestTs ? current : latest; + }); +} + function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) { if (!tokens || tokens.length === 0) { return "none"; @@ -172,15 +184,31 @@ export function registerDevicesCli(program: Command) { devices .command("approve") .description("Approve a pending device pairing request") - .argument("", "Pending request id") - .action(async (requestId: string, opts: DevicesRpcOpts) => { - const result = await callGatewayCli("device.pair.approve", opts, { requestId }); + .argument("[requestId]", "Pending request id") + .option("--latest", "Approve the most recent pending request", false) + .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); + resolvedRequestId = latest?.requestId?.trim(); + } + if (!resolvedRequestId) { + defaultRuntime.error("No pending device pairing requests to approve"); + defaultRuntime.exit(1); + return; + } + const result = await callGatewayCli("device.pair.approve", opts, { + requestId: resolvedRequestId, + }); if (opts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; } const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId; - defaultRuntime.log(`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")}`); + defaultRuntime.log( + `${theme.success("Approved")} ${theme.command(deviceId ?? "ok")} ${theme.muted(`(${resolvedRequestId})`)}`, + ); }), );