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

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