mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
Reference in New Issue
Block a user