fix(nodes): allow removing stale paired nodes

This commit is contained in:
Peter Steinberger 2026-04-27 13:20:52 +01:00
parent 400be3b63f
commit 7fb2a356e8
No known key found for this signature in database
18 changed files with 156 additions and 5 deletions

View file

@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Nodes/CLI: add `openclaw nodes remove --node <id|name|ip>` 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:<id>`. 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.

View file

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

View file

@ -29,6 +29,7 @@ openclaw nodes list --last-connected 24h
openclaw nodes pending
openclaw nodes approve <requestId>
openclaw nodes reject <requestId>
openclaw nodes remove --node <id|name|ip>
openclaw nodes rename --node <id|name|ip> --name <displayName>
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 <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
Use `nodes remove --node <id|name|ip>` to delete a stale gateway-owned node pairing record.
Approval note:

View file

@ -39,6 +39,7 @@ openclaw nodes pending
openclaw nodes approve <requestId>
openclaw nodes reject <requestId>
openclaw nodes status
openclaw nodes remove --node <id|name|ip>
openclaw nodes rename --node <id|name|ip> --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:

View file

@ -369,7 +369,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
</Accordion>
<Accordion title="Node pairing, invoke, and pending work">
- `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.

View file

@ -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 <id|name|ip>` 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`

View file

@ -71,6 +71,30 @@ export function registerNodesPairingCommands(nodes: Command) {
}),
);
nodesCallOpts(
nodes
.command("remove")
.description("Remove a paired node entry")
.requiredOption("--node <idOrNameOrIp>", "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")

View file

@ -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 <id|name|ip>", "Remove a stale paired node entry."],
[
'openclaw nodes invoke --node <id> --command system.which --params \'{"name":"uname"}\'',
"Invoke a node command directly.",

View file

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

View file

@ -55,6 +55,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"node.pair.request",
"node.pair.list",
"node.pair.reject",
"node.pair.remove",
"node.pair.verify",
"node.pair.approve",
"device.pair.list",

View file

@ -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<NodePairApproveParams>(
export const validateNodePairRejectParams = ajv.compile<NodePairRejectParams>(
NodePairRejectParamsSchema,
);
export const validateNodePairRemoveParams = ajv.compile<NodePairRemoveParams>(
NodePairRemoveParamsSchema,
);
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
NodePairVerifyParamsSchema,
);
@ -538,8 +543,7 @@ export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(ChatAbortParamsSchema);
export const validateChatInjectParams = ajv.compile<ChatInjectParams>(ChatInjectParamsSchema);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export const validateUpdateStatusParams =
ajv.compile<UpdateStatusParams>(UpdateStatusParamsSchema);
export const validateUpdateStatusParams = ajv.compile<UpdateStatusParams>(UpdateStatusParamsSchema);
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(UpdateRunParamsSchema);
export const validateWebLoginStartParams =
ajv.compile<WebLoginStartParams>(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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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