mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
fix(nodes): allow removing stale paired nodes
This commit is contained in:
parent
400be3b63f
commit
7fb2a356e8
18 changed files with 156 additions and 5 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue