Gateway/CLI: add paired-device remove and clear flows (#20057)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 26523f8a38
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-18 13:27:31 +00:00
committed by GitHub
parent fc65f70a9b
commit 1437ed76a0
13 changed files with 239 additions and 2 deletions

View File

@@ -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.

View File

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

View File

@@ -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("<deviceId>", "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")

View File

@@ -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<DevicePairApprovePara
export const validateDevicePairRejectParams = ajv.compile<DevicePairRejectParams>(
DevicePairRejectParamsSchema,
);
export const validateDevicePairRemoveParams = ajv.compile<DevicePairRemoveParams>(
DevicePairRemoveParamsSchema,
);
export const validateDeviceTokenRotateParams = ajv.compile<DeviceTokenRotateParams>(
DeviceTokenRotateParamsSchema,
);

View File

@@ -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,

View File

@@ -68,6 +68,7 @@ import {
import {
DevicePairApproveParamsSchema,
DevicePairListParamsSchema,
DevicePairRemoveParamsSchema,
DevicePairRejectParamsSchema,
DevicePairRequestedEventSchema,
DevicePairResolvedEventSchema,
@@ -245,6 +246,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
DevicePairListParams: DevicePairListParamsSchema,
DevicePairApproveParams: DevicePairApproveParamsSchema,
DevicePairRejectParams: DevicePairRejectParamsSchema,
DevicePairRemoveParams: DevicePairRemoveParamsSchema,
DeviceTokenRotateParams: DeviceTokenRotateParamsSchema,
DeviceTokenRevokeParams: DeviceTokenRevokeParamsSchema,
DevicePairRequestedEvent: DevicePairRequestedEventSchema,

View File

@@ -66,6 +66,7 @@ import type {
import type {
DevicePairApproveParamsSchema,
DevicePairListParamsSchema,
DevicePairRemoveParamsSchema,
DevicePairRejectParamsSchema,
DeviceTokenRevokeParamsSchema,
DeviceTokenRotateParamsSchema,
@@ -234,6 +235,7 @@ export type ExecApprovalResolveParams = Static<typeof ExecApprovalResolveParamsS
export type DevicePairListParams = Static<typeof DevicePairListParamsSchema>;
export type DevicePairApproveParams = Static<typeof DevicePairApproveParamsSchema>;
export type DevicePairRejectParams = Static<typeof DevicePairRejectParamsSchema>;
export type DevicePairRemoveParams = Static<typeof DevicePairRemoveParamsSchema>;
export type DeviceTokenRotateParams = Static<typeof DeviceTokenRotateParamsSchema>;
export type DeviceTokenRevokeParams = Static<typeof DeviceTokenRevokeParamsSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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();
});
});

View File

@@ -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<Omit<PairedDevice, "deviceId" | "createdAtMs" | "approvedAtMs">>,

View File

@@ -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;