From 1437ed76a03ff115682aef6e48b0fa4faccfa12c Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:27:31 +0000 Subject: [PATCH] Gateway/CLI: add paired-device remove and clear flows (#20057) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 26523f8a38148073412cf24590176be9a6ab1237 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 2 + src/cli/devices-cli.test.ts | 79 ++++++++++++++++++ src/cli/devices-cli.ts | 82 +++++++++++++++++++ src/gateway/protocol/index.ts | 5 ++ src/gateway/protocol/schema/devices.ts | 5 ++ .../protocol/schema/protocol-schemas.ts | 2 + src/gateway/protocol/schema/types.ts | 2 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods.ts | 1 + src/gateway/server-methods/devices.ts | 25 ++++++ src/infra/device-pairing.test.ts | 12 +++ src/infra/device-pairing.ts | 16 ++++ src/test-utils/model-auth-mock.ts | 9 +- 13 files changed, 239 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd774e2d051..f7e139f9f2cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. + ### Fixes - iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. diff --git a/src/cli/devices-cli.test.ts b/src/cli/devices-cli.test.ts index b6d96d06a598..7f89bb100ddc 100644 --- a/src/cli/devices-cli.test.ts +++ b/src/cli/devices-cli.test.ts @@ -28,6 +28,13 @@ async function runDevicesApprove(argv: string[]) { await program.parseAsync(["devices", "approve", ...argv], { from: "user" }); } +async function runDevicesCommand(argv: string[]) { + const { registerDevicesCli } = await import("./devices-cli.js"); + const program = new Command(); + registerDevicesCli(program); + await program.parseAsync(["devices", ...argv], { from: "user" }); +} + describe("devices cli approve", () => { afterEach(() => { callGateway.mockReset(); @@ -113,3 +120,75 @@ describe("devices cli approve", () => { ); }); }); + +describe("devices cli remove", () => { + afterEach(() => { + callGateway.mockReset(); + withProgress.mockClear(); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); + }); + + it("removes a paired device by id", async () => { + callGateway.mockResolvedValueOnce({ deviceId: "device-1" }); + + await runDevicesCommand(["remove", "device-1"]); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "device.pair.remove", + params: { deviceId: "device-1" }, + }), + ); + }); +}); + +describe("devices cli clear", () => { + afterEach(() => { + callGateway.mockReset(); + withProgress.mockClear(); + runtime.log.mockReset(); + runtime.error.mockReset(); + runtime.exit.mockReset(); + }); + + it("requires --yes before clearing", async () => { + await runDevicesCommand(["clear"]); + + expect(callGateway).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith("Refusing to clear pairing table without --yes"); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("clears paired devices and optionally pending requests", async () => { + callGateway + .mockResolvedValueOnce({ + paired: [{ deviceId: "device-1" }, { deviceId: "device-2" }], + pending: [{ requestId: "req-1" }], + }) + .mockResolvedValueOnce({ deviceId: "device-1" }) + .mockResolvedValueOnce({ deviceId: "device-2" }) + .mockResolvedValueOnce({ requestId: "req-1", deviceId: "device-1" }); + + await runDevicesCommand(["clear", "--yes", "--pending"]); + + expect(callGateway).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: "device.pair.list" }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-1" } }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ method: "device.pair.remove", params: { deviceId: "device-2" } }), + ); + expect(callGateway).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ method: "device.pair.reject", params: { requestId: "req-1" } }), + ); + }); +}); diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 0ac721a424f8..6539e8ff4e77 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -14,6 +14,8 @@ type DevicesRpcOpts = { timeout?: string; json?: boolean; latest?: boolean; + yes?: boolean; + pending?: boolean; device?: string; role?: string; scope?: string[]; @@ -180,6 +182,86 @@ export function registerDevicesCli(program: Command) { }), ); + devicesCallOpts( + devices + .command("remove") + .description("Remove a paired device entry") + .argument("", "Paired device id") + .action(async (deviceId: string, opts: DevicesRpcOpts) => { + const trimmed = deviceId.trim(); + if (!trimmed) { + defaultRuntime.error("deviceId is required"); + defaultRuntime.exit(1); + return; + } + const result = await callGatewayCli("device.pair.remove", opts, { deviceId: trimmed }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + defaultRuntime.log(`${theme.warn("Removed")} ${theme.command(trimmed)}`); + }), + ); + + devicesCallOpts( + devices + .command("clear") + .description("Clear paired devices from the gateway table") + .option("--pending", "Also reject all pending pairing requests", false) + .option("--yes", "Confirm destructive clear", false) + .action(async (opts: DevicesRpcOpts) => { + if (!opts.yes) { + defaultRuntime.error("Refusing to clear pairing table without --yes"); + defaultRuntime.exit(1); + return; + } + const list = parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {})); + const removedDeviceIds: string[] = []; + const rejectedRequestIds: string[] = []; + const paired = Array.isArray(list.paired) ? list.paired : []; + for (const device of paired) { + const deviceId = typeof device.deviceId === "string" ? device.deviceId.trim() : ""; + if (!deviceId) { + continue; + } + await callGatewayCli("device.pair.remove", opts, { deviceId }); + removedDeviceIds.push(deviceId); + } + if (opts.pending) { + const pending = Array.isArray(list.pending) ? list.pending : []; + for (const req of pending) { + const requestId = typeof req.requestId === "string" ? req.requestId.trim() : ""; + if (!requestId) { + continue; + } + await callGatewayCli("device.pair.reject", opts, { requestId }); + rejectedRequestIds.push(requestId); + } + } + if (opts.json) { + defaultRuntime.log( + JSON.stringify( + { + removedDevices: removedDeviceIds, + rejectedPending: rejectedRequestIds, + }, + null, + 2, + ), + ); + return; + } + defaultRuntime.log( + `${theme.warn("Cleared")} ${removedDeviceIds.length} paired device${removedDeviceIds.length === 1 ? "" : "s"}`, + ); + if (opts.pending) { + defaultRuntime.log( + `${theme.warn("Rejected")} ${rejectedRequestIds.length} pending request${rejectedRequestIds.length === 1 ? "" : "s"}`, + ); + } + }), + ); + devicesCallOpts( devices .command("approve") diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 98f1e0e529c4..c57d8355b54a 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -95,6 +95,8 @@ import { DevicePairApproveParamsSchema, type DevicePairListParams, DevicePairListParamsSchema, + type DevicePairRemoveParams, + DevicePairRemoveParamsSchema, type DevicePairRejectParams, DevicePairRejectParamsSchema, type DeviceTokenRevokeParams, @@ -333,6 +335,9 @@ export const validateDevicePairApproveParams = ajv.compile( DevicePairRejectParamsSchema, ); +export const validateDevicePairRemoveParams = ajv.compile( + DevicePairRemoveParamsSchema, +); export const validateDeviceTokenRotateParams = ajv.compile( DeviceTokenRotateParamsSchema, ); diff --git a/src/gateway/protocol/schema/devices.ts b/src/gateway/protocol/schema/devices.ts index 8163be27a3b4..752347a092f4 100644 --- a/src/gateway/protocol/schema/devices.ts +++ b/src/gateway/protocol/schema/devices.ts @@ -13,6 +13,11 @@ export const DevicePairRejectParamsSchema = Type.Object( { additionalProperties: false }, ); +export const DevicePairRemoveParamsSchema = Type.Object( + { deviceId: NonEmptyString }, + { additionalProperties: false }, +); + export const DeviceTokenRotateParamsSchema = Type.Object( { deviceId: NonEmptyString, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 68670a3d7ed2..2d273aab6333 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -68,6 +68,7 @@ import { import { DevicePairApproveParamsSchema, DevicePairListParamsSchema, + DevicePairRemoveParamsSchema, DevicePairRejectParamsSchema, DevicePairRequestedEventSchema, DevicePairResolvedEventSchema, @@ -245,6 +246,7 @@ export const ProtocolSchemas: Record = { DevicePairListParams: DevicePairListParamsSchema, DevicePairApproveParams: DevicePairApproveParamsSchema, DevicePairRejectParams: DevicePairRejectParamsSchema, + DevicePairRemoveParams: DevicePairRemoveParamsSchema, DeviceTokenRotateParams: DeviceTokenRotateParamsSchema, DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema, DevicePairRequestedEvent: DevicePairRequestedEventSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index ead66ca789b5..42cc3427c1e3 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -66,6 +66,7 @@ import type { import type { DevicePairApproveParamsSchema, DevicePairListParamsSchema, + DevicePairRemoveParamsSchema, DevicePairRejectParamsSchema, DeviceTokenRevokeParamsSchema, DeviceTokenRotateParamsSchema, @@ -234,6 +235,7 @@ export type ExecApprovalResolveParams = Static; export type DevicePairApproveParams = Static; export type DevicePairRejectParams = Static; +export type DevicePairRemoveParams = Static; export type DeviceTokenRotateParams = Static; export type DeviceTokenRevokeParams = Static; export type ChatAbortParams = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index bb691f08ea39..1bff6bf88bb9 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -64,6 +64,7 @@ const BASE_METHODS = [ "device.pair.list", "device.pair.approve", "device.pair.reject", + "device.pair.remove", "device.token.rotate", "device.token.revoke", "node.rename", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 5a4394bc26db..c9d8e703fde8 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -47,6 +47,7 @@ const PAIRING_METHODS = new Set([ "device.pair.list", "device.pair.approve", "device.pair.reject", + "device.pair.remove", "device.token.rotate", "device.token.revoke", "node.rename", diff --git a/src/gateway/server-methods/devices.ts b/src/gateway/server-methods/devices.ts index ebf7d7f9474c..d1011c88dff7 100644 --- a/src/gateway/server-methods/devices.ts +++ b/src/gateway/server-methods/devices.ts @@ -1,6 +1,7 @@ import { approveDevicePairing, listDevicePairing, + removePairedDevice, type DeviceAuthToken, rejectDevicePairing, revokeDeviceToken, @@ -13,6 +14,7 @@ import { formatValidationErrors, validateDevicePairApproveParams, validateDevicePairListParams, + validateDevicePairRemoveParams, validateDevicePairRejectParams, validateDeviceTokenRevokeParams, validateDeviceTokenRotateParams, @@ -121,6 +123,29 @@ export const deviceHandlers: GatewayRequestHandlers = { ); respond(true, rejected, undefined); }, + "device.pair.remove": async ({ params, respond, context }) => { + if (!validateDevicePairRemoveParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid device.pair.remove params: ${formatValidationErrors( + validateDevicePairRemoveParams.errors, + )}`, + ), + ); + return; + } + const { deviceId } = params as { deviceId: string }; + const removed = await removePairedDevice(deviceId); + if (!removed) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId")); + return; + } + context.logGateway.info(`device pairing removed device=${removed.deviceId}`); + respond(true, removed, undefined); + }, "device.token.rotate": async ({ params, respond, context }) => { if (!validateDeviceTokenRotateParams(params)) { respond( diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index 2335c2f7d74d..ab0864b9f4c4 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest"; import { approveDevicePairing, getPairedDevice, + removePairedDevice, requestDevicePairing, rotateDeviceToken, verifyDeviceToken, @@ -109,4 +110,15 @@ describe("device pairing tokens", () => { }), ).resolves.toEqual({ ok: false, reason: "token-mismatch" }); }); + + test("removes paired devices by device id", async () => { + const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-")); + await setupPairedOperatorDevice(baseDir, ["operator.read"]); + + const removed = await removePairedDevice("device-1", baseDir); + expect(removed).toEqual({ deviceId: "device-1" }); + await expect(getPairedDevice("device-1", baseDir)).resolves.toBeNull(); + + await expect(removePairedDevice("device-1", baseDir)).resolves.toBeNull(); + }); }); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 8a0dab286ed6..884f2e9dd313 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -321,6 +321,22 @@ export async function rejectDevicePairing( }); } +export async function removePairedDevice( + deviceId: string, + baseDir?: string, +): Promise<{ deviceId: string } | null> { + return await withLock(async () => { + const state = await loadState(baseDir); + const normalized = normalizeDeviceId(deviceId); + if (!normalized || !state.pairedByDeviceId[normalized]) { + return null; + } + delete state.pairedByDeviceId[normalized]; + await persistState(state, baseDir); + return { deviceId: normalized }; + }); +} + export async function updatePairedDeviceMetadata( deviceId: string, patch: Partial>, diff --git a/src/test-utils/model-auth-mock.ts b/src/test-utils/model-auth-mock.ts index 8737fd31e6cf..925530622739 100644 --- a/src/test-utils/model-auth-mock.ts +++ b/src/test-utils/model-auth-mock.ts @@ -1,8 +1,13 @@ import { vi } from "vitest"; -export function createModelAuthMockModule() { +type ModelAuthMockModule = { + resolveApiKeyForProvider: (...args: unknown[]) => unknown; + requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => string; +}; + +export function createModelAuthMockModule(): ModelAuthMockModule { return { - resolveApiKeyForProvider: vi.fn(), + resolveApiKeyForProvider: vi.fn() as (...args: unknown[]) => unknown, requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => { if (auth?.apiKey) { return auth.apiKey;