CLI: approve latest pending device request

This commit is contained in:
Mariano Belinky
2026-02-17 14:08:04 +00:00
parent cc359d338e
commit b114c82701
3 changed files with 152 additions and 6 deletions

View File

@@ -21,12 +21,15 @@ openclaw devices list
openclaw devices list --json
```
### `openclaw devices approve <requestId>`
### `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 <requestId>
openclaw devices approve --latest
```
### `openclaw devices reject <requestId>`

115
src/cli/devices-cli.test.ts Normal file
View File

@@ -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<unknown>) => 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" }),
);
});
});

View File

@@ -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("<requestId>", "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})`)}`,
);
}),
);