fix(cli): keep nodes list aligned with nodes status (#72619)

* fix(cli): keep nodes list aligned with nodes status

* fix(clownfish): address review for ghcrawl-156588-autonomous-smoke (1)

* fix(cli): keep nodes list aligned with nodes status
This commit is contained in:
Vincent Koc
2026-04-27 02:39:33 -07:00
committed by GitHub
parent af03f9248d
commit a50edbdc60
4 changed files with 295 additions and 47 deletions

View File

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

View File

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

View File

@@ -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<NodeListNode>;
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<string>();
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<NodeListNode[] | null> {
try {
return parseNodeList(await callGatewayCli("node.list", opts, {}));
} catch {
return null;
}
}
function sanitizePairedNodeForListJson(node: PairedNodeListRow): Omit<PairedNodeListRow, "token"> {
const copy: Record<string, unknown> = { ...node };
delete copy.token;
return copy as Omit<PairedNodeListRow, "token">;
}
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(),
);
}

View File

@@ -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[]) => {