diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd774e2d..f7e139f9f 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 b6d96d06a..7f89bb100 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 0ac721a42..6539e8ff4 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 98f1e0e52..c57d8355b 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 8163be27a..752347a09 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 68670a3d7..2d273aab6 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 ead66ca78..42cc3427c 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 bb691f08e..1bff6bf88 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 5a4394bc2..c9d8e703f 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 ebf7d7f94..d1011c88d 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 2335c2f7d..ab0864b9f 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 8a0dab286..884f2e9dd 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 8737fd31e..925530622 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;