mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05: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:
@@ -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.
|
||||
|
||||
@@ -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