diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 8bee90135ba..87a7ebc708a 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1044,7 +1044,7 @@ nemohermes my-assistant agents delete work --force --json List OpenClaw conversation sessions in the sandbox. With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent. -This is a thin pass-through to `openclaw sessions` via `openshell sandbox exec`; flags accepted by the in-sandbox CLI are forwarded verbatim. +NemoClaw invokes `openclaw sessions` via `openshell sandbox exec` and forwards OpenClaw flags verbatim, but filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output. ```bash nemohermes my-assistant sessions @@ -1053,8 +1053,8 @@ nemohermes my-assistant sessions --all-agents --json ### `nemohermes sessions list` -Pass-through to `openclaw sessions list` inside the sandbox. -Accepts every flag the in-sandbox CLI does (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`). +Invoke `openclaw sessions list` inside the sandbox. +NemoClaw forwards every flag the in-sandbox CLI accepts (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`) and filters the resulting default table or JSON so internal `nemoclaw-onboard-warmup-*` sessions are hidden. ```bash nemohermes my-assistant sessions list @@ -1110,7 +1110,8 @@ The command routes by the sandbox's agent kind recorded in the registry. For an OpenClaw sandbox, the command enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` (plus optional `.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload. -With no positional keys, the command exports every session for the agent. +With no positional keys, the command exports every non-internal session for the agent; if only internal warm-up sessions exist, the command reports that there are no sessions to bundle and writes no artifact. +Internal `nemoclaw-onboard-warmup-*` sessions are excluded from export-all output, but passing an explicit warm-up session key still exports that session for debugging. Pass one or more keys (alias or canonical `agent::`) to filter. For a Hermes sandbox, the command invokes the in-sandbox `hermes sessions export` against a staging path under `/sandbox/.nemoclaw-staging` (the same downloadable workspace boundary OpenClaw uses), then downloads the resulting single JSONL stream to the host. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 1fb1af6c5f3..aa47b724472 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1342,7 +1342,7 @@ Pass `-f` / `--file ` to point at the manifest; `--yes` confirms th List OpenClaw conversation sessions in the sandbox. With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent. -This is a thin pass-through to `openclaw sessions` via `openshell sandbox exec`; flags accepted by the in-sandbox CLI are forwarded verbatim. +NemoClaw invokes `openclaw sessions` via `openshell sandbox exec` and forwards OpenClaw flags verbatim, but filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output. ```bash $$nemoclaw my-assistant sessions @@ -1351,8 +1351,8 @@ $$nemoclaw my-assistant sessions --all-agents --json ### `$$nemoclaw sessions list` -Pass-through to `openclaw sessions list` inside the sandbox. -Accepts every flag the in-sandbox CLI does (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`). +Invoke `openclaw sessions list` inside the sandbox. +NemoClaw forwards every flag the in-sandbox CLI accepts (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`) and filters the resulting default table or JSON so internal `nemoclaw-onboard-warmup-*` sessions are hidden. ```bash $$nemoclaw my-assistant sessions list @@ -1408,7 +1408,8 @@ The command routes by the sandbox's agent kind recorded in the registry. For an OpenClaw sandbox, the command enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` (plus optional `.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload. -With no positional keys, the command exports every session for the agent. +With no positional keys, the command exports every non-internal session for the agent; if only internal warm-up sessions exist, the command reports that there are no sessions to bundle and writes no artifact. +Internal `nemoclaw-onboard-warmup-*` sessions are excluded from export-all output, but passing an explicit warm-up session key still exports that session for debugging. Pass one or more keys (alias or canonical `agent::`) to filter. For a Hermes sandbox, the command invokes the in-sandbox `hermes sessions export` against a staging path under `/sandbox/.nemoclaw-staging` (the same downloadable workspace boundary OpenClaw uses), then downloads the resulting single JSONL stream to the host. diff --git a/src/commands/sandbox/sessions.ts b/src/commands/sandbox/sessions.ts index d3bb3ce0d30..593df0b69d9 100644 --- a/src/commands/sandbox/sessions.ts +++ b/src/commands/sandbox/sessions.ts @@ -13,7 +13,7 @@ export default class SandboxSessionsCommand extends NemoClawCommand { static strict = false; static summary = "List OpenClaw conversation sessions in a sandbox"; static description = - "Pass through to `openclaw sessions` in the sandbox. With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent. Additional OpenClaw flags are forwarded verbatim after the sandbox name."; + "Pass through to `openclaw sessions` in the sandbox. With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent, hiding internal NemoClaw onboard warm-up sessions from default user-facing output. Additional OpenClaw flags are forwarded verbatim after the sandbox name."; static usage = [" [openclaw-sessions-flags...]"]; static examples = [ "<%= config.bin %> sandbox sessions alpha", diff --git a/src/commands/sandbox/sessions/export.ts b/src/commands/sandbox/sessions/export.ts index 9b5d18a203b..3b05c804af5 100644 --- a/src/commands/sandbox/sessions/export.ts +++ b/src/commands/sandbox/sessions/export.ts @@ -21,8 +21,9 @@ export default class SandboxSessionsExportCommand extends NemoClawCommand { "", "OpenClaw sandbox: tar the per-session JSONL files inside the sandbox and", "download the bundle to the host via `openshell sandbox download`. By default", - "every session for the agent is exported; pass one or more positional keys to", - "filter. Keys may be either an alias (e.g. `main`, `telegram:t-1`) or the", + "every non-internal session for the agent is exported; NemoClaw onboard", + "warm-up sessions are hidden from export-all output. Pass one or more", + "positional keys to filter. Keys may be either an alias (e.g. `main`, `telegram:t-1`) or the", "canonical `agent::` form. Use --agent to scope aliases to a", "non-default agent; mismatched --agent + canonical-key combinations are", "refused. Trajectory files are excluded by default (large) and re-added with", diff --git a/src/commands/sandbox/sessions/list.ts b/src/commands/sandbox/sessions/list.ts index 6a4393c1ed2..b83155dfb7f 100644 --- a/src/commands/sandbox/sessions/list.ts +++ b/src/commands/sandbox/sessions/list.ts @@ -13,7 +13,7 @@ export default class SandboxSessionsListCommand extends NemoClawCommand { static strict = false; static summary = "List OpenClaw conversation sessions in a sandbox"; static description = - "Pass through to `openclaw sessions list` in the sandbox. All OpenClaw flags (--agent, --all-agents, --active, --limit, --json, --store, --verbose) are forwarded verbatim."; + "Pass through to `openclaw sessions list` in the sandbox, hiding internal NemoClaw onboard warm-up sessions from default user-facing output. All OpenClaw flags (--agent, --all-agents, --active, --limit, --json, --store, --verbose) are forwarded verbatim."; static usage = [" [openclaw-sessions-list-flags...]"]; static examples = [ "<%= config.bin %> sandbox sessions list alpha", diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index 44512db3a45..d3d7c8f5384 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 4c00d1ef295..ebccf488c08 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 f2dc47d7101..b157d78723b 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -21,7 +21,8 @@ vi.mock("../../../state/registry", () => ({ import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; import * as registry from "../../../state/registry"; -import { buildSandboxTarArgv, exportSandboxSessions, parseSessionIndex } from "./export"; +import { isWarmupSessionId, WARMUP_SESSION_ID_PREFIX } from "../warmup-session"; +import { buildSandboxTarArgv, exportSandboxSessions } from "./export"; const captureMock = captureOpenshell as unknown as ReturnType; const runMock = runOpenshell as unknown as ReturnType; @@ -74,42 +75,70 @@ describe("buildSandboxTarArgv", () => { }); }); -describe("parseSessionIndex", () => { - it("accepts a plain JSON array of entries", () => { - const output = '[{"key":"agent:main:main","sessionId":"sid-1"}]'; - expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); +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); }); +}); - it("accepts an object wrapper with a sessions array", () => { - const output = '{"sessions":[{"key":"agent:main:main","sessionId":"sid-1"}]}'; - expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); - }); +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" }, + ]), + ), + ); - it("treats id as an alias for sessionId", () => { - const output = '[{"key":"agent:main:main","id":"sid-1"}]'; - expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); - }); + const result = await exportSandboxSessions({ + sandboxName: "alpha", + out: "./out.tgz", + format: "tar", + }); - it("tolerates log noise preceding a single-line JSON payload", () => { - const output = 'warning: deprecation\n[{"key":"agent:main:main","sessionId":"sid-1"}]'; - expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); + 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("returns [] when the upstream emits an empty index (empty array)", () => { - expect(parseSessionIndex("[]")).toEqual([]); - }); + 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` }, + ]), + ), + ); - it("returns [] when the upstream emits no output at all", () => { - expect(parseSessionIndex("")).toEqual([]); + await expect( + exportSandboxSessions({ + sandboxName: "alpha", + out: "./sessions-alpha", + }), + ).rejects.toThrow(/agent 'main' has no sessions to bundle/); + expect(runMock).not.toHaveBeenCalled(); }); - it("returns null when the output is non-empty but no JSON shape is recognised", () => { - expect(parseSessionIndex("hello world")).toBeNull(); - }); + 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", + }); - it("returns null when the array is non-empty but every entry uses unknown field names (schema drift)", () => { - const output = JSON.stringify([{ alias: "agent:main:main", uuid: "sid-1" }]); - expect(parseSessionIndex(output)).toBeNull(); + expect(result.resolvedSessionIds).toEqual([warmupId]); + expect(result.resolvedFiles).toEqual([`${warmupId}.jsonl`]); }); }); diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index b1ff186d081..6b45c5bbaf2 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -46,6 +46,8 @@ import { CLI_NAME } from "../../../cli/branding"; import * as registry from "../../../state/registry"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { resolveHostPathFromCwd } from "../host-path"; +import { isWarmupSessionId } from "../warmup-session"; +import { type SessionIndexEntry, parseSessionIndex } from "./session-index"; import { DEFAULT_AGENT_ID, parseAgentIdFromSessionKey, @@ -87,11 +89,6 @@ export interface SessionsExportResult { sessions: SessionExportEntry[]; } -interface SessionIndexEntry { - key: string; - sessionId: string; -} - // Session ids must start with an alphanumeric character so they can never be // interpreted as a tar option (`--checkpoint-action=...`, etc.) when appended // to the argv. Hyphens and underscores remain permitted as inner characters. @@ -509,7 +506,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) { @@ -583,94 +584,6 @@ function readSessionIndex(sandboxName: string, agent: string): SessionIndexEntry return parsed; } -// Tolerant parsing of `openclaw sessions list --json`. -// -// - Invalid state addressed: the upstream OpenClaw CLI has historically -// emitted the session index either as a plain JSON array, wrapped in -// `{sessions:[...]}` / `{entries:[...]}` / `{items:[...]}`, with -// `sessionId` or `id` as the file-name field, and prefixed with Node -// experimental-feature warnings. Each shape variant is enough to break a -// strict parser and abort the export. -// - Source boundary: NemoClaw must accept the upstream-of-the-day shape -// read-only. The upstream-pinned contract is captured in -// `agents/openclaw/manifest.yaml -> expected_version`; this code does not -// hard-code the literal so the manifest stays the single source of -// truth. -// - Source-fix constraint: tightening the parser to one shape would -// regress against any in-the-wild OpenClaw build that still emits a -// legacy shape, and NemoClaw cannot rev the upstream CLI from this side. -// - Regression-test coverage: `export.test.ts > parseSessionIndex` covers -// each accepted shape plus the log-noise prefix; CLI-level coverage in -// `test/sandbox-sessions-export-cli.test.ts` exercises the array and -// wrapped-object forms via the stub openshell. -// - Removal condition: once OpenClaw documents a single stable JSON -// contract for `sessions list --json` in its release notes, this -// parser can collapse to the strict shape and the alias map can drop. -export function parseSessionIndex(output: string): SessionIndexEntry[] | null { - const trimmed = output.trim(); - if (!trimmed) return []; - const lines = trimmed.split(/\r?\n/); - const candidates: string[] = []; - 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); - for (const candidate of candidates) { - const entries = tryExtractIndex(candidate); - if (entries) return entries; - } - // Non-empty output, but no JSON-shaped candidate parsed into a recognised - // session index. Distinguish this from the empty-string case so callers - // can surface a parse error instead of silently treating it as "no - // sessions" — the latter would mask an upstream contract drift. - return null; -} - -function tryExtractIndex(text: string): SessionIndexEntry[] | null { - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return null; - } - const array = pickIndexArray(parsed); - if (!array) return null; - // Legitimate empty index — upstream said no sessions. - if (array.length === 0) return []; - const entries: SessionIndexEntry[] = []; - for (const entry of array) { - if (!entry || typeof entry !== "object") continue; - const obj = entry as Record; - const key = typeof obj.key === "string" ? obj.key : null; - const sessionId = - typeof obj.sessionId === "string" - ? obj.sessionId - : typeof obj.id === "string" - ? obj.id - : null; - if (key && sessionId) entries.push({ key, sessionId }); - } - // Non-empty upstream array yielded zero recognised entries — schema drift. - // Return null so the caller surfaces a parse error instead of silently - // treating it as "no sessions". - if (entries.length === 0) return null; - return entries; -} - -function pickIndexArray(parsed: unknown): unknown[] | null { - if (Array.isArray(parsed)) return parsed; - if (parsed && typeof parsed === "object") { - const obj = parsed as Record; - if (Array.isArray(obj.sessions)) return obj.sessions; - if (Array.isArray(obj.entries)) return obj.entries; - if (Array.isArray(obj.items)) return obj.items; - } - return null; -} - function stagingTarballPath(agent: string): string { const suffix = randomBytes(6).toString("hex"); return `${STAGING_DIR_IN_SANDBOX}/sessions-export-${agent}-${suffix}.tgz`; 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 00000000000..f0e2eff7e97 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/passthrough.test.ts @@ -0,0 +1,369 @@ +// 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("filters warm-up sessions from every recognized wrapped list array", () => { + const filtered = filterWarmupSessionsListJson( + JSON.stringify({ + count: 2, + totalCount: 2, + sessions: [{ key: "agent:main:explicit:real", sessionId: "sid-real" }], + entries: [{ key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` }], + }), + ); + + expect(JSON.stringify(JSON.parse(filtered as string))).not.toContain(WARMUP_SESSION_ID_PREFIX); + expect(JSON.parse(filtered as string)).toEqual({ + count: 1, + totalCount: 1, + sessions: [{ key: "agent:main:explicit:real", sessionId: "sid-real" }], + entries: [], + }); + }); + + 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("filters warm-up rows when the session id uses alternate text labels or a bare id column", () => { + const filtered = filterWarmupSessionsListText( + [ + "Sessions listed: 4", + "direct agent:main:main 1m ago model id:sid-real", + `direct agent:main:explicit 1m ago model sessionId:${WARMUP_SESSION_ID_PREFIX}session-id`, + `direct agent:main:explicit 1m ago model sid:${WARMUP_SESSION_ID_PREFIX}sid`, + `direct agent:main:explicit 1m ago model ${WARMUP_SESSION_ID_PREFIX}bare`, + "", + ].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, maxBuffer: 64 * 1024 * 1024 }, + ); + 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, maxBuffer: 64 * 1024 * 1024 }, + ); + 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("reports a clear filter buffer error when large sessions list output exceeds capture capacity", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((( + code?: string | number | null, + ) => { + throw new Error(`process.exit:${code}`); + }) as never); + captureMock.mockReturnValueOnce({ + status: null, + output: "", + stdout: "", + stderr: "", + error: Object.assign(new Error("spawnSync openshell ENOBUFS"), { code: "ENOBUFS" }), + }); + + try { + await expect( + runSessionsPassthrough("alpha", { verb: "list", extraArgs: ["--all-agents", "--json"] }), + ).rejects.toThrow("process.exit:1"); + } finally { + exitSpy.mockRestore(); + } + + expect(captureMock).toHaveBeenCalledWith(expect.any(Array), { + ignoreError: true, + includeStreams: true, + maxBuffer: 64 * 1024 * 1024, + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("output exceeded NemoClaw's 64 MiB filtering buffer"), + ); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("--agent")); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("--limit")); + }); + + 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 d3972fca57b..9a3bd6762ce 100644 --- a/src/lib/actions/sandbox/sessions/passthrough.ts +++ b/src/lib/actions/sandbox/sessions/passthrough.ts @@ -1,9 +1,14 @@ // 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 "./session-index"; + +const SESSIONS_LIST_CAPTURE_MAX_BUFFER_BYTES = 64 * 1024 * 1024; export type SessionsPassthroughVerb = "list"; @@ -29,10 +34,169 @@ export function printSessionsPassthroughHelp(verb?: SessionsPassthroughVerb): vo console.log( ` Pass-through to \`openclaw sessions${usageSuffix} ...\` inside the sandbox via \`openshell sandbox exec\`.`, ); + console.log(" Internal NemoClaw onboard warm-up sessions are hidden from default list output."); console.log(" All flags accepted by the in-sandbox OpenClaw CLI are forwarded verbatim."); 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; + let sawSessionArray = false; + let removedTotal = 0; + const next = { ...obj }; + for (const key of ["sessions", "entries", "items"]) { + const value = obj[key]; + if (!Array.isArray(value)) continue; + sawSessionArray = true; + const { entries, removed } = filterWarmupArray(value); + next[key] = entries; + removedTotal += removed; + } + if (!sawSessionArray) return null; + if (removedTotal === 0) return parsed; + if (typeof next.count === "number") next.count = Math.max(0, next.count - removedTotal); + if (typeof next.totalCount === "number") { + next.totalCount = Math.max(0, next.totalCount - removedTotal); + } + return next; +} + +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.", + ); +} + +function printSessionsListCaptureBufferFailure(): void { + console.error( + ` OpenClaw sessions list output exceeded NemoClaw's ${Math.round( + SESSIONS_LIST_CAPTURE_MAX_BUFFER_BYTES / 1024 / 1024, + )} MiB filtering buffer. Retry with narrower OpenClaw filters such as --agent, --limit, or --json.`, + ); +} + +function isCaptureBufferFailure(result: { error?: Error }): boolean { + return (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOBUFS"; +} + +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 { + const escapedPrefix = WARMUP_SESSION_ID_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const labeledSessionId = new RegExp(`\\b(?:id|sessionId|sid):${escapedPrefix}`); + const bareSessionIdColumn = new RegExp(`(?:^|\\s)${escapedPrefix}[^\\s]*(?:\\s|$)`); + return labeledSessionId.test(line) || bareSessionIdColumn.test(line); +} + +// Text output is a compatibility wrapper around OpenClaw's non-TTY table. +// OpenClaw owns the table format and currently stores NemoClaw's onboarding +// scope-upgrade warm-up as a normal session, so we hide rows whose session-id +// field uses the internal warm-up prefix. The accepted source boundary is only +// session-id shaped cells (`id:`, `sessionId:`, `sid:`, or a bare id column), not +// arbitrary notes that merely mention the prefix. Prefer the JSON path for +// stable structure; remove this text filter when OpenClaw can mark/prevent the +// internal warm-up session or NemoClaw renders list output from stable JSON. +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 +205,48 @@ 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, + maxBuffer: SESSIONS_LIST_CAPTURE_MAX_BUFFER_BYTES, + }); + 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 (isCaptureBufferFailure(result)) { + printSessionsListCaptureBufferFailure(); + } else 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/sessions/session-index.test.ts b/src/lib/actions/sandbox/sessions/session-index.test.ts new file mode 100644 index 00000000000..d0dbaebdb13 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/session-index.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { parseSessionIndex } from "./session-index"; + +describe("parseSessionIndex", () => { + it("accepts a plain JSON array of entries", () => { + const output = '[{"key":"agent:main:main","sessionId":"sid-1"}]'; + expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); + }); + + it("accepts an object wrapper with a sessions array", () => { + const output = '{"sessions":[{"key":"agent:main:main","sessionId":"sid-1"}]}'; + expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); + }); + + it("treats id as an alias for sessionId", () => { + const output = '[{"key":"agent:main:main","id":"sid-1"}]'; + expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]); + }); + + it("tolerates log noise preceding a single-line JSON payload", () => { + const output = 'warning: deprecation\n[{"key":"agent:main:main","sessionId":"sid-1"}]'; + 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([]); + }); + + it("returns [] when the upstream emits no output at all", () => { + expect(parseSessionIndex("")).toEqual([]); + }); + + it("returns null when the output is non-empty but no JSON shape is recognised", () => { + expect(parseSessionIndex("hello world")).toBeNull(); + }); + + it("returns null when the array is non-empty but every entry uses unknown field names (schema drift)", () => { + 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(); + }); +}); diff --git a/src/lib/actions/sandbox/sessions/session-index.ts b/src/lib/actions/sandbox/sessions/session-index.ts new file mode 100644 index 00000000000..8034c1ab211 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/session-index.ts @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface SessionIndexEntry { + key: string; + sessionId: string; +} + +// Tolerant parsing of `openclaw sessions list --json`. +// +// - Invalid state addressed: the upstream OpenClaw CLI has historically +// emitted the session index either as a plain JSON array, wrapped in +// `{sessions:[...]}` / `{entries:[...]}` / `{items:[...]}`, with +// `sessionId` or `id` as the file-name field, and prefixed with Node +// experimental-feature warnings. Each shape variant is enough to break a +// strict parser and abort the export. +// - Source boundary: NemoClaw must accept the upstream-of-the-day shape +// read-only. The upstream-pinned contract is captured in +// `agents/openclaw/manifest.yaml -> expected_version`; this code does not +// hard-code the literal so the manifest stays the single source of +// truth. +// - Source-fix constraint: tightening the parser to one shape would +// regress against any in-the-wild OpenClaw build that still emits a +// legacy shape, and NemoClaw cannot rev the upstream CLI from this side. +// - Regression-test coverage: `session-index.test.ts > parseSessionIndex` +// covers each accepted shape plus the log-noise prefix; CLI-level +// coverage in `test/sandbox-sessions-export-cli.test.ts` exercises the +// array and wrapped-object forms via the stub openshell. +// - Removal condition: once OpenClaw documents a single stable JSON +// contract for `sessions list --json` in its release notes, this +// parser can collapse to the strict shape and the alias map can drop. +export function parseSessionIndex(output: string): SessionIndexEntry[] | null { + 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); + for (const candidate of candidates) { + const entries = tryExtractIndex(candidate); + if (entries) return entries; + } + // Non-empty output, but no JSON-shaped candidate parsed into a recognised + // session index. Distinguish this from the empty-string case so callers + // can surface a parse error instead of silently treating it as "no + // sessions" — the latter would mask an upstream contract drift. + 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 { + parsed = JSON.parse(text); + } catch { + return null; + } + const array = pickIndexArray(parsed); + if (!array) return null; + // Legitimate empty index — upstream said no sessions. + if (array.length === 0) return []; + const entries: SessionIndexEntry[] = []; + for (const entry of array) { + if (!entry || typeof entry !== "object") continue; + const obj = entry as Record; + const key = typeof obj.key === "string" ? obj.key : null; + const sessionId = + typeof obj.sessionId === "string" + ? obj.sessionId + : typeof obj.id === "string" + ? obj.id + : null; + if (key && sessionId) entries.push({ key, sessionId }); + } + // Non-empty upstream array yielded zero recognised entries — schema drift. + // Return null so the caller surfaces a parse error instead of silently + // treating it as "no sessions". + if (entries.length === 0) return null; + return entries; +} + +function pickIndexArray(parsed: unknown): unknown[] | null { + if (Array.isArray(parsed)) return parsed; + if (parsed && typeof parsed === "object") { + const obj = parsed as Record; + if (Array.isArray(obj.sessions)) return obj.sessions; + if (Array.isArray(obj.entries)) return obj.entries; + if (Array.isArray(obj.items)) return obj.items; + } + return null; +} diff --git a/src/lib/actions/sandbox/warmup-session.ts b/src/lib/actions/sandbox/warmup-session.ts new file mode 100644 index 00000000000..9a565bfa78b --- /dev/null +++ b/src/lib/actions/sandbox/warmup-session.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// NemoClaw's onboarding scope-upgrade warm-up currently runs through +// OpenClaw as a normal in-sandbox session. Until OpenClaw can pre-approve the +// full scope set or mark/prevent this internal session at the source, tag it +// with this prefix and hide it from default user-facing list/export-all output. +// Explicit session-key export remains allowed for debugging. +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 b35cc7d9c96..ec41f409f53 100644 --- a/src/lib/adapters/openshell/client.test.ts +++ b/src/lib/adapters/openshell/client.test.ts @@ -118,17 +118,24 @@ describe("openshell helpers", () => { expect(result.status).toBe(0); }); - it("passes timeout options through to OpenShell spawn calls", () => { - const timeouts: Array = []; + it("passes timeout and maxBuffer options through to OpenShell spawn calls", () => { + const observedOptions: Array<{ timeout?: number; maxBuffer?: number }> = []; const spawnSyncImpl: OpenshellSpawnSync = (_command, _args, options) => { - timeouts.push(options.timeout); + observedOptions.push({ timeout: options.timeout, maxBuffer: options.maxBuffer }); return makeSpawnResult({ status: 0, stdout: "ok\n", stderr: "" }); }; runOpenshellCommand("openshell", ["status"], { timeout: 4321, spawnSyncImpl }); - captureOpenshellCommand("openshell", ["status"], { timeout: 9876, spawnSyncImpl }); + captureOpenshellCommand("openshell", ["status"], { + timeout: 9876, + maxBuffer: 123456, + spawnSyncImpl, + }); - expect(timeouts).toEqual([4321, 9876]); + expect(observedOptions).toEqual([ + { timeout: 4321, maxBuffer: undefined }, + { timeout: 9876, maxBuffer: 123456 }, + ]); }); it("returns ignored run timeouts so callers can fall back", () => { @@ -172,6 +179,31 @@ describe("openshell helpers", () => { }); }); + it("returns ignored capture buffer failures with partial streams", () => { + const result = captureOpenshellCommand("openshell", ["sandbox", "exec"], { + ignoreError: true, + includeStreams: true, + maxBuffer: 1024, + spawnSyncImpl: stubSpawnSync({ + status: null, + stdout: "partial\n", + stderr: "buffer detail\n", + error: Object.assign(new Error("spawnSync openshell ENOBUFS"), { code: "ENOBUFS" }), + signal: "SIGTERM", + }), + exit: exitWithCode, + }); + + expect(result).toEqual({ + status: null, + output: "partial", + stdout: "partial\n", + stderr: "buffer detail\n", + error: expect.objectContaining({ message: expect.stringContaining("ENOBUFS") }), + signal: "SIGTERM", + }); + }); + it("verifies sandbox existence before requesting SSH config", () => { const calls: string[][] = []; const spawnSyncImpl: OpenshellSpawnSync = (_command, args) => { diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 78c9a2f4cda..cfbebb8a273 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -36,6 +36,7 @@ export interface RunOpenshellOptions extends OpenshellSpawnOptions { export interface CaptureOpenshellOptions extends OpenshellSpawnOptions { includeStderr?: boolean; includeStreams?: boolean; + maxBuffer?: number; } export interface CaptureOpenshellAsyncOptions extends CaptureOpenshellOptions { @@ -94,6 +95,11 @@ function isIgnoredTimeout(error: Error, opts: OpenshellSpawnOptions): boolean { return opts.ignoreError === true && (error as NodeJS.ErrnoException).code === "ETIMEDOUT"; } +function isIgnoredCaptureError(error: Error, opts: CaptureOpenshellOptions): boolean { + if (isIgnoredTimeout(error, opts)) return true; + return opts.ignoreError === true && (error as NodeJS.ErrnoException).code === "ENOBUFS"; +} + function shouldIncludeStderr(opts: CaptureOpenshellOptions): boolean { return opts.includeStderr === true || opts.ignoreError !== true; } @@ -174,9 +180,10 @@ export function captureOpenshellCommand( encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: opts.timeout, + maxBuffer: opts.maxBuffer, }); if (result.error) { - if (isIgnoredTimeout(result.error, opts)) { + if (isIgnoredCaptureError(result.error, opts)) { return { status: result.status, output: captureOutput(result, opts), diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index 9f658e640b7..0a1fb5972e7 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -24,6 +24,7 @@ type RunnerOptions = { includeStderr?: boolean; includeStreams?: boolean; timeout?: number; + maxBuffer?: number; }; let openshellBin: string | null = null; @@ -60,6 +61,7 @@ export function captureOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { includeStderr: opts.includeStderr, includeStreams: opts.includeStreams, timeout: opts.timeout, + maxBuffer: opts.maxBuffer, errorLine: console.error, exit: (code: number) => process.exit(code), }); @@ -70,6 +72,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), diff --git a/test/sandbox-sessions-export-cli.test.ts b/test/sandbox-sessions-export-cli.test.ts index df805638b59..2cec71b57b9 100644 --- a/test/sandbox-sessions-export-cli.test.ts +++ b/test/sandbox-sessions-export-cli.test.ts @@ -8,7 +8,12 @@ import { describe, expect, it } from "vitest"; import { runWithEnv, writeSandboxRegistry } from "./cli/helpers"; -function buildStubOpenshell(home: string, logFile: string, sessionListJson: string): string { +function buildStubOpenshell( + home: string, + logFile: string, + sessionListJson: string, + sessionListStderr = "", +): string { const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); fs.writeFileSync( @@ -22,6 +27,7 @@ function buildStubOpenshell(home: string, logFile: string, sessionListJson: stri ' "gateway info -g nemoclaw"*) printf "Gateway: nemoclaw\\n"; exit 0 ;;', ' *"openclaw sessions list"*)', ` printf '%s\\n' ${JSON.stringify(sessionListJson)}`, + ` if [ -n ${JSON.stringify(sessionListStderr)} ]; then printf '%s\\n' ${JSON.stringify(sessionListStderr)} >&2; fi`, " exit 0 ;;", ' *"sandbox exec --name alpha -- sh -c"*) exit 0 ;;', ' "sandbox download"*)', @@ -37,6 +43,47 @@ function buildStubOpenshell(home: string, logFile: string, sessionListJson: stri return localBin; } +describe("sandbox sessions list CLI", () => { + it("filters warm-up sessions and preserves OpenClaw stderr", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-list-")); + try { + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const localBin = buildStubOpenshell( + home, + openshellLog, + JSON.stringify({ + count: 2, + totalCount: 2, + sessions: [ + { key: "agent:main:explicit:warm", sessionId: "nemoclaw-onboard-warmup-1" }, + { key: "agent:main:explicit:real", sessionId: "sid-real" }, + ], + }), + "warning: noisy but non-fatal", + ); + + const result = runWithEnv("alpha sessions list --json 2>&1", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + expect(result.code).toBe(0); + expect(result.out).toContain("warning: noisy but non-fatal"); + expect(result.out).not.toContain("nemoclaw-onboard-warmup-"); + expect(JSON.parse(result.out.slice(0, result.out.indexOf("\nwarning:")))).toEqual({ + count: 1, + totalCount: 1, + sessions: [{ key: "agent:main:explicit:real", sessionId: "sid-real" }], + }); + + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch(/openclaw sessions list --json/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe("sandbox sessions export CLI", () => { it("enumerates every session via openclaw sessions list when no keys are supplied and tars only the resolved files", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-all-")); @@ -242,6 +289,40 @@ describe("sandbox sessions export CLI", () => { } }); + it("reports nothing to export and creates no output artifact when only warm-up sessions exist", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-warmup-")); + try { + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const localBin = buildStubOpenshell( + home, + openshellLog, + JSON.stringify([ + { + key: "agent:main:explicit:warm", + sessionId: "nemoclaw-onboard-warmup-cli-only", + }, + ]), + ); + + const outDir = path.join(home, "sessions-alpha"); + const result = runWithEnv(`alpha sessions export --out ${outDir} 2>&1`, { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + expect(result.code).toBe(1); + expect(result.out).toMatch(/agent 'main' has no sessions to bundle/); + expect(fs.existsSync(outDir)).toBe(false); + + const calls = fs.existsSync(openshellLog) ? fs.readFileSync(openshellLog, "utf8") : ""; + expect(calls).toMatch(/openclaw sessions list --agent main --json/); + expect(calls).not.toMatch(/-- sh -c/); + expect(calls).not.toMatch(/sandbox download/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("routes a hermes sandbox to `hermes sessions export` instead of `openclaw sessions list`", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-hermes-")); try {