From 7fb2a356e8f2ae2bdbde12952bb2a9c71e8ed775 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 13:20:52 +0100 Subject: [PATCH] fix(nodes): allow removing stale paired nodes --- CHANGELOG.md | 1 + docs/channels/pairing.md | 2 +- docs/cli/nodes.md | 2 ++ docs/gateway/pairing.md | 2 ++ docs/gateway/protocol.md | 2 +- docs/nodes/index.md | 4 ++- src/cli/nodes-cli/register.pairing.ts | 24 ++++++++++++++ src/cli/nodes-cli/register.ts | 1 + src/cli/program.nodes-basic.e2e.test.ts | 29 +++++++++++++++++ src/gateway/method-scopes.ts | 1 + src/gateway/protocol/index.ts | 10 ++++-- src/gateway/protocol/schema/nodes.ts | 5 +++ .../protocol/schema/protocol-schemas.ts | 2 ++ src/gateway/protocol/schema/types.ts | 1 + src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods/nodes.ts | 31 +++++++++++++++++++ src/infra/node-pairing.test.ts | 27 ++++++++++++++++ src/infra/node-pairing.ts | 16 ++++++++++ 18 files changed, 156 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 472105e88c2..c826d288895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Nodes/CLI: add `openclaw nodes remove --node ` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files. Thanks @openclaw. - Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe. - Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354. - Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319. diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index bf484071ee0..1fa12e3f65d 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -136,7 +136,7 @@ Stored under `~/.openclaw/devices/`: ### Notes -- The legacy `node.pair.*` API (CLI: `openclaw nodes pending|approve|reject|rename`) is a +- The legacy `node.pair.*` API (CLI: `openclaw nodes pending|approve|reject|remove|rename`) is a separate gateway-owned pairing store. WS nodes still require device pairing. - The pairing record is the durable source of truth for approved roles. Active device tokens stay bounded to that approved role set; a stray token entry diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md index 6c46ef19cdf..15e7e8733d9 100644 --- a/docs/cli/nodes.md +++ b/docs/cli/nodes.md @@ -29,6 +29,7 @@ openclaw nodes list --last-connected 24h openclaw nodes pending openclaw nodes approve openclaw nodes reject +openclaw nodes remove --node openclaw nodes rename --node --name openclaw nodes status openclaw nodes status --connected @@ -38,6 +39,7 @@ openclaw nodes status --last-connected 24h `nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect). Use `--connected` to only show currently-connected nodes. Use `--last-connected ` to filter to nodes that connected within a duration (e.g. `24h`, `7d`). +Use `nodes remove --node ` to delete a stale gateway-owned node pairing record. Approval note: diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 736140a0b11..78ec51c8e55 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -39,6 +39,7 @@ openclaw nodes pending openclaw nodes approve openclaw nodes reject openclaw nodes status +openclaw nodes remove --node openclaw nodes rename --node --name "Living Room iPad" ``` @@ -57,6 +58,7 @@ Methods: - `node.pair.list` — list pending + paired nodes (`operator.pairing`). - `node.pair.approve` — approve a pending request (issues token). - `node.pair.reject` — reject a pending request. +- `node.pair.remove` — remove a stale paired node entry. - `node.pair.verify` — verify `{ nodeId, token }`. Notes: diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index cb23e61ee1a..1d18027b134 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -369,7 +369,7 @@ enumeration of `src/gateway/server-methods/*.ts`. - - `node.pair.request`, `node.pair.list`, `node.pair.approve`, `node.pair.reject`, and `node.pair.verify` cover node pairing and bootstrap verification. + - `node.pair.request`, `node.pair.list`, `node.pair.approve`, `node.pair.reject`, `node.pair.remove`, and `node.pair.verify` cover node pairing and bootstrap verification. - `node.list` and `node.describe` return known/connected node state. - `node.rename` updates a paired node label. - `node.invoke` forwards a command to a connected node. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 7d0ab193bc3..7b5f311d7d7 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -49,8 +49,10 @@ Notes: - The device pairing record is the durable approved-role contract. Token rotation stays inside that contract; it cannot upgrade a paired node into a different role that pairing approval never granted. -- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/rename`) is a separate gateway-owned +- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate gateway-owned node pairing store; it does **not** gate the WS `connect` handshake. +- `openclaw nodes remove --node ` deletes stale entries from that + separate gateway-owned node pairing store. - Approval scope follows the pending request's declared commands: - commandless request: `operator.pairing` - non-exec node commands: `operator.pairing` + `operator.write` diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index 53bd90ac6b8..4ca09992460 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -71,6 +71,30 @@ export function registerNodesPairingCommands(nodes: Command) { }), ); + nodesCallOpts( + nodes + .command("remove") + .description("Remove a paired node entry") + .requiredOption("--node ", "Node id, name, or IP") + .action(async (opts: NodesRpcOpts) => { + await runNodesCommand("remove", async () => { + const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? ""); + if (!nodeId) { + defaultRuntime.error("--node required"); + defaultRuntime.exit(1); + return; + } + const result = await callGatewayCli("node.pair.remove", opts, { nodeId }); + if (opts.json) { + defaultRuntime.writeJson(result); + return; + } + const { warn } = getNodesTheme(); + defaultRuntime.log(warn(`Removed paired node ${nodeId}`)); + }); + }), + ); + nodesCallOpts( nodes .command("rename") diff --git a/src/cli/nodes-cli/register.ts b/src/cli/nodes-cli/register.ts index 93d380aac6c..673dceb72c4 100644 --- a/src/cli/nodes-cli/register.ts +++ b/src/cli/nodes-cli/register.ts @@ -22,6 +22,7 @@ export function registerNodesCli(program: Command) { `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw nodes status", "List known nodes with live status."], ["openclaw nodes pairing pending", "Show pending node pairing requests."], + ["openclaw nodes remove --node ", "Remove a stale paired node entry."], [ 'openclaw nodes invoke --node --command system.which --params \'{"name":"uname"}\'', "Invoke a node command directly.", diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index efc86c75b1f..19a769d1090 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -424,6 +424,35 @@ describe("cli program (nodes basics)", () => { ); }); + it("runs nodes remove and calls node.pair.remove", async () => { + callGateway.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string }; + if (opts.method === "node.list") { + return { + nodes: [{ nodeId: "ios-node", displayName: "iOS Node", paired: true }], + }; + } + if (opts.method === "node.pair.list") { + return { + pending: [], + paired: [{ nodeId: "ios-node", displayName: "iOS Node" }], + }; + } + if (opts.method === "node.pair.remove") { + return { nodeId: "ios-node" }; + } + return { ok: true }; + }); + + await runProgram(["nodes", "remove", "--node", "iOS Node"]); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "node.pair.remove", + params: { nodeId: "ios-node" }, + }), + ); + }); + it("runs nodes invoke and calls node.invoke", async () => { mockGatewayWithIosNodeListAnd("node.invoke", { ok: true, diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index 3d31db631f7..0e0d572b75f 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -55,6 +55,7 @@ const METHOD_SCOPE_GROUPS: Record = { "node.pair.request", "node.pair.list", "node.pair.reject", + "node.pair.remove", "node.pair.verify", "node.pair.approve", "device.pair.list", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index ace83ed58ef..8275076797f 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -187,6 +187,8 @@ import { NodePairListParamsSchema, type NodePairRejectParams, NodePairRejectParamsSchema, + type NodePairRemoveParams, + NodePairRemoveParamsSchema, type NodePairRequestParams, NodePairRequestParamsSchema, type NodePairVerifyParams, @@ -355,6 +357,9 @@ export const validateNodePairApproveParams = ajv.compile( export const validateNodePairRejectParams = ajv.compile( NodePairRejectParamsSchema, ); +export const validateNodePairRemoveParams = ajv.compile( + NodePairRemoveParamsSchema, +); export const validateNodePairVerifyParams = ajv.compile( NodePairVerifyParamsSchema, ); @@ -538,8 +543,7 @@ export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); export const validateChatAbortParams = ajv.compile(ChatAbortParamsSchema); export const validateChatInjectParams = ajv.compile(ChatInjectParamsSchema); export const validateChatEvent = ajv.compile(ChatEventSchema); -export const validateUpdateStatusParams = - ajv.compile(UpdateStatusParamsSchema); +export const validateUpdateStatusParams = ajv.compile(UpdateStatusParamsSchema); export const validateUpdateRunParams = ajv.compile(UpdateRunParamsSchema); export const validateWebLoginStartParams = ajv.compile(WebLoginStartParamsSchema); @@ -611,6 +615,7 @@ export { NodePairListParamsSchema, NodePairApproveParamsSchema, NodePairRejectParamsSchema, + NodePairRemoveParamsSchema, NodePairVerifyParamsSchema, NodeListParamsSchema, NodePendingAckParamsSchema, @@ -803,6 +808,7 @@ export type { SkillsInstallParams, SkillsUpdateParams, NodePairRejectParams, + NodePairRemoveParams, NodePairVerifyParams, NodeListParams, NodeInvokeParams, diff --git a/src/gateway/protocol/schema/nodes.ts b/src/gateway/protocol/schema/nodes.ts index 28fb6a80770..8e6534076d6 100644 --- a/src/gateway/protocol/schema/nodes.ts +++ b/src/gateway/protocol/schema/nodes.ts @@ -39,6 +39,11 @@ export const NodePairRejectParamsSchema = Type.Object( { additionalProperties: false }, ); +export const NodePairRemoveParamsSchema = Type.Object( + { nodeId: NonEmptyString }, + { additionalProperties: false }, +); + export const NodePairVerifyParamsSchema = Type.Object( { nodeId: NonEmptyString, token: NonEmptyString }, { additionalProperties: false }, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 32d1e4d15f1..4180789e3b2 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -146,6 +146,7 @@ import { NodePendingAckParamsSchema, NodePairApproveParamsSchema, NodePairListParamsSchema, + NodePairRemoveParamsSchema, NodePairRejectParamsSchema, NodePairRequestParamsSchema, NodePairVerifyParamsSchema, @@ -222,6 +223,7 @@ export const ProtocolSchemas = { NodePairListParams: NodePairListParamsSchema, NodePairApproveParams: NodePairApproveParamsSchema, NodePairRejectParams: NodePairRejectParamsSchema, + NodePairRemoveParams: NodePairRemoveParamsSchema, NodePairVerifyParams: NodePairVerifyParamsSchema, NodeRenameParams: NodeRenameParamsSchema, NodeListParams: NodeListParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index c25aa46a05b..0fec4c3492b 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -25,6 +25,7 @@ export type NodePairRequestParams = SchemaType<"NodePairRequestParams">; export type NodePairListParams = SchemaType<"NodePairListParams">; export type NodePairApproveParams = SchemaType<"NodePairApproveParams">; export type NodePairRejectParams = SchemaType<"NodePairRejectParams">; +export type NodePairRemoveParams = SchemaType<"NodePairRemoveParams">; export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">; export type NodeRenameParams = SchemaType<"NodeRenameParams">; export type NodeListParams = SchemaType<"NodeListParams">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 142de04f33e..8979342537d 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -103,6 +103,7 @@ const BASE_METHODS = [ "node.pair.list", "node.pair.approve", "node.pair.reject", + "node.pair.remove", "node.pair.verify", "device.pair.list", "device.pair.approve", diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 9d26664ddde..4d7e5b72a05 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -7,6 +7,7 @@ import { approveNodePairing, listNodePairing, rejectNodePairing, + removePairedNode, renamePairedNode, requestNodePairing, verifyNodeToken, @@ -44,6 +45,7 @@ import { validateNodePairApproveParams, validateNodePairListParams, validateNodePairRejectParams, + validateNodePairRemoveParams, validateNodePairRequestParams, validateNodePairVerifyParams, validateNodeRenameParams, @@ -640,6 +642,35 @@ export const nodeHandlers: GatewayRequestHandlers = { respond(true, rejected, undefined); }); }, + "node.pair.remove": async ({ params, respond, context }) => { + if (!validateNodePairRemoveParams(params)) { + respondInvalidParams({ + respond, + method: "node.pair.remove", + validator: validateNodePairRemoveParams, + }); + return; + } + const { nodeId } = params as { nodeId: string }; + await respondUnavailableOnThrow(respond, async () => { + const removed = await removePairedNode(nodeId); + if (!removed) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId")); + return; + } + context.broadcast( + "node.pair.resolved", + { + requestId: "", + nodeId: removed.nodeId, + decision: "removed", + ts: Date.now(), + }, + { dropIfSlow: true }, + ); + respond(true, removed, undefined); + }); + }, "node.pair.verify": async ({ params, respond }) => { if (!validateNodePairVerifyParams(params)) { respondInvalidParams({ diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index 062cd8bf1d6..b58ad9838b8 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -5,6 +5,7 @@ import { approveNodePairing, getPairedNode, listNodePairing, + removePairedNode, requestNodePairing, verifyNodeToken, } from "./node-pairing.js"; @@ -152,6 +153,32 @@ describe("node pairing tokens", () => { }); }); + test("removes paired nodes without disturbing pending requests", async () => { + await withNodePairingDir(async (baseDir) => { + await setupPairedNode(baseDir); + const pending = await requestNodePairing( + { + nodeId: "node-2", + platform: "darwin", + }, + baseDir, + ); + + await expect(removePairedNode("node-1", baseDir)).resolves.toEqual({ nodeId: "node-1" }); + await expect(removePairedNode("node-1", baseDir)).resolves.toBeNull(); + await expect(getPairedNode("node-1", baseDir)).resolves.toBeNull(); + await expect(listNodePairing(baseDir)).resolves.toEqual({ + pending: [ + expect.objectContaining({ + requestId: pending.request.requestId, + nodeId: "node-2", + }), + ], + paired: [], + }); + }); + }); + test("requires the right scopes to approve node requests", async () => { await withNodePairingDir(async (baseDir) => { const systemRunRequest = await requestNodePairing( diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index ceb956f4913..bda53c5fa2d 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -287,6 +287,22 @@ export async function rejectNodePairing( }); } +export async function removePairedNode( + nodeId: string, + baseDir?: string, +): Promise<{ nodeId: string } | null> { + return await withLock(async () => { + const state = await loadState(baseDir); + const normalized = normalizeNodeId(nodeId); + if (!normalized || !state.pairedByNodeId[normalized]) { + return null; + } + delete state.pairedByNodeId[normalized]; + await persistState(state, baseDir); + return { nodeId: normalized }; + }); +} + export async function verifyNodeToken( nodeId: string, token: string,