From 28da5be7e84b0d66b090efbdadee464d4a29ecd1 Mon Sep 17 00:00:00 2001 From: Rui Luo Date: Wed, 17 Jun 2026 13:33:40 +0800 Subject: [PATCH] fix(sessions): hide onboard warm-up session from list and export Signed-off-by: Rui Luo --- .../actions/sandbox/auto-pair-warmup.test.ts | 25 +- src/lib/actions/sandbox/auto-pair-warmup.ts | 5 +- .../actions/sandbox/sessions/export.test.ts | 92 +++++- src/lib/actions/sandbox/sessions/export.ts | 55 +++- .../sandbox/sessions/passthrough.test.ts | 299 ++++++++++++++++++ .../actions/sandbox/sessions/passthrough.ts | 176 ++++++++++- src/lib/actions/sandbox/warmup-session.ts | 8 + src/lib/adapters/openshell/client.test.ts | 18 ++ src/lib/adapters/openshell/client.ts | 14 + src/lib/adapters/openshell/runtime.ts | 4 + 10 files changed, 684 insertions(+), 12 deletions(-) create mode 100644 src/lib/actions/sandbox/sessions/passthrough.test.ts create mode 100644 src/lib/actions/sandbox/warmup-session.ts diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index 44512db3a4..d3d7c8f538 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -5,7 +5,8 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { wrapSandboxShellScript } from "./auto-pair-approval"; -import { WARMUP_TIMEOUT_MS } from "./auto-pair-warmup"; +import { WARMUP_SCRIPT, WARMUP_TIMEOUT_MS } from "./auto-pair-warmup"; +import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; // NOTE on coverage shape (#4504-v2): `runSandboxScopeWarmupRun` is not exercised // in-process here. Like its sibling `runSandboxAutoPairApprovalPass`, the leaf @@ -50,7 +51,7 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => { const warmupShaped = [ "command -v openclaw >/dev/null 2>&1 || exit 0", 'openclaw agent --agent main -m "ping" \\', - ' --session-id "nemoclaw-onboard-warmup-$$-$(date +%s)" >/dev/null 2>&1 || true', + ` --session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true`, "exit 0", "", ].join("\n"); @@ -60,7 +61,10 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => { expect(wrapped).toContain("mktemp"); }); - it("round-trips a warm-up-shaped payload and preserves its exit-0 status when run", () => { + const shAvailable = spawnSync("sh", ["-c", "exit 0"], { encoding: "utf-8" }).status === 0; + const itWithSh = shAvailable ? it : it.skip; + + itWithSh("round-trips a warm-up-shaped payload and preserves its exit-0 status when run", () => { // Mirror the real warm-up: the provoke command itself may "fail" (the agent // falls back to embedded mode), but `|| true` + trailing `exit 0` mean the // wrapped script always exits 0 — so a failed provoke never surfaces as a @@ -72,3 +76,18 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => { expect(result.status).toBe(0); }); }); + +describe("warm-up tags its throwaway session for user-facing filters (#5511)", () => { + it("tags the provoke session with the shared warm-up prefix", () => { + expect(WARMUP_SESSION_ID_PREFIX).toBe("nemoclaw-onboard-warmup-"); + expect(WARMUP_SCRIPT).toContain(`--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`); + }); + + it("keeps the #4504-v2 provoke run foreground and within the original budget", () => { + expect(WARMUP_SCRIPT).toContain('openclaw agent --agent main -m "ping" \\'); + expect(WARMUP_SCRIPT).toContain(">/dev/null 2>&1 || true"); + expect(WARMUP_SCRIPT).not.toContain("setsid"); + expect(WARMUP_SCRIPT).not.toContain("WARMUP_AGENT_PID"); + expect(WARMUP_SCRIPT).not.toContain("warmup_cleanup_attempt"); + }); +}); diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 4c00d1ef29..ebccf488c0 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -38,6 +38,7 @@ import { spawnSync } from "node:child_process"; import { ROOT } from "../../state/paths"; import { wrapSandboxShellScript } from "./auto-pair-approval"; +import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; // Outer spawnSync cap (ms) for the throwaway warm-up agent run. The `-m` // one-shot prompt ("ping") returns fast even when it falls back to embedded @@ -65,12 +66,12 @@ export const WARMUP_POLL_LIST_TIMEOUT_S = 2; // the approval pass that runs immediately after could otherwise list devices // before the gateway has registered the upgrade. The poll bounds are // interpolated so the cap is asserted on real values, not source text. -const WARMUP_SCRIPT = ` +export const WARMUP_SCRIPT = ` PROXY_ENV=/tmp/nemoclaw-proxy-env.sh [ -r "$PROXY_ENV" ] && . "$PROXY_ENV" command -v openclaw >/dev/null 2>&1 || exit 0 openclaw agent --agent main -m "ping" \\ - --session-id "nemoclaw-onboard-warmup-$$-$(date +%s)" >/dev/null 2>&1 || true + --session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true command -v python3 >/dev/null 2>&1 || exit 0 OPENCLAW_BIN="$(command -v openclaw)" i=0 diff --git a/src/lib/actions/sandbox/sessions/export.test.ts b/src/lib/actions/sandbox/sessions/export.test.ts index 27ee2d182c..44ee58e924 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -15,6 +16,7 @@ vi.mock("../../../adapters/openshell/runtime", () => ({ })); import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; +import { isWarmupSessionId, WARMUP_SESSION_ID_PREFIX } from "../warmup-session"; import { buildSandboxTarArgv, exportSandboxSessions, parseSessionIndex } from "./export"; const captureMock = captureOpenshell as unknown as ReturnType; @@ -86,6 +88,14 @@ describe("parseSessionIndex", () => { expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); }); + it("tolerates log noise after a pretty JSON payload", () => { + const output = [ + JSON.stringify({ sessions: [{ key: "agent:main:main", sessionId: "sid-1" }] }, null, 2), + "(node:1) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental", + ].join("\n"); + expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); + }); + it("returns [] when the upstream emits an empty index (empty array)", () => { expect(parseSessionIndex("[]")).toEqual([]); }); @@ -102,6 +112,80 @@ describe("parseSessionIndex", () => { const output = JSON.stringify([{ alias: "agent:main:main", uuid: "sid-1" }]); expect(parseSessionIndex(output)).toBeNull(); }); + + it("does not accept session-shaped arrays under an unknown wrapper", () => { + const output = JSON.stringify({ + records: [{ key: "agent:main:main", sessionId: "sid-1" }], + }); + expect(parseSessionIndex(output)).toBeNull(); + }); +}); + +describe("isWarmupSessionId", () => { + it("matches the onboard warm-up session id prefix (#5511)", () => { + expect(isWarmupSessionId(`${WARMUP_SESSION_ID_PREFIX}123`)).toBe(true); + expect(isWarmupSessionId("sid-real")).toBe(false); + }); +}); + +describe("exportSandboxSessions warm-up filtering", () => { + it("excludes the onboard warm-up session from export-all but keeps real sessions (#5511)", async () => { + captureMock.mockReturnValueOnce( + makeCapture( + JSON.stringify([ + { key: "agent:main:main", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }, + { key: "agent:main:telegram:t-1", sessionId: "sid-real" }, + ]), + ), + ); + + const result = await exportSandboxSessions({ + sandboxName: "alpha", + out: "./out.tgz", + format: "tar", + }); + + const tarCall = runMock.mock.calls[0]?.[0] as string[]; + const shellCommand = tarCall[7] as string; + expect(result.resolvedSessionIds).toEqual(["sid-real"]); + expect(shellCommand).toMatch(/-- \.\/sid-real\.jsonl/); + expect(shellCommand).not.toContain(WARMUP_SESSION_ID_PREFIX); + }); + + it("refuses export-all when only the onboard warm-up session remains (#5511)", async () => { + captureMock.mockReturnValueOnce( + makeCapture( + JSON.stringify([ + { key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }, + ]), + ), + ); + + await expect( + exportSandboxSessions({ + sandboxName: "alpha", + out: "./sessions-alpha", + }), + ).rejects.toThrow(/agent 'main' has no sessions to bundle/); + expect(runMock).not.toHaveBeenCalled(); + }); + + it("still exports a warm-up session when the caller names it explicitly", async () => { + const warmupId = `${WARMUP_SESSION_ID_PREFIX}explicit`; + captureMock.mockReturnValueOnce( + makeCapture(JSON.stringify([{ key: "agent:main:main", sessionId: warmupId }])), + ); + + const result = await exportSandboxSessions({ + sandboxName: "alpha", + keys: ["agent:main:main"], + out: "./out.tgz", + format: "tar", + }); + + expect(result.resolvedSessionIds).toEqual([warmupId]); + expect(result.resolvedFiles).toEqual([`${warmupId}.jsonl`]); + }); }); describe("exportSandboxSessions", () => { @@ -188,10 +272,10 @@ describe("exportSandboxSessions", () => { "download", "alpha", "/sandbox/.openclaw/agents/main/sessions/sid-a.jsonl", - "sessions-alpha/sid-a.jsonl", + path.join("sessions-alpha", "sid-a.jsonl"), ]); // Each downloaded file is locked to owner-only (session JSONL may hold secrets). - expect(chmodSpy).toHaveBeenCalledWith("sessions-alpha/sid-a.jsonl", 0o600); + expect(chmodSpy).toHaveBeenCalledWith(path.join("sessions-alpha", "sid-a.jsonl"), 0o600); expect(result.format).toBe("dir"); expect(result.hostDest).toBe("./sessions-alpha"); @@ -200,13 +284,13 @@ describe("exportSandboxSessions", () => { { key: "agent:main:main", sessionId: "sid-a", - path: "sessions-alpha/sid-a.jsonl", + path: path.join("sessions-alpha", "sid-a.jsonl"), sizeBytes: 42, }, { key: "agent:main:telegram:t-1", sessionId: "sid-b", - path: "sessions-alpha/sid-b.jsonl", + path: path.join("sessions-alpha", "sid-b.jsonl"), sizeBytes: 42, }, ]); diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index c0efabe278..27f844b00e 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -44,6 +44,7 @@ import path from "node:path"; import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; import { CLI_NAME } from "../../../cli/branding"; import { ensureLiveSandboxOrExit } from "../gateway-state"; +import { isWarmupSessionId } from "../warmup-session"; import { DEFAULT_AGENT_ID, parseAgentIdFromSessionKey, @@ -323,7 +324,11 @@ function resolveSelectedFiles( const entries: { key: string; sessionId: string }[] = []; if (keys.length === 0) { - for (const entry of index) entries.push(entry); + // Export-all hides internal warm-up sessions; explicit keys are honored below. + for (const entry of index) { + if (isWarmupSessionId(entry.sessionId)) continue; + entries.push(entry); + } } else { const missing: string[] = []; for (const key of keys) { @@ -424,7 +429,7 @@ export function parseSessionIndex(output: string): SessionIndexEntry[] | null { const trimmed = output.trim(); if (!trimmed) return []; const lines = trimmed.split(/\r?\n/); - const candidates: string[] = []; + const candidates = balancedJsonCandidates(trimmed); for (let index = lines.length - 1; index >= 0; index -= 1) { const candidate = lines[index]?.trim(); if (candidate && (candidate.startsWith("[") || candidate.startsWith("{"))) { @@ -443,6 +448,52 @@ export function parseSessionIndex(output: string): SessionIndexEntry[] | null { return null; } +export function balancedJsonCandidates(text: string): string[] { + const candidates: string[] = []; + const lineStartJson = /^(\s*)([\[{])/gm; + let match: RegExpExecArray | null; + while ((match = lineStartJson.exec(text)) !== null) { + const candidate = balancedJsonFrom(text, match.index + match[1].length); + if (candidate) candidates.push(candidate); + } + return candidates; +} + +function balancedJsonFrom(text: string, start: number): string | null { + const stack: string[] = []; + let inString = false; + let escaped = false; + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (inString) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + continue; + } + if (char === "{") { + stack.push("}"); + continue; + } + if (char === "[") { + stack.push("]"); + continue; + } + if (char !== "}" && char !== "]") continue; + if (stack.pop() !== char) return null; + if (stack.length === 0) return text.slice(start, index + 1); + } + return null; +} + function tryExtractIndex(text: string): SessionIndexEntry[] | null { let parsed: unknown; try { diff --git a/src/lib/actions/sandbox/sessions/passthrough.test.ts b/src/lib/actions/sandbox/sessions/passthrough.test.ts new file mode 100644 index 0000000000..a1e926a8d2 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/passthrough.test.ts @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const captureMock = vi.hoisted(() => vi.fn()); +const execMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureLiveMock = vi.hoisted(() => vi.fn(async () => ({}))); + +vi.mock("../../../adapters/openshell/runtime", () => ({ + captureOpenshell: captureMock, +})); +vi.mock("../exec", async () => { + const actual = await vi.importActual("../exec"); + return { ...actual, execSandbox: execMock }; +}); +vi.mock("../gateway-state", () => ({ ensureLiveSandboxOrExit: ensureLiveMock })); + +import { WARMUP_SESSION_ID_PREFIX } from "../warmup-session"; +import { + filterWarmupSessionsListJson, + filterWarmupSessionsListText, + runSessionsPassthrough, +} from "./passthrough"; + +describe("filterWarmupSessionsListJson", () => { + it("filters internal warm-up sessions from wrapped OpenClaw list JSON (#5511)", () => { + const filtered = filterWarmupSessionsListJson( + JSON.stringify({ + count: 2, + totalCount: 2, + sessions: [ + { key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }, + { key: "agent:main:explicit:real", sessionId: "sid-real" }, + ], + }), + ); + + expect(JSON.parse(filtered as string)).toEqual({ + count: 1, + totalCount: 1, + sessions: [{ key: "agent:main:explicit:real", sessionId: "sid-real" }], + }); + }); + + it("filters plain array list JSON", () => { + const filtered = filterWarmupSessionsListJson( + JSON.stringify([ + { key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }, + { key: "agent:main:explicit:real", sessionId: "sid-real" }, + ]), + ); + + expect(JSON.parse(filtered as string)).toEqual([ + { key: "agent:main:explicit:real", sessionId: "sid-real" }, + ]); + }); + + it("uses the tolerant session-index parser for noisy JSON output", () => { + const filtered = filterWarmupSessionsListJson( + [ + "(node:1) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental", + JSON.stringify({ + count: 1, + totalCount: 1, + sessions: [ + { key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }, + ], + }), + ].join("\n"), + ); + + expect(JSON.parse(filtered as string)).toEqual({ count: 0, totalCount: 0, sessions: [] }); + }); + + it("filters pretty JSON when stderr warnings are appended to the captured output", () => { + const filtered = filterWarmupSessionsListJson( + [ + JSON.stringify( + { + path: "/sandbox/.openclaw/agents/main/sessions/sessions.json", + count: 1, + totalCount: 1, + sessions: [ + { key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }, + ], + }, + null, + 2, + ), + "(node:1) [UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental", + ].join("\n"), + ); + + expect(JSON.parse(filtered as string)).toEqual({ + path: "/sandbox/.openclaw/agents/main/sessions/sessions.json", + count: 0, + totalCount: 0, + sessions: [], + }); + }); +}); + +describe("filterWarmupSessionsListText", () => { + it("filters internal warm-up rows and adjusts the displayed count (#5511)", () => { + const filtered = filterWarmupSessionsListText( + [ + "Sessions listed: 2", + "direct agent:main:main 1m ago model id:sid-real", + `direct agent:main:expli... 1m ago model id:${WARMUP_SESSION_ID_PREFIX}1`, + "", + ].join("\n"), + ); + + expect(filtered).toBe( + ["Sessions listed: 1", "direct agent:main:main 1m ago model id:sid-real", ""].join("\n"), + ); + }); + + it("does not drop unrelated text that merely mentions the warm-up prefix", () => { + const filtered = filterWarmupSessionsListText( + [ + "Sessions listed: 1", + `direct agent:main:main 1m ago model note:${WARMUP_SESSION_ID_PREFIX}mentioned`, + "", + ].join("\n"), + ); + + expect(filtered).toBe( + [ + "Sessions listed: 1", + `direct agent:main:main 1m ago model note:${WARMUP_SESSION_ID_PREFIX}mentioned`, + "", + ].join("\n"), + ); + }); +}); + +describe("runSessionsPassthrough", () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + captureMock.mockReset(); + execMock.mockClear(); + ensureLiveMock.mockClear(); + stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it("captures and filters `sessions list --json` instead of streaming warm-up entries", async () => { + captureMock.mockReturnValueOnce({ + status: 0, + output: JSON.stringify({ + count: 1, + totalCount: 1, + sessions: [{ key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }], + }), + }); + + await runSessionsPassthrough("alpha", { + verb: "list", + extraArgs: ["--agent", "main", "--json"], + }); + + expect(ensureLiveMock).toHaveBeenCalledWith("alpha", { allowNonReadyPhase: true }); + expect(execMock).not.toHaveBeenCalled(); + expect(captureMock).toHaveBeenCalledWith( + [ + "sandbox", + "exec", + "--name", + "alpha", + "--", + "openclaw", + "sessions", + "list", + "--agent", + "main", + "--json", + ], + { ignoreError: true, includeStreams: true }, + ); + expect(JSON.parse(String(stdoutSpy.mock.calls[0]?.[0]))).toEqual({ + count: 0, + totalCount: 0, + sessions: [], + }); + }); + + it("captures and filters text `sessions list` output", async () => { + captureMock.mockReturnValueOnce({ + status: 0, + stdout: [ + "Sessions listed: 1", + `direct agent:main:expli... 1m ago model id:${WARMUP_SESSION_ID_PREFIX}1`, + ].join("\n"), + stderr: "warning: noisy but non-fatal\n", + output: [ + "Sessions listed: 1", + `direct agent:main:expli... 1m ago model id:${WARMUP_SESSION_ID_PREFIX}1`, + ].join("\n"), + }); + + await runSessionsPassthrough("alpha", { verb: "list", extraArgs: ["--agent", "main"] }); + + expect(execMock).not.toHaveBeenCalled(); + expect(captureMock).toHaveBeenCalled(); + expect(String(stdoutSpy.mock.calls[0]?.[0])).toBe("Sessions listed: 0\n"); + expect(String(stderrSpy.mock.calls[0]?.[0])).toBe("warning: noisy but non-fatal\n"); + }); + + it("also filters the parent `sessions` list shorthand", async () => { + captureMock.mockReturnValueOnce({ + status: 0, + output: `Sessions listed: 1\nid:${WARMUP_SESSION_ID_PREFIX}1`, + }); + + await runSessionsPassthrough("alpha", { extraArgs: [] }); + + expect(execMock).not.toHaveBeenCalled(); + expect(captureMock).toHaveBeenCalledWith( + ["sandbox", "exec", "--name", "alpha", "--", "openclaw", "sessions"], + { ignoreError: true, includeStreams: true }, + ); + expect(String(stdoutSpy.mock.calls[0]?.[0])).toBe("Sessions listed: 0\n"); + }); + + it("fails closed on unrecognised JSON that could leak a warm-up session", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit:${code}`); + }) as never); + captureMock.mockReturnValueOnce({ + status: 0, + output: JSON.stringify({ + records: [{ sid: `${WARMUP_SESSION_ID_PREFIX}1` }], + }), + }); + + try { + await expect( + runSessionsPassthrough("alpha", { verb: "list", extraArgs: ["--json"] }), + ).rejects.toThrow("process.exit:1"); + } finally { + exitSpy.mockRestore(); + } + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Could not parse")); + }); + + it("passes through unrecognised JSON when it cannot leak a warm-up session", async () => { + const raw = JSON.stringify({ records: [{ key: "agent:main:main", sessionId: "sid-real" }] }); + captureMock.mockReturnValueOnce({ + status: 0, + output: raw, + }); + + await runSessionsPassthrough("alpha", { verb: "list", extraArgs: ["--json"] }); + + expect(String(stdoutSpy.mock.calls[0]?.[0])).toBe(`${raw}\n`); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("prints captured output when OpenClaw exits non-zero", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit:${code}`); + }) as never); + captureMock.mockReturnValueOnce({ + status: 2, + output: "", + stdout: "", + stderr: "unknown flag: --bad\n", + }); + + try { + await expect( + runSessionsPassthrough("alpha", { verb: "list", extraArgs: ["--bad"] }), + ).rejects.toThrow("process.exit:2"); + } finally { + exitSpy.mockRestore(); + } + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(String(stderrSpy.mock.calls[0]?.[0])).toBe("unknown flag: --bad\n"); + }); +}); diff --git a/src/lib/actions/sandbox/sessions/passthrough.ts b/src/lib/actions/sandbox/sessions/passthrough.ts index d3972fca57..d8d8177db5 100644 --- a/src/lib/actions/sandbox/sessions/passthrough.ts +++ b/src/lib/actions/sandbox/sessions/passthrough.ts @@ -1,9 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { captureOpenshell } from "../../../adapters/openshell/runtime"; import { CLI_NAME } from "../../../cli/branding"; -import { execSandbox } from "../exec"; +import { buildOpenshellExecArgs, computeExitCode, execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; +import { isWarmupSessionId, WARMUP_SESSION_ID_PREFIX } from "../warmup-session"; +import { balancedJsonCandidates, parseSessionIndex } from "./export"; export type SessionsPassthroughVerb = "list"; @@ -33,6 +36,139 @@ export function printSessionsPassthroughHelp(verb?: SessionsPassthroughVerb): vo console.log(""); } +function isFilterableListPassthrough(verb: SessionsPassthroughVerb | undefined) { + return verb === undefined || verb === "list"; +} + +function isJsonOutput(args: readonly string[]) { + return args.includes("--json"); +} + +function sessionEntryIsWarmup(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const obj = entry as Record; + for (const field of ["sessionId", "id"]) { + const value = obj[field]; + if (typeof value === "string" && isWarmupSessionId(value)) return true; + } + return false; +} + +function filterWarmupArray(entries: unknown[]): { entries: unknown[]; removed: number } { + const filtered = entries.filter((entry) => !sessionEntryIsWarmup(entry)); + return { entries: filtered, removed: entries.length - filtered.length }; +} + +function jsonCandidates(output: string): string[] { + const trimmed = output.trim(); + if (!trimmed) return ["[]"]; + const lines = trimmed.split(/\r?\n/); + const candidates = balancedJsonCandidates(trimmed); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const candidate = lines[index]?.trim(); + if (candidate && (candidate.startsWith("[") || candidate.startsWith("{"))) { + candidates.push(candidate); + } + } + candidates.push(trimmed); + return candidates; +} + +function parseJsonPayload(output: string): unknown | null { + for (const candidate of jsonCandidates(output)) { + try { + return JSON.parse(candidate); + } catch { + // Try the next tolerant candidate; OpenClaw may prefix Node warnings. + } + } + return null; +} + +function filterWarmupSessionsListPayload(parsed: unknown): unknown | null { + if (Array.isArray(parsed)) { + return filterWarmupArray(parsed).entries; + } + if (!parsed || typeof parsed !== "object") return null; + + const obj = parsed as Record; + for (const key of ["sessions", "entries", "items"]) { + const value = obj[key]; + if (!Array.isArray(value)) continue; + const { entries, removed } = filterWarmupArray(value); + if (removed === 0) return parsed; + const next = { ...obj, [key]: entries }; + if (typeof next.count === "number") next.count = Math.max(0, next.count - removed); + if (typeof next.totalCount === "number") { + next.totalCount = Math.max(0, next.totalCount - removed); + } + return next; + } + return null; +} + +function writeWithTrailingNewline(stream: NodeJS.WriteStream, value: string | undefined): void { + if (!value) return; + stream.write(value.endsWith("\n") ? value : `${value}\n`); +} + +function capturedStdout(result: { output: string; stdout?: string }): string { + return typeof result.stdout === "string" ? result.stdout.trim() : result.output; +} + +function capturedStderr(result: { stderr?: string }): string { + return typeof result.stderr === "string" ? result.stderr.trim() : ""; +} + +function printJsonParseFailure(): void { + console.error( + " Could not parse `openclaw sessions list --json` output as a session index. Check the OpenClaw version pinned in agents/openclaw/manifest.yaml.", + ); +} + +export function filterWarmupSessionsListJson(output: string): string | null { + const parsedIndex = parseSessionIndex(output); + if (parsedIndex === null) { + return null; + } + + const parsedPayload = parseJsonPayload(output); + const filteredPayload = + parsedPayload === null ? null : filterWarmupSessionsListPayload(parsedPayload); + if (filteredPayload !== null) { + return JSON.stringify(filteredPayload, null, 2); + } + + const sessions = parsedIndex.filter((entry) => !isWarmupSessionId(entry.sessionId)); + return JSON.stringify({ count: sessions.length, totalCount: sessions.length, sessions }, null, 2); +} + +function warmupIdInTextRow(line: string): boolean { + return line.includes(`id:${WARMUP_SESSION_ID_PREFIX}`); +} + +// Text output is a compatibility wrapper around OpenClaw's current non-TTY +// table. Prefer the JSON path for stable structure; this only hides warm-up +// rows from the human display until NemoClaw owns a native renderer. +export function filterWarmupSessionsListText(output: string): string { + const lines = output.split(/\r?\n/); + let removed = 0; + const filtered = lines.filter((line) => { + if (!warmupIdInTextRow(line)) return true; + removed += 1; + return false; + }); + if (removed === 0) return output; + return filtered + .map((line) => + line.replace(/^(Sessions listed:\s*)(\d+)(.*)$/, (_match, prefix, count, suffix) => { + const nextCount = Math.max(0, Number.parseInt(count, 10) - removed); + return `${prefix}${nextCount}${suffix}`; + }), + ) + .join("\n"); +} + export async function runSessionsPassthrough( sandboxName: string, { verb, extraArgs = [] }: SessionsPassthroughOptions = {}, @@ -41,5 +177,43 @@ export async function runSessionsPassthrough( const command = ["openclaw", "sessions"]; if (verb) command.push(verb); for (const arg of extraArgs) command.push(arg); + if (isFilterableListPassthrough(verb)) { + const result = captureOpenshell(buildOpenshellExecArgs(sandboxName, command), { + ignoreError: true, + includeStreams: true, + }); + const { code, errorMessage } = computeExitCode(result); + const capturedOutput = capturedStdout(result); + const capturedError = capturedStderr(result); + if (code !== 0) { + writeWithTrailingNewline(process.stdout, capturedOutput); + writeWithTrailingNewline(process.stderr, capturedError); + if (errorMessage) console.error(` Failed to invoke openshell: ${errorMessage}`); + process.exit(code); + } + + if (isJsonOutput(extraArgs)) { + const filtered = filterWarmupSessionsListJson(capturedOutput); + if (filtered === null) { + // Preserve pass-through compatibility unless the raw payload could leak + // an internal warm-up session. + if (capturedOutput.includes(WARMUP_SESSION_ID_PREFIX)) { + printJsonParseFailure(); + process.exit(1); + } + writeWithTrailingNewline(process.stdout, capturedOutput); + writeWithTrailingNewline(process.stderr, capturedError); + return; + } + writeWithTrailingNewline(process.stdout, filtered); + writeWithTrailingNewline(process.stderr, capturedError); + return; + } + + const filtered = filterWarmupSessionsListText(capturedOutput); + writeWithTrailingNewline(process.stdout, filtered); + writeWithTrailingNewline(process.stderr, capturedError); + return; + } await execSandbox(sandboxName, command); } diff --git a/src/lib/actions/sandbox/warmup-session.ts b/src/lib/actions/sandbox/warmup-session.ts new file mode 100644 index 0000000000..93d46dd46e --- /dev/null +++ b/src/lib/actions/sandbox/warmup-session.ts @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const WARMUP_SESSION_ID_PREFIX = "nemoclaw-onboard-warmup-"; + +export function isWarmupSessionId(sessionId: string): boolean { + return sessionId.startsWith(WARMUP_SESSION_ID_PREFIX); +} diff --git a/src/lib/adapters/openshell/client.test.ts b/src/lib/adapters/openshell/client.test.ts index 6762da4583..004de954e0 100644 --- a/src/lib/adapters/openshell/client.test.ts +++ b/src/lib/adapters/openshell/client.test.ts @@ -89,6 +89,24 @@ describe("openshell helpers", () => { expect(result).toEqual({ status: 1, output: "hello" }); }); + it("can expose raw stdout and stderr without changing the filtered output", () => { + const result = captureOpenshellCommand("openshell", ["status"], { + ignoreError: true, + includeStreams: true, + spawnSyncImpl: stubSpawnSync({ + status: 1, + stdout: "hello\n", + stderr: "boom\n", + }), + }); + expect(result).toEqual({ + status: 1, + output: "hello", + stdout: "hello\n", + stderr: "boom\n", + }); + }); + it("returns the spawn result when the command succeeds", () => { const result = runOpenshellCommand("openshell", ["status"], { spawnSyncImpl: stubSpawnSync({ diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index d4d264ab60..ae47222916 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -35,6 +35,7 @@ export interface RunOpenshellOptions extends OpenshellSpawnOptions { export interface CaptureOpenshellOptions extends OpenshellSpawnOptions { includeStderr?: boolean; + includeStreams?: boolean; } export interface CaptureOpenshellAsyncOptions extends CaptureOpenshellOptions { @@ -45,6 +46,8 @@ export interface CaptureOpenshellAsyncOptions extends CaptureOpenshellOptions { export interface CaptureOpenshellResult { status: number | null; output: string; + stdout?: string; + stderr?: string; error?: Error; signal?: NodeJS.Signals | null; } @@ -99,6 +102,14 @@ function captureOutput(result: SpawnSyncReturns, opts: CaptureOpenshellO return `${result.stdout || ""}${shouldIncludeStderr(opts) ? result.stderr || "" : ""}`.trim(); } +function rawStreams( + stdout: string | undefined, + stderr: string | undefined, + opts: CaptureOpenshellOptions, +): Pick { + return opts.includeStreams ? { stdout: stdout || "", stderr: stderr || "" } : {}; +} + function timeoutError(binary: string, args: string[], timeout: number): NodeJS.ErrnoException { const error = new Error( `spawn ${binary} ${args.join(" ")} timed out after ${timeout} ms`, @@ -169,6 +180,7 @@ export function captureOpenshellCommand( return { status: result.status, output: captureOutput(result, opts), + ...rawStreams(result.stdout, result.stderr, opts), error: result.error, signal: result.signal, }; @@ -178,6 +190,7 @@ export function captureOpenshellCommand( return { status: result.status ?? 1, output: captureOutput(result, opts), + ...rawStreams(result.stdout, result.stderr, opts), }; } @@ -240,6 +253,7 @@ export function captureOpenshellCommandAsync( resolve({ status: status ?? (timedOut ? null : 1), output: buildOutput(), + ...rawStreams(stdout, stderr, opts), ...(error ? { error } : {}), signal, }); diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index e9f429507f..fcf127e251 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -21,6 +21,7 @@ type RunnerOptions = { stdio?: StdioOptions; input?: string; ignoreError?: boolean; + includeStreams?: boolean; timeout?: number; }; @@ -55,6 +56,7 @@ export function captureOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { cwd: ROOT, env: opts.env, ignoreError: opts.ignoreError, + includeStreams: opts.includeStreams, timeout: opts.timeout, errorLine: console.error, exit: (code: number) => process.exit(code), @@ -66,6 +68,7 @@ export function captureSandboxSshConfig(sandboxName: string, opts: RunnerOptions cwd: ROOT, env: opts.env, ignoreError: opts.ignoreError, + includeStreams: opts.includeStreams, timeout: opts.timeout, errorLine: console.error, exit: (code: number) => process.exit(code), @@ -83,6 +86,7 @@ export function captureOpenshellForStatus(args: CommandArgs, opts: RunnerOptions cwd: ROOT, env: opts.env, ignoreError: opts.ignoreError, + includeStreams: opts.includeStreams, timeout: opts.timeout ?? getStatusProbeTimeoutMs(), killGraceMs: 1000, });