diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd774e2d0..f7e139f9f2 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 b6d96d06a5..7f89bb100d 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 0ac721a424..6539e8ff4e 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 98f1e0e529..c57d8355b5 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 8163be27a3..752347a092 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 68670a3d7e..2d273aab63 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 ead66ca789..42cc3427c1 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 bb691f08ea..1bff6bf88b 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 5a4394bc26..c9d8e703fd 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 ebf7d7f947..d1011c88df 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 2335c2f7d7..ab0864b9f4 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 8a0dab286e..884f2e9dd3 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 8737fd31e6..9255306227 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;