mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 12:01:25 +00:00
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:
@@ -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" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user