diff --git a/CHANGELOG.md b/CHANGELOG.md index 063a9d2016a..99c479520b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss. - TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant. - Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the target user scope when `systemctl --user` reports no-medium bus failures, without letting stale `SUDO_USER` override `sudo -u` installs. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, @mssteuer, and @boyuaner. +- CLI/nodes: make unfiltered `openclaw nodes list` prefer the effective paired-node view used by `nodes status` while preserving pending rows, pairing-scope fallback, terminal-safe table rendering, and paired JSON metadata. Fixes #46871; carries forward #65772 through the ProjectClownfish #72619 repair. Thanks @skainguyen1412. - CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc. - Feishu/Lark: stop treating broadcast-only `@all`/`@_all` messages as bot mentions while preserving direct bot mentions, including messages that also include `@all`. Fixes #37706. Thanks @JosepLee. - CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras. diff --git a/src/cli/nodes-cli/pairing-render.ts b/src/cli/nodes-cli/pairing-render.ts index ff4cb115ba9..b74e0e99a75 100644 --- a/src/cli/nodes-cli/pairing-render.ts +++ b/src/cli/nodes-cli/pairing-render.ts @@ -1,4 +1,5 @@ import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; +import { sanitizeTerminalText } from "../../terminal/safe-text.js"; import { renderTable } from "../../terminal/table.js"; import type { PendingRequest } from "./types.js"; @@ -13,13 +14,16 @@ export function renderPendingPairingRequestsTable(params: { }; }) { const { pending, now, tableWidth, theme } = params; - const rows = pending.map((r) => ({ - Request: r.requestId, - Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId, - IP: r.remoteIp ?? "", - Requested: - typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown"), - })); + const rows = pending.map((r) => { + const nodeLabel = r.displayName?.trim() ? r.displayName.trim() : r.nodeId; + return { + Request: sanitizeTerminalText(r.requestId), + Node: sanitizeTerminalText(nodeLabel), + IP: sanitizeTerminalText(r.remoteIp ?? ""), + Requested: + typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown"), + }; + }); return { heading: theme.heading("Pending"), table: renderTable({ diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 013207d7079..e5c6fbe714b 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -7,6 +7,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { sanitizeTerminalText } from "../../terminal/safe-text.js"; import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { shortenHomeInString } from "../../utils.js"; import { parseDurationMs } from "../parse-duration.js"; @@ -14,7 +15,9 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { formatPermissions, parseNodeList, parsePairingList } from "./format.js"; import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; -import type { NodesRpcOpts } from "./types.js"; +import type { NodeListNode, NodesRpcOpts, PairedNode } from "./types.js"; + +type PairedNodeListRow = PairedNode & Partial; function formatVersionLabel(raw: string) { const trimmed = raw.trim(); @@ -88,6 +91,11 @@ function formatClientLabel(node: { clientId?: string; clientMode?: string }): st return clientId || clientMode || null; } +function formatNodeTerminalLabel(node: { nodeId: string; displayName?: string }): string { + const label = node.displayName?.trim() ? node.displayName.trim() : node.nodeId; + return sanitizeTerminalText(label); +} + function parseSinceMs(raw: unknown, label: string): number | undefined { if (raw === undefined || raw === null) { return undefined; @@ -111,6 +119,67 @@ function parseSinceMs(raw: unknown, label: string): number | undefined { } } +function mergePairedNodeWithEffectiveNode( + paired: PairedNode | undefined, + effective: NodeListNode, +): PairedNodeListRow { + return { + ...paired, + ...effective, + token: paired?.token, + createdAtMs: paired?.createdAtMs, + lastConnectedAtMs: paired?.lastConnectedAtMs ?? effective.connectedAtMs, + displayName: effective.displayName ?? paired?.displayName, + platform: effective.platform ?? paired?.platform, + version: effective.version ?? paired?.version, + coreVersion: effective.coreVersion ?? paired?.coreVersion, + uiVersion: effective.uiVersion ?? paired?.uiVersion, + remoteIp: effective.remoteIp ?? paired?.remoteIp, + permissions: effective.permissions ?? paired?.permissions, + approvedAtMs: effective.approvedAtMs ?? paired?.approvedAtMs, + }; +} + +function mergePairedNodesWithEffectiveNodes( + paired: PairedNode[], + effectiveNodes: NodeListNode[] | null, +): PairedNodeListRow[] { + if (effectiveNodes === null) { + return paired; + } + const pairedById = new Map(paired.map((node) => [node.nodeId, node])); + const seen = new Set(); + const rows: PairedNodeListRow[] = []; + for (const effective of effectiveNodes) { + const pairedNode = pairedById.get(effective.nodeId); + if (!pairedNode && effective.paired !== true) { + continue; + } + seen.add(effective.nodeId); + rows.push(mergePairedNodeWithEffectiveNode(pairedNode, effective)); + } + for (const node of paired) { + if (!seen.has(node.nodeId)) { + rows.push(node); + } + } + return rows; +} + +async function tryReadNodeList(opts: NodesRpcOpts): Promise { + try { + return parseNodeList(await callGatewayCli("node.list", opts, {})); + } catch { + return null; + } +} + +function sanitizePairedNodeForListJson(node: PairedNodeListRow): Omit { + const copy: Record = { ...node }; + delete copy.token; + return copy as Omit; +} + export function registerNodesStatusCommands(nodes: Command) { nodesCallOpts( nodes @@ -176,7 +245,6 @@ export function registerNodesStatusCommands(nodes: Command) { } const rows = filtered.map((n) => { - const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId; const perms = formatPermissions(n.permissions); const versions = formatNodeVersions(n); const pathEnv = formatPathEnv(n.pathEnv); @@ -188,9 +256,11 @@ export function registerNodesStatusCommands(nodes: Command) { perms ? `perms: ${perms}` : null, versions, pathEnv ? `path: ${pathEnv}` : null, - ].filter(Boolean) as string[]; + ] + .filter(Boolean) + .map((part) => sanitizeTerminalText(String(part))); const caps = Array.isArray(n.caps) - ? n.caps.map(String).filter(Boolean).toSorted().join(", ") + ? sanitizeTerminalText(n.caps.map(String).filter(Boolean).toSorted().join(", ")) : "?"; const paired = n.paired ? ok("paired") : warn("unpaired"); const connected = n.connected ? ok("connected") : muted("disconnected"); @@ -200,9 +270,9 @@ export function registerNodesStatusCommands(nodes: Command) { : ""; return { - Node: name, - ID: n.nodeId, - IP: n.remoteIp ?? "", + Node: formatNodeTerminalLabel(n), + ID: sanitizeTerminalText(n.nodeId), + IP: sanitizeTerminalText(n.remoteIp ?? ""), Detail: detailParts.join(" · "), Status: `${paired} · ${connected}${since}`, Caps: caps, @@ -275,17 +345,17 @@ export function registerNodesStatusCommands(nodes: Command) { }`; const tableWidth = getTerminalTableWidth(); const rows = [ - { Field: "ID", Value: nodeId }, - displayName ? { Field: "Name", Value: displayName } : null, - client ? { Field: "Client", Value: client } : null, - ip ? { Field: "IP", Value: ip } : null, - family ? { Field: "Device", Value: family } : null, - model ? { Field: "Model", Value: model } : null, - perms ? { Field: "Perms", Value: perms } : null, - versions ? { Field: "Version", Value: versions } : null, - pathEnv ? { Field: "PATH", Value: pathEnv } : null, + { Field: "ID", Value: sanitizeTerminalText(nodeId) }, + displayName ? { Field: "Name", Value: sanitizeTerminalText(displayName) } : null, + client ? { Field: "Client", Value: sanitizeTerminalText(client) } : null, + ip ? { Field: "IP", Value: sanitizeTerminalText(ip) } : null, + family ? { Field: "Device", Value: sanitizeTerminalText(family) } : null, + model ? { Field: "Model", Value: sanitizeTerminalText(model) } : null, + perms ? { Field: "Perms", Value: sanitizeTerminalText(perms) } : null, + versions ? { Field: "Version", Value: sanitizeTerminalText(versions) } : null, + pathEnv ? { Field: "PATH", Value: sanitizeTerminalText(pathEnv) } : null, { Field: "Status", Value: status }, - { Field: "Caps", Value: caps ? caps.join(", ") : "?" }, + { Field: "Caps", Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?" }, ].filter(Boolean) as Array<{ Field: string; Value: string }>; defaultRuntime.log(heading("Node")); @@ -329,28 +399,22 @@ export function registerNodesStatusCommands(nodes: Command) { const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== undefined; const pendingRows = hasFilters ? [] : pending; - const connectedById = hasFilters - ? new Map( - parseNodeList(await callGatewayCli("node.list", opts, {})).map((node) => [ - node.nodeId, - node, - ]), - ) - : null; - const filteredPaired = paired.filter((node) => { + const effectiveNodes = hasFilters + ? parseNodeList(await callGatewayCli("node.list", opts, {})) + : await tryReadNodeList(opts); + const effectivePairedRows = mergePairedNodesWithEffectiveNodes(paired, effectiveNodes); + const filteredPaired = effectivePairedRows.filter((node) => { if (connectedOnly) { - const live = connectedById?.get(node.nodeId); - if (!live?.connected) { + if (!node.connected) { return false; } } if (sinceMs !== undefined) { - const live = connectedById?.get(node.nodeId); const lastConnectedAtMs = typeof node.lastConnectedAtMs === "number" ? node.lastConnectedAtMs - : typeof live?.connectedAtMs === "number" - ? live.connectedAtMs + : typeof node.connectedAtMs === "number" + ? node.connectedAtMs : undefined; if (typeof lastConnectedAtMs !== "number") { return false; @@ -368,7 +432,10 @@ export function registerNodesStatusCommands(nodes: Command) { ); if (opts.json) { - defaultRuntime.writeJson({ pending: pendingRows, paired: filteredPaired }); + defaultRuntime.writeJson({ + pending: pendingRows, + paired: filteredPaired.map(sanitizePairedNodeForListJson), + }); return; } @@ -385,18 +452,17 @@ export function registerNodesStatusCommands(nodes: Command) { } if (filteredPaired.length > 0) { - const pairedRows = filteredPaired.map((n) => { - const live = connectedById?.get(n.nodeId); + const pairedTableRows = filteredPaired.map((n) => { const lastConnectedAtMs = typeof n.lastConnectedAtMs === "number" ? n.lastConnectedAtMs - : typeof live?.connectedAtMs === "number" - ? live.connectedAtMs + : typeof n.connectedAtMs === "number" + ? n.connectedAtMs : undefined; return { - Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId, - Id: n.nodeId, - IP: n.remoteIp ?? "", + Node: formatNodeTerminalLabel(n), + Id: sanitizeTerminalText(n.nodeId), + IP: sanitizeTerminalText(n.remoteIp ?? ""), LastConnect: typeof lastConnectedAtMs === "number" ? formatTimeAgo(Math.max(0, now - lastConnectedAtMs)) @@ -414,7 +480,7 @@ export function registerNodesStatusCommands(nodes: Command) { { key: "IP", header: "IP", minWidth: 10 }, { key: "LastConnect", header: "Last Connect", minWidth: 14 }, ], - rows: pairedRows, + rows: pairedTableRows, }).trimEnd(), ); } diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index cc7e22a4ea6..efc86c75b1f 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -62,6 +62,183 @@ describe("cli program (nodes basics)", () => { program = createProgram(); }); + it("runs nodes list with the effective paired node view while preserving paired metadata", async () => { + const now = Date.now(); + callGateway.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string }; + if (opts.method === "node.pair.list") { + return { + pending: [{ requestId: "r1", nodeId: "pending-node", ts: now - 10_000 }], + paired: [ + { + nodeId: "paired-store", + displayName: "Stale paired name", + remoteIp: "10.0.0.1", + token: "paired-token", + lastConnectedAtMs: now - 5_000, + }, + { + nodeId: "pair-only", + displayName: "Pair Only", + token: "pair-only-token", + }, + ], + }; + } + if (opts.method === "node.list") { + return { + nodes: [ + { + nodeId: "paired-store", + displayName: "Effective paired name", + remoteIp: "10.0.0.2", + connected: true, + connectedAtMs: now - 1_000, + }, + { + nodeId: "catalog-only", + displayName: "Catalog Only", + remoteIp: "10.0.0.3", + paired: true, + connected: false, + }, + { + nodeId: "effective-only-unknown", + displayName: "Effective Only Unknown", + connected: true, + }, + { + nodeId: "unpaired-live", + displayName: "Unpaired Live", + paired: false, + connected: true, + }, + ], + }; + } + return { ok: true }; + }); + + await runProgram(["nodes", "list", "--json"]); + + expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" })); + expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" })); + expect(runtime.writeJson).toHaveBeenCalledWith({ + pending: [{ requestId: "r1", nodeId: "pending-node", ts: now - 10_000 }], + paired: [ + expect.objectContaining({ + nodeId: "paired-store", + displayName: "Effective paired name", + remoteIp: "10.0.0.2", + lastConnectedAtMs: now - 5_000, + connected: true, + }), + expect.objectContaining({ + nodeId: "catalog-only", + displayName: "Catalog Only", + paired: true, + }), + expect.objectContaining({ + nodeId: "pair-only", + displayName: "Pair Only", + }), + ], + }); + expect(JSON.stringify(runtime.writeJson.mock.calls[0]?.[0])).not.toContain("paired-token"); + expect(JSON.stringify(runtime.writeJson.mock.calls[0]?.[0])).not.toContain("pair-only-token"); + const output = getRuntimeOutput(); + expect(output).toContain("Pending: 1 · Paired: 3"); + expect(output).not.toContain("Effective Only Unknown"); + expect(output).not.toContain("unpaired-live"); + }); + + it("runs unfiltered nodes list with pairing data when node.list is unavailable", async () => { + callGateway.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string }; + if (opts.method === "node.pair.list") { + return { + pending: [], + paired: [ + { + nodeId: "pairing-scoped", + displayName: "Pairing Scoped", + remoteIp: "10.0.0.9", + }, + ], + }; + } + if (opts.method === "node.list") { + throw new Error("unauthorized"); + } + return { ok: true }; + }); + + await runProgram(["nodes", "list"]); + + const output = getRuntimeOutput(); + expect(output).toContain("Pending: 0 · Paired: 1"); + expect(output).toContain("Pairing Scoped"); + }); + + it("sanitizes untrusted nodes list table fields while preserving JSON values", async () => { + const now = Date.now(); + callGateway.mockImplementation(async (...args: unknown[]) => { + const opts = (args[0] ?? {}) as { method?: string }; + if (opts.method === "node.pair.list") { + return { + pending: [ + { + requestId: "request\u001b[2K-1", + nodeId: "pending-node", + displayName: "Pending\u001b[1A\nNode", + remoteIp: "10.0.0.4\rrewritten", + ts: now - 1_000, + }, + ], + paired: [ + { + nodeId: "paired-node", + displayName: "Paired\u001b[2K\nNode", + remoteIp: "10.0.0.5\rrewritten", + }, + ], + }; + } + if (opts.method === "node.list") { + throw new Error("older gateway"); + } + return { ok: true }; + }); + + await runProgram(["nodes", "list"]); + + const output = getRuntimeOutput(); + expect(output).not.toContain("\u001b"); + expect(output).not.toContain("[2K"); + expect(output).toContain("Pending\\nNode"); + expect(output).toContain("Paired\\nNode"); + expect(output).toContain("10.0.0.5\\rrewritten"); + + runtime.log.mockClear(); + await runProgram(["nodes", "list", "--json"]); + + expect(runtime.writeJson).toHaveBeenCalledWith({ + pending: [ + expect.objectContaining({ + requestId: "request\u001b[2K-1", + displayName: "Pending\u001b[1A\nNode", + }), + ], + paired: [ + expect.objectContaining({ + nodeId: "paired-node", + displayName: "Paired\u001b[2K\nNode", + remoteIp: "10.0.0.5\rrewritten", + }), + ], + }); + }); + it("runs nodes list --connected and filters to connected nodes", async () => { const now = Date.now(); callGateway.mockImplementation(async (...args: unknown[]) => {