From 72d365725c8387d45669c84ec8a01a51f0da96a2 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 30 May 2026 18:32:43 +0000 Subject: [PATCH 1/7] feat(cli): add sandbox sessions subcommand group Signed-off-by: Tinson Lai --- .github/workflows/nightly-e2e.yaml | 27 +++ docs/index.yml | 3 + docs/reference/commands.mdx | 28 +++ docs/reference/session-storage.mdx | 98 ++++++++ src/commands/sandbox/sessions.ts | 29 +++ src/commands/sandbox/sessions/cleanup.ts | 29 +++ src/commands/sandbox/sessions/download.ts | 63 +++++ src/commands/sandbox/sessions/list.ts | 28 +++ src/commands/sandbox/sessions/rm.ts | 57 +++++ src/lib/actions/sandbox/sessions/download.ts | 180 +++++++++++++++ .../actions/sandbox/sessions/passthrough.ts | 23 ++ .../actions/sandbox/sessions/paths.test.ts | 65 ++++++ src/lib/actions/sandbox/sessions/paths.ts | 48 ++++ src/lib/actions/sandbox/sessions/rm.ts | 178 +++++++++++++++ .../actions/sandbox/sessions/store.test.ts | 82 +++++++ src/lib/actions/sandbox/sessions/store.ts | 53 +++++ src/lib/cli/command-registry.test.ts | 26 ++- src/lib/cli/public-display-defaults.ts | 40 ++++ test/cli.test.ts | 159 +++++++++++++ test/e2e/test-sessions-cli.sh | 215 ++++++++++++++++++ 20 files changed, 1420 insertions(+), 11 deletions(-) create mode 100644 docs/reference/session-storage.mdx create mode 100644 src/commands/sandbox/sessions.ts create mode 100644 src/commands/sandbox/sessions/cleanup.ts create mode 100644 src/commands/sandbox/sessions/download.ts create mode 100644 src/commands/sandbox/sessions/list.ts create mode 100644 src/commands/sandbox/sessions/rm.ts create mode 100644 src/lib/actions/sandbox/sessions/download.ts create mode 100644 src/lib/actions/sandbox/sessions/passthrough.ts create mode 100644 src/lib/actions/sandbox/sessions/paths.test.ts create mode 100644 src/lib/actions/sandbox/sessions/paths.ts create mode 100644 src/lib/actions/sandbox/sessions/rm.ts create mode 100644 src/lib/actions/sandbox/sessions/store.test.ts create mode 100644 src/lib/actions/sandbox/sessions/store.ts create mode 100755 test/e2e/test-sessions-cli.sh diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index eeb501fe75..b0e0a79467 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -502,6 +502,30 @@ jobs: secrets: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} + # ── Sessions CLI E2E (#834, #3978, #3979) ─────────────────────────── + # Exercises the host-side `nemoclaw sessions {list,cleanup,rm,download}` + # surface against a live sandbox: pass-through to `openclaw sessions list/cleanup`, + # raw rm + sessions.json edit, and openshell-based download of the agent + # sessions directory to the host. + sessions-cli-e2e: + if: >- + github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' || + inputs.jobs == '' || + contains(format(',{0},', inputs.jobs), ',sessions-cli-e2e,')) + uses: ./.github/workflows/e2e-script.yaml + with: + ref: ${{ inputs.target_ref || github.ref }} + script: test/e2e/test-sessions-cli.sh + timeout_minutes: 60 + artifact_name: "install-log-sessions-cli" + artifact_path: | + /tmp/nemoclaw-e2e-install.log + env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_SANDBOX_NAME":"e2e-sessions-cli"}' + nvidia_api_key: true + github_token: true + secrets: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} # ── Channels stop/start lifecycle E2E (#3462) ─────────────────────── # Regression coverage for #3453 (stop must disable across rebuild), #3381 # (start must re-attach from cached credentials). @@ -1927,6 +1951,7 @@ jobs: issue-4462-gateway-pinned-approval-characterization-e2e, messaging-compatible-endpoint-e2e, channels-add-remove-e2e, + sessions-cli-e2e, channels-stop-start-e2e, brave-search-e2e, kimi-inference-compat-e2e, @@ -2034,6 +2059,7 @@ jobs: issue-4462-gateway-pinned-approval-characterization-e2e, messaging-compatible-endpoint-e2e, channels-add-remove-e2e, + sessions-cli-e2e, channels-stop-start-e2e, brave-search-e2e, kimi-inference-compat-e2e, @@ -2198,6 +2224,7 @@ jobs: issue-4462-gateway-pinned-approval-characterization-e2e, messaging-compatible-endpoint-e2e, channels-add-remove-e2e, + sessions-cli-e2e, channels-stop-start-e2e, brave-search-e2e, kimi-inference-compat-e2e, diff --git a/docs/index.yml b/docs/index.yml index 4925942034..f638cc4fef 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -132,6 +132,9 @@ navigation: - page: "Network Policies" path: reference/network-policies.mdx slug: network-policies + - page: "Session Storage Layout" + path: reference/session-storage.mdx + slug: session-storage - page: "Troubleshooting" path: reference/troubleshooting.mdx slug: troubleshooting diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 0b5472af5c..28ababd269 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -825,6 +825,34 @@ Files with unsafe path characters are rejected to prevent shell injection. If the skill already exists on the sandbox, the command updates it in place and preserves chat history. For new installs, the agent session index is refreshed so the agent discovers the skill on the next session. +### `nemoclaw sessions` + +Inspect and manage OpenClaw conversation sessions stored inside a sandbox. +For the on-disk layout the commands operate on, see [Session Storage Layout](/reference/session-storage). +`` arguments are the canonical keys as stored in `sessions.json` (`agent::`). + +| Subcommand | Purpose | +|---|---| +| `sessions` | List stored sessions for the configured default agent (forwards to `openclaw sessions`). | +| `sessions list` | Same as the root form. Forwards `--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store` to `openclaw sessions list`. | +| `sessions cleanup` | Run OpenClaw's policy-driven store maintenance. Forwards `--dry-run`, `--enforce`, `--agent`, `--all-agents`, `--fix-missing`, `--fix-dm-scope`, `--active-key`, `--json`, `--store`. | +| `sessions rm []` | Wipe a single session or the whole agent's sessions directory in the sandbox. | +| `sessions download [] [--out ]` | Copy session files from the sandbox to the host (defaults to `./sessions-/agent-/`). | + +```console +$ nemoclaw my-assistant sessions list --json +$ nemoclaw my-assistant sessions cleanup --dry-run +$ nemoclaw my-assistant sessions rm main +$ nemoclaw my-assistant sessions rm main agent:main:telegram:thread +$ nemoclaw my-assistant sessions download main --out ./out/ +$ nemoclaw my-assistant sessions download main agent:main:telegram:thread +``` + +`sessions list` and `sessions cleanup` shell out to `openclaw sessions ...` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the output format and exit code. +`sessions rm` and `sessions download` are NemoClaw-side helpers: they read `sessions.json` over `openshell sandbox exec`, resolve the supplied `` to its current `sessionId`, and act on every `.*` file in the agent's `sessions/` directory. +With no ``, `sessions rm` wipes every `*.jsonl`, `*.jsonl.lock`, and reset archive under that agent's directory and resets `sessions.json` to `{}`; `sessions download` copies the whole directory (including `sessions.json` and any orphan files) to the host. +The OpenClaw gateway should be idle for the target agent before running `sessions rm`; live writes against `sessions.json` race the gateway writer. + ### `nemoclaw rebuild` Upgrade a sandbox to the current agent version while preserving workspace state. diff --git a/docs/reference/session-storage.mdx b/docs/reference/session-storage.mdx new file mode 100644 index 0000000000..0415ffe01f --- /dev/null +++ b/docs/reference/session-storage.mdx @@ -0,0 +1,98 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Session Storage Layout" +sidebar-title: "Session Storage" +description: "Where OpenClaw persists conversation sessions, transcripts, and trajectories inside a NemoClaw sandbox." +description-agent: "Documents the on-disk layout of OpenClaw session state inside a sandbox: sessions.json, per-session transcripts, trajectory sidecars, lock files, and reset archives. Use when building audit dashboards, replay tooling, or session-management CLI surfaces." +keywords: ["nemoclaw session storage", "openclaw sessions.json", "agent transcript jsonl", "trajectory jsonl", "audit log paths"] +content: + type: "reference" +--- +OpenClaw persists each agent's conversation state on disk inside the sandbox. +NemoClaw orchestrates the sandbox but does not own the schema; the canonical +layout lives in [OpenClaw's session management reference](https://docs.openclaw.ai/reference/session-management-compaction). +This page surfaces the parts a NemoClaw operator needs when reading audit data, +copying transcripts off a sandbox, or recovering from a stuck session. + +## Where Sessions Live + +State for each agent is rooted at `$OPENCLAW_STATE_DIR/agents//`. +`OPENCLAW_STATE_DIR` defaults to `~/.openclaw`. Inside the sandbox, the agent +process runs with `HOME=/sandbox`, so the resolved root is +`/sandbox/.openclaw/agents//`. The default agent id is `main`. + +```text +/sandbox/.openclaw/agents//sessions/ +├── sessions.json # store: sessionKey -> SessionEntry map +├── .jsonl # per-turn transcript (append-only tree) +├── .trajectory.jsonl # per-token trajectory sidecar +├── .jsonl.lock # write lock for the active transcript +├── -topic-.jsonl # Telegram topic transcripts (optional) +└── .reset..jsonl # archived transcripts from prior resets +``` + +`sessions.json` is the index. Each key is a canonical `sessionKey` (for +example `agent:main:main`, `agent:main:telegram:thread`, +`agent:main:whatsapp:group:`) and each value carries the current +`sessionId`, last activity timestamp, token counters, and other metadata. +Same `sessionKey` in two different agents = two different sessions on disk; +uniqueness is per-agent only. + +## What Each File Carries + +| File | Contents | When to consume | +|---|---|---| +| `sessions.json` | Map of `sessionKey` to `SessionEntry`. Resolves a routing key to the current `sessionId` on disk. | Lookups before reading or wiping a transcript; checking which session is active for a chat or channel binding. | +| `.jsonl` | One JSON record per event. `message` records with `role: "assistant"` carry the `thinking` block, `toolCall` blocks, `toolResult` blocks, and `usage` (tokens + cost). | Audit trails and compliance dashboards. The "what the agent said and did" stream. | +| `.trajectory.jsonl` | Per-token lower-level model trajectory. Tens of megabytes per long session. | Fine-grained replay and visualization. Not needed for audit narratives. | +| `.jsonl.lock` | Empty marker file held by the gateway while writing the transcript. | Lock contention diagnostics; stale locks blocking new turns. | +| `.reset..jsonl` | Snapshot of the transcript at the moment of a reset, kept per `session.maintenance.resetArchiveRetention`. | Forensic recovery of a session whose store entry was reset. | + +`nemoclaw logs` streams `/tmp/gateway.log`, which carries +`[gateway]` and `[sandbox]` lifecycle events only. Agent reasoning, tool +calls, and token usage live in the JSONL files above, not in the gateway log. + +## Pulling Session Data Out of a Sandbox + +Three host commands cover the common needs without manual `kubectl cp`, +`docker cp`, or `cat` over SSH: + +- `nemoclaw sessions list` lists stored sessions for the agent's + configured default agent (forwards to `openclaw sessions list`). +- `nemoclaw sessions download ` copies the whole agent + `sessions/` directory to the host (defaults to + `./sessions-/agent-/`). +- `nemoclaw sessions download ` resolves the + key against `sessions.json` and copies just that session's + `.*` files. + +`` is the canonical key as stored in `sessions.json` +(`agent::`), not a session id or channel-specific shorthand. + +## Recovering From a Stuck Session + +When a session has wedged — for example because a write lock survived a +crash, or a channel bridge handed messages to a session whose transcript +is corrupted — the host commands to know about are: + +- `nemoclaw sessions cleanup --enforce` runs OpenClaw's policy-based + store maintenance (age, count, disk-budget cleanup). Use this for stale + entries and orphan files, not for "wipe this one session right now." +- `nemoclaw sessions rm ` removes a single + session's `.*` files and strips the matching entry from + `sessions.json`. +- `nemoclaw sessions rm ` wipes the whole sessions directory + for an agent and resets `sessions.json` to `{}`. + +The OpenClaw gateway should be idle for the target agent before running +`sessions rm`. Live writes against `sessions.json` race the gateway writer; +stop or restart the agent first when in doubt. Removed sessions do not get +an automatic `.reset..jsonl` archive — that semantic +belongs to OpenClaw's in-gateway reset path, not the on-disk wipe. + +## Related References + +- [OpenClaw — Session management deep dive](https://docs.openclaw.ai/reference/session-management-compaction) +- [Commands Reference: `nemoclaw sessions`](/reference/commands#nemoclaw-name-sessions) +- [Best Practices: workspace and credential storage](/security/best-practices) diff --git a/src/commands/sandbox/sessions.ts b/src/commands/sandbox/sessions.ts new file mode 100644 index 0000000000..c74cd05e87 --- /dev/null +++ b/src/commands/sandbox/sessions.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runSessionsPassthrough } from "../../lib/actions/sandbox/sessions/passthrough"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; + +export default class SandboxSessionsCommand extends NemoClawCommand { + static id = "sandbox:sessions"; + 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, lists stored sessions for the configured default agent. Additional OpenClaw flags are forwarded verbatim after the sandbox name."; + static usage = [" [openclaw-sessions-flags...]"]; + static examples = [ + "<%= config.bin %> sandbox sessions alpha", + "<%= config.bin %> sandbox sessions alpha --all-agents", + "<%= config.bin %> sandbox sessions alpha --json", + ]; + + public async run(): Promise { + this.parsed = true; + const [sandboxName, ...extraArgs] = this.argv; + if (!sandboxName || sandboxName.trim() === "") { + this.failWithLines(["Missing required sandbox name for sessions."], 2); + return; + } + await runSessionsPassthrough(sandboxName, { extraArgs }); + } +} diff --git a/src/commands/sandbox/sessions/cleanup.ts b/src/commands/sandbox/sessions/cleanup.ts new file mode 100644 index 0000000000..1835c47608 --- /dev/null +++ b/src/commands/sandbox/sessions/cleanup.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runSessionsPassthrough } from "../../../lib/actions/sandbox/sessions/passthrough"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +export default class SandboxSessionsCleanupCommand extends NemoClawCommand { + static id = "sandbox:sessions:cleanup"; + static strict = false; + static summary = "Run OpenClaw session-store maintenance in a sandbox"; + static description = + "Pass through to `openclaw sessions cleanup` in the sandbox. Use --dry-run to preview, --enforce to apply. Other OpenClaw flags (--agent, --all-agents, --fix-missing, --fix-dm-scope, --active-key, --json, --store) are forwarded verbatim."; + static usage = [" [openclaw-sessions-cleanup-flags...]"]; + static examples = [ + "<%= config.bin %> sandbox sessions cleanup alpha --dry-run", + "<%= config.bin %> sandbox sessions cleanup alpha --enforce", + "<%= config.bin %> sandbox sessions cleanup alpha --all-agents --dry-run", + ]; + + public async run(): Promise { + this.parsed = true; + const [sandboxName, ...extraArgs] = this.argv; + if (!sandboxName || sandboxName.trim() === "") { + this.failWithLines(["Missing required sandbox name for sessions cleanup."], 2); + return; + } + await runSessionsPassthrough(sandboxName, { verb: "cleanup", extraArgs }); + } +} diff --git a/src/commands/sandbox/sessions/download.ts b/src/commands/sandbox/sessions/download.ts new file mode 100644 index 0000000000..03cc47e2cc --- /dev/null +++ b/src/commands/sandbox/sessions/download.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; + +import { downloadSandboxSessions } from "../../../lib/actions/sandbox/sessions/download"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/snapshot-command-support"; + +export default class SandboxSessionsDownloadCommand extends NemoClawCommand { + static id = "sandbox:sessions:download"; + static strict = true; + static summary = "Download OpenClaw session files from a sandbox to the host"; + static description = [ + "Copy session files out of /sandbox/.openclaw/agents//sessions/ to the host.", + "", + "With an agent only: copies the entire sessions directory (sessions.json + every", + "transcript, trajectory, lock, topic transcript, and reset archive) to .", + "", + "With an agent and a session key: resolves the entry's sessionId, copies all", + ".* files for that session only.", + "", + "Dest is always treated as a directory and created if missing (defaults to", + "./sessions-/agent-/).", + ].join("\n"); + static usage = [" [] [--out ]"]; + static examples = [ + "<%= config.bin %> sandbox sessions download alpha main", + "<%= config.bin %> sandbox sessions download alpha main --out ./out/", + "<%= config.bin %> sandbox sessions download alpha main agent:main:telegram:thread", + ]; + static args = { + sandboxName: sandboxNameArg, + agent: Args.string({ + name: "agent", + description: "Agent id (e.g. main).", + required: true, + }), + session: Args.string({ + name: "session", + description: "Optional canonical session key from sessions.json.", + required: false, + }), + }; + static flags = { + out: Flags.string({ + description: "Host destination directory (created if missing).", + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxSessionsDownloadCommand); + try { + await downloadSandboxSessions(args.sandboxName, { + agent: args.agent, + sessionKey: args.session, + out: flags.out, + }); + } catch (error) { + this.failWithLines([` ${(error as Error).message}`], 1); + } + } +} diff --git a/src/commands/sandbox/sessions/list.ts b/src/commands/sandbox/sessions/list.ts new file mode 100644 index 0000000000..ff3c85844d --- /dev/null +++ b/src/commands/sandbox/sessions/list.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { runSessionsPassthrough } from "../../../lib/actions/sandbox/sessions/passthrough"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; + +export default class SandboxSessionsListCommand extends NemoClawCommand { + static id = "sandbox:sessions:list"; + 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) are forwarded verbatim."; + static usage = [" [openclaw-sessions-list-flags...]"]; + static examples = [ + "<%= config.bin %> sandbox sessions list alpha", + "<%= config.bin %> sandbox sessions list alpha --agent work --json", + ]; + + public async run(): Promise { + this.parsed = true; + const [sandboxName, ...extraArgs] = this.argv; + if (!sandboxName || sandboxName.trim() === "") { + this.failWithLines(["Missing required sandbox name for sessions list."], 2); + return; + } + await runSessionsPassthrough(sandboxName, { verb: "list", extraArgs }); + } +} diff --git a/src/commands/sandbox/sessions/rm.ts b/src/commands/sandbox/sessions/rm.ts new file mode 100644 index 0000000000..1a5a643932 --- /dev/null +++ b/src/commands/sandbox/sessions/rm.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args } from "@oclif/core"; + +import { rmSandboxSessions } from "../../../lib/actions/sandbox/sessions/rm"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/snapshot-command-support"; + +export default class SandboxSessionsRmCommand extends NemoClawCommand { + static id = "sandbox:sessions:rm"; + static strict = true; + static summary = "Remove OpenClaw conversation sessions in a sandbox"; + static description = [ + "Wipe a single session or the whole agent's sessions directory in a running sandbox.", + "", + "With an agent only: removes every *.jsonl, *.jsonl.lock, and reset archive under", + "/sandbox/.openclaw/agents//sessions/, then resets sessions.json to '{}'.", + "", + "With an agent and a session key: looks up the entry's sessionId in sessions.json,", + "removes .* files (transcript, trajectory, lock, topic transcripts, reset", + "archives), and strips the matching entry from sessions.json.", + "", + "The OpenClaw Gateway should be idle for the target agent before running this command.", + "Live writes against sessions.json race the gateway writer; restart or stop the agent first.", + ].join("\n"); + static usage = [" []"]; + static examples = [ + "<%= config.bin %> sandbox sessions rm alpha main", + "<%= config.bin %> sandbox sessions rm alpha main agent:main:telegram:thread", + ]; + static args = { + sandboxName: sandboxNameArg, + agent: Args.string({ + name: "agent", + description: "Agent id (e.g. main).", + required: true, + }), + session: Args.string({ + name: "session", + description: "Optional canonical session key from sessions.json.", + required: false, + }), + }; + + public async run(): Promise { + const { args } = await this.parse(SandboxSessionsRmCommand); + try { + await rmSandboxSessions(args.sandboxName, { + agent: args.agent, + sessionKey: args.session, + }); + } catch (error) { + this.failWithLines([` ${(error as Error).message}`], 1); + } + } +} diff --git a/src/lib/actions/sandbox/sessions/download.ts b/src/lib/actions/sandbox/sessions/download.ts new file mode 100644 index 0000000000..8fa981114a --- /dev/null +++ b/src/lib/actions/sandbox/sessions/download.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { CLI_NAME } from "../../../cli/branding"; +import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; +import { ensureLiveSandboxOrExit } from "../gateway-state"; +import { + agentSessionsDir, + agentSessionsStorePath, + validateAgentId, + validateSessionKey, +} from "./paths"; +import { parseSessionStore, resolveSessionIdForKey } from "./store"; + +export interface SessionsDownloadOptions { + agent: string; + sessionKey?: string; + out?: string; +} + +export interface SessionsDownloadResult { + scope: "agent" | "session"; + out: string; + filesDownloaded: number; +} + +export async function downloadSandboxSessions( + sandboxName: string, + opts: SessionsDownloadOptions, +): Promise { + const agentId = validateAgentId(opts.agent); + const sessionKey = opts.sessionKey ? validateSessionKey(opts.sessionKey) : undefined; + await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + + const sessionsDir = agentSessionsDir(agentId); + const defaultOut = path.resolve( + process.cwd(), + `sessions-${sandboxName}`, + `agent-${agentId}`, + ); + const outDir = opts.out ? path.resolve(opts.out) : defaultOut; + fs.mkdirSync(outDir, { recursive: true }); + + if (!sessionKey) { + return downloadWholeAgent(sandboxName, sessionsDir, outDir, agentId); + } + return downloadSingleSession(sandboxName, sessionsDir, outDir, agentId, sessionKey); +} + +async function downloadWholeAgent( + sandboxName: string, + sessionsDir: string, + outDir: string, + agentId: string, +): Promise { + const probe = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + `if [ -d ${shellQuote(sessionsDir)} ]; then find ${shellQuote(sessionsDir)} -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' '; else echo MISSING; fi`, + ], + { ignoreError: true }, + ); + if (probe.status !== 0 || probe.output === "MISSING") { + console.error(` Sessions directory not found for agent '${agentId}': ${sessionsDir}`); + console.error( + ` Did the agent ever start in this sandbox? Try \`${CLI_NAME} sessions list\`.`, + ); + process.exit(1); + } + + runOpenshell(["sandbox", "download", sandboxName, `${sessionsDir}/`, outDir]); + const filesDownloaded = countLocalFilesShallow(outDir); + console.error( + ` Downloaded agent '${agentId}' sessions to ${outDir} (${filesDownloaded} file${filesDownloaded === 1 ? "" : "s"}).`, + ); + return { scope: "agent", out: outDir, filesDownloaded }; +} + +async function downloadSingleSession( + sandboxName: string, + sessionsDir: string, + outDir: string, + agentId: string, + sessionKey: string, +): Promise { + const storeText = readSessionStoreText(sandboxName, agentId); + const store = parseSessionStore(storeText); + const sessionId = resolveSessionIdForKey(store, sessionKey); + + const listResult = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + `cd ${shellQuote(sessionsDir)} 2>/dev/null && find . -mindepth 1 -maxdepth 1 -type f -name ${shellQuote(`${sessionId}*`)} | sed 's|^\\./||'`, + ], + { ignoreError: true }, + ); + if (listResult.status !== 0) { + console.error( + ` Failed to list session files for '${sessionKey}' (id '${sessionId}'): exit ${listResult.status}`, + ); + process.exit(1); + } + const files = listResult.output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (files.length === 0) { + console.error( + ` No files found on disk for session '${sessionKey}' (id '${sessionId}') under ${sessionsDir}.`, + ); + console.error(" The store entry may be orphaned; try `sessions cleanup --fix-missing`."); + process.exit(1); + } + + for (const fileName of files) { + runOpenshell([ + "sandbox", + "download", + sandboxName, + `${sessionsDir}/${fileName}`, + `${outDir}/`, + ]); + } + console.error( + ` Downloaded session '${sessionKey}' (id '${sessionId}') to ${outDir} (${files.length} file${files.length === 1 ? "" : "s"}).`, + ); + return { scope: "session", out: outDir, filesDownloaded: files.length }; +} + +function readSessionStoreText(sandboxName: string, agentId: string): string { + const storePath = agentSessionsStorePath(agentId); + const result = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + `if [ -s ${shellQuote(storePath)} ]; then cat ${shellQuote(storePath)}; else echo '{}'; fi`, + ], + { ignoreError: true }, + ); + if (result.status !== 0) { + console.error( + ` Failed to read sessions store for agent '${agentId}': ${result.output || `exit ${result.status}`}`, + ); + process.exit(1); + } + return result.output; +} + +function countLocalFilesShallow(dir: string): number { + try { + return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile()).length; + } catch { + return 0; + } +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/lib/actions/sandbox/sessions/passthrough.ts b/src/lib/actions/sandbox/sessions/passthrough.ts new file mode 100644 index 0000000000..0aa30351dc --- /dev/null +++ b/src/lib/actions/sandbox/sessions/passthrough.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSandbox } from "../exec"; +import { ensureLiveSandboxOrExit } from "../gateway-state"; + +export type SessionsPassthroughVerb = "list" | "cleanup" | "export-trajectory"; + +export interface SessionsPassthroughOptions { + verb?: SessionsPassthroughVerb; + extraArgs?: readonly string[]; +} + +export async function runSessionsPassthrough( + sandboxName: string, + { verb, extraArgs = [] }: SessionsPassthroughOptions = {}, +): Promise { + await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + const command = ["openclaw", "sessions"]; + if (verb) command.push(verb); + for (const arg of extraArgs) command.push(arg); + await execSandbox(sandboxName, command); +} diff --git a/src/lib/actions/sandbox/sessions/paths.test.ts b/src/lib/actions/sandbox/sessions/paths.test.ts new file mode 100644 index 0000000000..dcc850f66f --- /dev/null +++ b/src/lib/actions/sandbox/sessions/paths.test.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + SANDBOX_OPENCLAW_STATE_DIR, + agentSessionsDir, + agentSessionsStorePath, + validateAgentId, + validateSessionId, + validateSessionKey, +} from "../../../../../dist/lib/actions/sandbox/sessions/paths"; + +describe("session path helpers", () => { + it("anchors agent sessions under /sandbox/.openclaw", () => { + expect(SANDBOX_OPENCLAW_STATE_DIR).toBe("/sandbox/.openclaw"); + expect(agentSessionsDir("main")).toBe("/sandbox/.openclaw/agents/main/sessions"); + expect(agentSessionsStorePath("main")).toBe( + "/sandbox/.openclaw/agents/main/sessions/sessions.json", + ); + }); + + it("accepts a wide range of legitimate agent ids", () => { + expect(validateAgentId("main")).toBe("main"); + expect(validateAgentId("work_assistant")).toBe("work_assistant"); + expect(validateAgentId("agent-42.beta")).toBe("agent-42.beta"); + }); + + it("rejects agent ids with shell metacharacters or path separators", () => { + expect(() => validateAgentId("main/extra")).toThrow(/Invalid agent id/); + expect(() => validateAgentId("..")).toThrow(/Invalid agent id/); + expect(() => validateAgentId("main; rm -rf /")).toThrow(/Invalid agent id/); + expect(() => validateAgentId("")).toThrow(/Invalid agent id/); + }); + + it("accepts canonical OpenClaw session keys", () => { + expect(validateSessionKey("agent:main:main")).toBe("agent:main:main"); + expect(validateSessionKey("agent:main:telegram:thread")).toBe("agent:main:telegram:thread"); + expect(validateSessionKey("agent:main:whatsapp:group:120363051234567890@g.us")).toBe( + "agent:main:whatsapp:group:120363051234567890@g.us", + ); + }); + + it("rejects session keys with quotes, backticks, $, backslash, or newline", () => { + expect(() => validateSessionKey("agent:main:'evil'")).toThrow(/Invalid session key/); + expect(() => validateSessionKey("agent:main:\"evil\"")).toThrow(/Invalid session key/); + expect(() => validateSessionKey("agent:main:`evil`")).toThrow(/Invalid session key/); + expect(() => validateSessionKey("agent:main:$evil")).toThrow(/Invalid session key/); + expect(() => validateSessionKey("agent:main:evil\\")).toThrow(/Invalid session key/); + expect(() => validateSessionKey("agent:main:\nevil")).toThrow(/Invalid session key/); + expect(() => validateSessionKey("")).toThrow(/Invalid session key/); + }); + + it("validates session ids for shell-safe glob usage", () => { + expect(validateSessionId("session-abc123")).toBe("session-abc123"); + expect(validateSessionId("01HZX7QWERTY")).toBe("01HZX7QWERTY"); + }); + + it("rejects session ids that could escape a shell glob", () => { + expect(() => validateSessionId("../etc/passwd")).toThrow(/Refusing to operate/); + expect(() => validateSessionId("session id with space")).toThrow(/Refusing to operate/); + expect(() => validateSessionId("session*")).toThrow(/Refusing to operate/); + }); +}); diff --git a/src/lib/actions/sandbox/sessions/paths.ts b/src/lib/actions/sandbox/sessions/paths.ts new file mode 100644 index 0000000000..89b962cde4 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/paths.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const SANDBOX_OPENCLAW_STATE_DIR = "/sandbox/.openclaw"; + +export function agentSessionsDir(agentId: string): string { + return `${SANDBOX_OPENCLAW_STATE_DIR}/agents/${agentId}/sessions`; +} + +export function agentSessionsStorePath(agentId: string): string { + return `${agentSessionsDir(agentId)}/sessions.json`; +} + +const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +export function validateAgentId(agentId: string): string { + const trimmed = agentId.trim(); + if (!AGENT_ID_RE.test(trimmed)) { + throw new Error( + `Invalid agent id '${agentId}'. Allowed characters: letters, digits, '.', '_', '-' (max 64).`, + ); + } + return trimmed; +} + +const SESSION_KEY_RE = /^[\x20-\x7E]{1,256}$/; +const SESSION_KEY_REJECT = /["'`$\\\n\r\t]/; + +export function validateSessionKey(sessionKey: string): string { + const trimmed = sessionKey.trim(); + if (!trimmed || !SESSION_KEY_RE.test(trimmed) || SESSION_KEY_REJECT.test(trimmed)) { + throw new Error( + `Invalid session key '${sessionKey}'. Must be a printable ASCII string without quotes, backticks, '$', backslash, or whitespace control characters.`, + ); + } + return trimmed; +} + +const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export function validateSessionId(sessionId: string): string { + if (!SESSION_ID_RE.test(sessionId)) { + throw new Error( + `Refusing to operate on session id '${sessionId}'. Expected an alphanumeric identifier (with '.', '_', '-').`, + ); + } + return sessionId; +} diff --git a/src/lib/actions/sandbox/sessions/rm.ts b/src/lib/actions/sandbox/sessions/rm.ts new file mode 100644 index 0000000000..cbd7af4f93 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/rm.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CLI_NAME } from "../../../cli/branding"; +import { captureOpenshell } from "../../../adapters/openshell/runtime"; +import { ensureLiveSandboxOrExit } from "../gateway-state"; +import { + agentSessionsDir, + agentSessionsStorePath, + validateAgentId, + validateSessionId, + validateSessionKey, +} from "./paths"; +import { + parseSessionStore, + resolveSessionIdForKey, + type SessionStore, +} from "./store"; + +export interface SessionsRmOptions { + agent: string; + sessionKey?: string; +} + +export interface SessionsRmResult { + scope: "agent" | "session"; + removedSessionId?: string; + removedSessionKey?: string; + filesRemoved: number; +} + +const NOT_FOUND_HINT = ` Did the agent ever start in this sandbox? Try \`${CLI_NAME} sessions list\`.`; + +export async function rmSandboxSessions( + sandboxName: string, + opts: SessionsRmOptions, +): Promise { + const agentId = validateAgentId(opts.agent); + const sessionKey = opts.sessionKey ? validateSessionKey(opts.sessionKey) : undefined; + await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + + const sessionsDir = agentSessionsDir(agentId); + const storePath = agentSessionsStorePath(agentId); + + if (!sessionKey) { + return wipeWholeAgent(sandboxName, sessionsDir, storePath, agentId); + } + return removeSingleSession(sandboxName, sessionsDir, storePath, agentId, sessionKey); +} + +async function wipeWholeAgent( + sandboxName: string, + sessionsDir: string, + storePath: string, + agentId: string, +): Promise { + const probe = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + `test -d ${shellQuote(sessionsDir)} && echo PRESENT || echo MISSING`, + ], + { ignoreError: true }, + ); + if (probe.status !== 0 || !probe.output.includes("PRESENT")) { + console.error(` Sessions directory not found for agent '${agentId}': ${sessionsDir}`); + console.error(NOT_FOUND_HINT); + process.exit(1); + } + + const script = [ + `cd ${shellQuote(sessionsDir)} || exit 1`, + "count=$(find . -mindepth 1 -maxdepth 1 \\( -name '*.jsonl' -o -name '*.jsonl.lock' \\) -type f | wc -l | tr -d ' ')", + "find . -mindepth 1 -maxdepth 1 \\( -name '*.jsonl' -o -name '*.jsonl.lock' \\) -type f -delete", + `printf '%s' '{}' > ${shellQuote(storePath)}`, + 'echo "REMOVED=$count"', + ].join("\n"); + + const result = captureOpenshell( + ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], + { ignoreError: true }, + ); + if (result.status !== 0) { + console.error(` Failed to wipe sessions for agent '${agentId}':`); + console.error(` ${result.output}`); + process.exit(1); + } + const filesRemoved = parseRemovedCount(result.output); + console.error( + ` Wiped agent '${agentId}' sessions directory (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json reset).`, + ); + return { scope: "agent", filesRemoved }; +} + +async function removeSingleSession( + sandboxName: string, + sessionsDir: string, + storePath: string, + agentId: string, + sessionKey: string, +): Promise { + const storeText = readSessionStoreText(sandboxName, storePath, agentId); + const store: SessionStore = parseSessionStore(storeText); + const sessionId = resolveSessionIdForKey(store, sessionKey); + const updatedStore = { ...store }; + delete updatedStore[sessionKey]; + const updatedJson = JSON.stringify(updatedStore); + + const safeJson = updatedJson.replace(/'/g, "'\\''"); + const safeSessionId = validateSessionId(sessionId); + const script = [ + `cd ${shellQuote(sessionsDir)} || exit 1`, + `count=$(find . -mindepth 1 -maxdepth 1 -type f -name '${safeSessionId}*' | wc -l | tr -d ' ')`, + `find . -mindepth 1 -maxdepth 1 -type f -name '${safeSessionId}*' -delete`, + `printf '%s' '${safeJson}' > ${shellQuote(storePath)}`, + 'echo "REMOVED=$count"', + ].join("\n"); + + const result = captureOpenshell( + ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], + { ignoreError: true }, + ); + if (result.status !== 0) { + console.error( + ` Failed to remove session '${sessionKey}' (id '${sessionId}') for agent '${agentId}':`, + ); + console.error(` ${result.output}`); + process.exit(1); + } + const filesRemoved = parseRemovedCount(result.output); + console.error( + ` Removed session '${sessionKey}' (id '${sessionId}') from agent '${agentId}' (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json updated).`, + ); + return { + scope: "session", + removedSessionKey: sessionKey, + removedSessionId: sessionId, + filesRemoved, + }; +} + +function readSessionStoreText(sandboxName: string, storePath: string, agentId: string): string { + const result = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + `if [ -s ${shellQuote(storePath)} ]; then cat ${shellQuote(storePath)}; else echo '{}'; fi`, + ], + { ignoreError: true }, + ); + if (result.status !== 0) { + console.error( + ` Failed to read sessions store for agent '${agentId}': ${result.output || `exit ${result.status}`}`, + ); + console.error(NOT_FOUND_HINT); + process.exit(1); + } + return result.output; +} + +function parseRemovedCount(output: string): number { + const match = /REMOVED=(\d+)/.exec(output); + return match ? Number.parseInt(match[1], 10) : 0; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/lib/actions/sandbox/sessions/store.test.ts b/src/lib/actions/sandbox/sessions/store.test.ts new file mode 100644 index 0000000000..fb8960f89e --- /dev/null +++ b/src/lib/actions/sandbox/sessions/store.test.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + parseSessionStore, + resolveSessionIdForKey, +} from "../../../../../dist/lib/actions/sandbox/sessions/store"; + +describe("session store parsing", () => { + it("returns an empty store for empty or whitespace input", () => { + expect(parseSessionStore("")).toEqual({}); + expect(parseSessionStore(" \n ")).toEqual({}); + }); + + it("parses a valid store with multiple entries", () => { + const json = JSON.stringify({ + "agent:main:main": { sessionId: "abc123", updatedAt: 17000000 }, + "agent:main:telegram:thread": { sessionId: "def456", updatedAt: 17000100 }, + }); + const store = parseSessionStore(json); + expect(Object.keys(store)).toEqual(["agent:main:main", "agent:main:telegram:thread"]); + expect(store["agent:main:main"].sessionId).toBe("abc123"); + expect(store["agent:main:telegram:thread"].sessionId).toBe("def456"); + }); + + it("drops entries with missing or non-string sessionId", () => { + const json = JSON.stringify({ + "agent:main:main": { sessionId: "abc123" }, + "agent:main:broken-no-id": {}, + "agent:main:broken-wrong-type": { sessionId: 42 }, + }); + const store = parseSessionStore(json); + expect(Object.keys(store)).toEqual(["agent:main:main"]); + }); + + it("rejects non-object or array roots", () => { + expect(() => parseSessionStore("[]")).toThrow(/object map of sessionKey/); + expect(() => parseSessionStore("null")).toThrow(/object map of sessionKey/); + expect(() => parseSessionStore("\"string\"")).toThrow(/object map of sessionKey/); + }); + + it("reports malformed JSON with a readable error", () => { + expect(() => parseSessionStore("{not json}")).toThrow(/Failed to parse session store JSON/); + }); +}); + +describe("session id resolution", () => { + const store = parseSessionStore( + JSON.stringify({ + "agent:main:main": { sessionId: "abc123" }, + "agent:main:telegram:thread": { sessionId: "def456" }, + }), + ); + + it("returns the sessionId for a known key", () => { + expect(resolveSessionIdForKey(store, "agent:main:main")).toBe("abc123"); + expect(resolveSessionIdForKey(store, "agent:main:telegram:thread")).toBe("def456"); + }); + + it("throws a helpful error listing known keys when missing", () => { + expect(() => resolveSessionIdForKey(store, "agent:main:unknown")).toThrow( + /not found in sessions store/, + ); + expect(() => resolveSessionIdForKey(store, "agent:main:unknown")).toThrow( + /agent:main:main, agent:main:telegram:thread/, + ); + }); + + it("refuses to return an id that fails shell-safe validation", () => { + const compromised = parseSessionStore( + JSON.stringify({ + "agent:main:main": { sessionId: "abc123" }, + }), + ); + compromised["agent:main:main"].sessionId = "abc; rm -rf /"; + expect(() => resolveSessionIdForKey(compromised, "agent:main:main")).toThrow( + /Refusing to operate/, + ); + }); +}); diff --git a/src/lib/actions/sandbox/sessions/store.ts b/src/lib/actions/sandbox/sessions/store.ts new file mode 100644 index 0000000000..f03eb7a936 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/store.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { agentSessionsStorePath, validateSessionId } from "./paths"; + +export interface SessionStoreEntry { + sessionId: string; + [field: string]: unknown; +} + +export type SessionStore = Record; + +const VALID_KEY_RE = /^[\x20-\x7E]+$/; + +export function parseSessionStore(text: string): SessionStore { + const trimmed = text.trim(); + if (!trimmed) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + throw new Error(`Failed to parse session store JSON: ${(err as Error).message}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Session store JSON must be an object map of sessionKey -> entry."); + } + const result: SessionStore = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (!VALID_KEY_RE.test(key)) continue; + if (!value || typeof value !== "object") continue; + const sessionId = (value as { sessionId?: unknown }).sessionId; + if (typeof sessionId !== "string" || sessionId.length === 0) continue; + result[key] = { ...(value as Record), sessionId }; + } + return result; +} + +export function resolveSessionIdForKey(store: SessionStore, sessionKey: string): string { + const entry = store[sessionKey]; + if (!entry) { + const knownKeys = Object.keys(store).slice(0, 10); + const suffix = + knownKeys.length > 0 + ? ` Known keys (first ${knownKeys.length}): ${knownKeys.join(", ")}.` + : ""; + throw new Error(`Session key '${sessionKey}' not found in sessions store.${suffix}`); + } + return validateSessionId(entry.sessionId); +} + +export function storePathForAgent(agentId: string): string { + return agentSessionsStorePath(agentId); +} diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 4c6b548446..155081aea9 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,11 @@ import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 63 commands", () => { + it("should contain exactly 68 commands", () => { // 28 global (22 visible + 6 hidden help/version aliases) - // 35 sandbox (29 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(63); + // 40 sandbox (34 visible + 6 hidden shields/config), now including + // the sandbox:sessions group (root + list + cleanup + rm + download) + expect(COMMANDS).toHaveLength(68); }); it("should have no duplicate usage strings", () => { @@ -52,9 +53,10 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 35 entries", () => { - // 29 visible + 6 hidden (shields×3 + config get/set/rotate-token) - expect(sandboxCommands()).toHaveLength(35); + it("should return exactly 40 entries", () => { + // 34 visible + 6 hidden (shields×3 + config get/set/rotate-token); + // 34 visible includes the sessions group (root + list + cleanup + rm + download). + expect(sandboxCommands()).toHaveLength(40); }); it("every entry has scope sandbox", () => { @@ -65,10 +67,11 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 12 hidden commands (51 visible)", () => { + it("should exclude 12 hidden commands (56 visible)", () => { // 6 hidden global (help, --help, -h, version, --version, -v) + - // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(51); + // 6 hidden sandbox (shields×3, config get/set/rotate-token); + // visible totals now include the sessions group (root + list + cleanup + rm + download). + expect(visibleCommands()).toHaveLength(56); }); it("no visible command has hidden=true", () => { @@ -204,9 +207,9 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 23 unique action tokens including empty string", () => { + it("returns exactly 24 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(23); + expect(tokens).toHaveLength(24); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "connect", @@ -222,6 +225,7 @@ describe("command-registry", () => { "hosts-list", "hosts-remove", "destroy", + "sessions", "skill", "rebuild", "recover", diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index c9ff085db0..ce00717abf 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -362,6 +362,46 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { "hidden": true } ], + "sandbox:sessions": [ + { + "group": "Sandbox Management", + "order": 17, + "flags": "[openclaw-sessions-flags...]", + "description": "List OpenClaw conversation sessions in the sandbox" + } + ], + "sandbox:sessions:cleanup": [ + { + "group": "Sandbox Management", + "order": 20, + "flags": "[openclaw-sessions-cleanup-flags...]", + "description": "Run OpenClaw session-store maintenance" + } + ], + "sandbox:sessions:download": [ + { + "group": "Sandbox Management", + "order": 22, + "flags": " [] [--out ]", + "description": "Copy OpenClaw session files to the host" + } + ], + "sandbox:sessions:list": [ + { + "group": "Sandbox Management", + "order": 18, + "flags": "[openclaw-sessions-list-flags...]", + "description": "List OpenClaw conversation sessions" + } + ], + "sandbox:sessions:rm": [ + { + "group": "Sandbox Management", + "order": 21, + "flags": " []", + "description": "Remove OpenClaw conversation sessions" + } + ], "sandbox:skill:install": [ { "group": "Skills", diff --git a/test/cli.test.ts b/test/cli.test.ts index 5f7ecff839..91624f2aad 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -563,6 +563,165 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Did you mean: nemoclaw alpha connect?"); }); + it("sandbox sessions list forwards to openclaw sessions list via openshell sandbox exec", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-list-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' *"sandbox exec --name alpha -- openclaw sessions list --json"*) echo "[]"; exit 0 ;;', + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("alpha sessions list --json", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("[]"); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch(/sandbox exec --name alpha -- openclaw sessions list --json/); + }); + + it("sandbox sessions rm wipes the agent sessions dir via openshell sandbox exec", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-agent-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"test -d '${sessionsDir}'"*) echo "PRESENT"; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"-name '*.jsonl'"*) echo "REMOVED=3"; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("sandbox sessions rm alpha main 2>&1", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Wiped agent 'main' sessions directory (3 files removed"); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch( + /sandbox exec --name alpha -- sh -c .*test -d '\/sandbox\/\.openclaw\/agents\/main\/sessions'/, + ); + expect(calls).toMatch(/-name '\*\.jsonl'/); + }); + + it("sandbox sessions rm resolves the sessionId and removes matching files", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-key-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; + const storePath = `${sessionsDir}/sessions.json`; + const sessionsJson = JSON.stringify({ + "agent:main:main": { sessionId: "abc123", updatedAt: 1 }, + "agent:main:telegram:thread": { sessionId: "def456", updatedAt: 2 }, + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"-name 'def456*'"*) echo "REMOVED=2"; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "sandbox sessions rm alpha main agent:main:telegram:thread 2>&1", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(0); + expect(r.out).toContain( + "Removed session 'agent:main:telegram:thread' (id 'def456') from agent 'main'", + ); + expect(r.out).toContain("2 files removed"); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch(/cat '\/sandbox\/\.openclaw\/agents\/main\/sessions\/sessions\.json'/); + expect(calls).toMatch(/-name 'def456\*'/); + }); + + it("sandbox sessions rm reports a helpful error when the session key is unknown", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-missing-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const sessionsJson = JSON.stringify({ + "agent:main:main": { sessionId: "abc123" }, + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"cat '/sandbox/.openclaw/agents/main/sessions/sessions.json'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("sandbox sessions rm alpha main agent:main:missing", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }); + + expect(r.code).toBe(1); + expect(r.out).toContain("Session key 'agent:main:missing' not found in sessions store"); + expect(r.out).toContain("agent:main:main"); + }); + it("list exits 0", () => { const r = run("list"); expect(r.code).toBe(0); diff --git a/test/e2e/test-sessions-cli.sh b/test/e2e/test-sessions-cli.sh new file mode 100755 index 0000000000..89c997ef71 --- /dev/null +++ b/test/e2e/test-sessions-cli.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# ============================================================================= +# test-sessions-cli.sh +# NemoClaw `sessions` Subcommand E2E Tests +# +# Covers (issues #834, #3978, #3979): +# TC-SESS-01: `nemoclaw sessions list --json` returns valid JSON +# from the in-sandbox OpenClaw CLI (pass-through wiring). +# TC-SESS-02: `nemoclaw sessions cleanup --dry-run` runs without +# mutating state (pass-through wiring). +# TC-SESS-03: `nemoclaw sessions download ` copies the +# agent's sessions directory to the host with sessions.json +# present (issue #3979). +# TC-SESS-04: `nemoclaw sessions rm ` wipes the agent's +# sessions directory and resets sessions.json to '{}' +# (issue #834). +# TC-SESS-05: After TC-SESS-04, `nemoclaw sessions list --json` +# returns an empty array (final-state assertion). +# +# Prerequisites: +# - Docker running +# - NVIDIA_API_KEY set (real key or fake OpenAI endpoint) +# - NEMOCLAW_NON_INTERACTIVE=1, NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ +# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-sessions-cli.sh +# ============================================================================= + +set -uo pipefail + +export NEMOCLAW_E2E_DEFAULT_TIMEOUT="${NEMOCLAW_E2E_DEFAULT_TIMEOUT:-2400}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +# shellcheck source=test/e2e/e2e-timeout.sh +. "${SCRIPT_DIR}/e2e-timeout.sh" + +# ── Tally + reporting ──────────────────────────────────────────────────────── +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 +pass() { + PASS=$((PASS + 1)) + TOTAL=$((TOTAL + 1)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + FAIL=$((FAIL + 1)) + TOTAL=$((TOTAL + 1)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + SKIP=$((SKIP + 1)) + TOTAL=$((TOTAL + 1)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } +print_summary() { + section "Summary" + echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" + if [ "$FAIL" -gt 0 ]; then + echo "" + echo "FAILED" + exit 1 + fi + echo "" + if [ "$SKIP" -gt 0 ]; then + echo "PASSED (with $SKIP skipped)" + else + echo "ALL PASSED" + fi +} + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-sessions-cli}" +DOWNLOAD_DIR="$(mktemp -d -t nemoclaw-sessions-dl-XXXXXX)" +trap 'rm -rf "$DOWNLOAD_DIR"' EXIT + +# shellcheck source=test/e2e/lib/sandbox-teardown.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh" +register_sandbox_for_teardown "$SANDBOX_NAME" + +# ── Preflight ──────────────────────────────────────────────────────────────── +preflight() { + section "Preflight" + if ! docker info >/dev/null 2>&1; then + fail "preflight: Docker not running" + print_summary + exit 1 + fi + if [ -z "${NVIDIA_API_KEY:-}" ]; then + skip "preflight: NVIDIA_API_KEY not set; sessions E2E requires a working onboard credential" + print_summary + exit 0 + fi + pass "preflight: docker + NVIDIA_API_KEY available" +} + +# ── Onboard a fresh sandbox ────────────────────────────────────────────────── +onboard_sandbox() { + section "Onboard sandbox '${SANDBOX_NAME}'" + rm -f "$HOME/.nemoclaw/onboard.lock" 2>/dev/null || true + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_POLICY_TIER="open" \ + nemoclaw onboard --non-interactive --yes-i-accept-third-party-software 2>&1 || { + fail "onboard: onboard command failed for '${SANDBOX_NAME}'" + print_summary + exit 1 + } + pass "onboard: sandbox '${SANDBOX_NAME}' is up" +} + +# ── Send one prompt so the agent creates a session ─────────────────────────── +seed_session() { + section "Seed session by sending one prompt" + if ! nemoclaw "$SANDBOX_NAME" exec -- openclaw agent --agent main -m "ping" 2>&1; then + fail "seed: agent invocation failed; sessions store may not be populated" + return 1 + fi + pass "seed: sent one prompt to agent 'main'" +} + +# ── TC-SESS-01: sessions list --json ───────────────────────────────────────── +test_sessions_list_json() { + section "TC-SESS-01: sessions list --json returns JSON" + local out + out="$(nemoclaw "$SANDBOX_NAME" sessions list --json 2>&1)" || { + fail "TC-SESS-01: sessions list --json exited non-zero" + info "$out" + return 1 + } + if ! printf '%s' "$out" | python3 -c "import json,sys; json.loads(sys.stdin.read())" 2>/dev/null; then + fail "TC-SESS-01: sessions list --json did not return parseable JSON" + info "$out" + return 1 + fi + pass "TC-SESS-01: sessions list --json returned valid JSON" +} + +# ── TC-SESS-02: sessions cleanup --dry-run ─────────────────────────────────── +test_sessions_cleanup_dry_run() { + section "TC-SESS-02: sessions cleanup --dry-run" + if ! nemoclaw "$SANDBOX_NAME" sessions cleanup --dry-run 2>&1; then + fail "TC-SESS-02: sessions cleanup --dry-run exited non-zero" + return 1 + fi + pass "TC-SESS-02: sessions cleanup --dry-run exited zero" +} + +# ── TC-SESS-03: sessions download ──────────────────────────────────── +test_sessions_download() { + section "TC-SESS-03: sessions download main" + local dest="${DOWNLOAD_DIR}/agent-main" + if ! nemoclaw "$SANDBOX_NAME" sessions download main --out "$dest" 2>&1; then + fail "TC-SESS-03: sessions download main exited non-zero" + return 1 + fi + if [ ! -f "${dest}/sessions.json" ]; then + fail "TC-SESS-03: expected ${dest}/sessions.json on host" + return 1 + fi + pass "TC-SESS-03: sessions download produced sessions.json on host" +} + +# ── TC-SESS-04: sessions rm wipes whole agent ──────────────────────── +test_sessions_rm_agent() { + section "TC-SESS-04: sessions rm main" + if ! nemoclaw "$SANDBOX_NAME" sessions rm main 2>&1; then + fail "TC-SESS-04: sessions rm main exited non-zero" + return 1 + fi + pass "TC-SESS-04: sessions rm main exited zero" +} + +# ── TC-SESS-05: list after rm shows empty store ────────────────────────────── +test_sessions_list_empty_after_rm() { + section "TC-SESS-05: sessions list --json is empty after rm" + local out + out="$(nemoclaw "$SANDBOX_NAME" sessions list --json 2>&1)" || { + fail "TC-SESS-05: sessions list --json exited non-zero after rm" + info "$out" + return 1 + } + local count + count="$(printf '%s' "$out" | python3 -c "import json,sys; v=json.loads(sys.stdin.read()); print(len(v) if isinstance(v, list) else len(v.get('sessions', [])))" 2>/dev/null || echo "")" + if [ -z "$count" ]; then + fail "TC-SESS-05: could not parse session count from list output" + info "$out" + return 1 + fi + if [ "$count" != "0" ]; then + fail "TC-SESS-05: expected 0 sessions after rm, got $count" + return 1 + fi + pass "TC-SESS-05: sessions list reports 0 sessions after rm" +} + +# ── Main ───────────────────────────────────────────────────────────────────── +preflight +onboard_sandbox +seed_session || skip "seed: skipping post-seed checks because agent never produced a session" +test_sessions_list_json +test_sessions_cleanup_dry_run +test_sessions_download +test_sessions_rm_agent +test_sessions_list_empty_after_rm +print_summary From a793e718c774509c68e22fecd567cc6310e4ea63 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 30 May 2026 20:11:31 +0000 Subject: [PATCH 2/7] fix(cli): tighten sessions subcommands per review (#4570) Signed-off-by: Tinson Lai --- .github/workflows/nightly-e2e.yaml | 2 +- docs/reference/commands.mdx | 45 ++++++++++++++++++- src/commands/sandbox/sessions.ts | 14 ++++-- src/commands/sandbox/sessions/cleanup.ts | 14 ++++-- src/commands/sandbox/sessions/list.ts | 14 ++++-- src/lib/actions/sandbox/sessions/download.ts | 4 +- .../actions/sandbox/sessions/passthrough.ts | 24 +++++++++- .../actions/sandbox/sessions/paths.test.ts | 11 +++++ src/lib/actions/sandbox/sessions/paths.ts | 5 +++ src/lib/actions/sandbox/sessions/rm.ts | 8 ++-- src/lib/actions/sandbox/sessions/store.ts | 6 +-- test/cli.test.ts | 4 +- test/e2e/test-sessions-cli.sh | 19 +++++--- 13 files changed, 139 insertions(+), 31 deletions(-) diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index b0e0a79467..29f7656a44 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -522,7 +522,7 @@ jobs: /tmp/nemoclaw-e2e-install.log env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_SANDBOX_NAME":"e2e-sessions-cli"}' nvidia_api_key: true - github_token: true + github_token: false secrets: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 28ababd269..73df2ecc46 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -849,10 +849,51 @@ $ nemoclaw my-assistant sessions download main agent:main:telegram:thread ``` `sessions list` and `sessions cleanup` shell out to `openclaw sessions ...` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the output format and exit code. -`sessions rm` and `sessions download` are NemoClaw-side helpers: they read `sessions.json` over `openshell sandbox exec`, resolve the supplied `` to its current `sessionId`, and act on every `.*` file in the agent's `sessions/` directory. -With no ``, `sessions rm` wipes every `*.jsonl`, `*.jsonl.lock`, and reset archive under that agent's directory and resets `sessions.json` to `{}`; `sessions download` copies the whole directory (including `sessions.json` and any orphan files) to the host. +`sessions rm` and `sessions download` are NemoClaw-side helpers: they read `sessions.json` over `openshell sandbox exec`, resolve the supplied `` to its current `sessionId`, and act on the owned filename shapes for that session in the agent's `sessions/` directory. +The owned shapes are `.*` (transcript, trajectory, lock, reset archives) and `-topic-*` (topic transcripts); files whose names only share a string prefix with the resolved `sessionId` are not touched. +With no ``, `sessions rm` wipes every `*.jsonl` and `*.jsonl.lock` under that agent's directory and resets `sessions.json` to `{}`; `sessions download` copies the whole directory (including `sessions.json` and any orphan files) to the host. The OpenClaw gateway should be idle for the target agent before running `sessions rm`; live writes against `sessions.json` race the gateway writer. +### `nemoclaw sessions list` + +List OpenClaw conversation sessions in a sandbox. +Pass-through to `openclaw sessions list` inside the sandbox via `openshell sandbox exec`; all flags accepted by the in-sandbox CLI (e.g. `--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`) are forwarded verbatim. +OpenClaw owns the output format and exit code. + +### `nemoclaw sessions cleanup` + +Run OpenClaw session-store maintenance in a sandbox. +Pass-through to `openclaw sessions cleanup` inside the sandbox via `openshell sandbox exec`; all flags accepted by the in-sandbox CLI (e.g. `--dry-run`, `--enforce`, `--agent`, `--all-agents`, `--fix-missing`, `--fix-dm-scope`, `--active-key`, `--json`, `--store`) are forwarded verbatim. +OpenClaw owns the policy and reporting; NemoClaw only routes the call. + +### `nemoclaw sessions rm` + +Remove OpenClaw conversation sessions in a sandbox. +NemoClaw-side helper: reads `sessions.json` over `openshell sandbox exec`, resolves the optional `` to its current `sessionId`, and removes the owned filename shapes (`.*` and `-topic-*`) for that session. + +```console +$ nemoclaw sessions rm [] +``` + +With no ``, every `*.jsonl` and `*.jsonl.lock` under that agent's `sessions/` directory is removed and `sessions.json` is reset to `{}`. +The OpenClaw gateway should be idle for the target agent before running this command; live writes against `sessions.json` race the gateway writer. + +### `nemoclaw sessions download` + +Download OpenClaw session files from a sandbox to the host. +Uses `openshell sandbox download` to copy from `/sandbox/.openclaw/agents//sessions/` onto the host. + +```console +$ nemoclaw sessions download [] [--out ] +``` + +| Flag | Description | +|------|-------------| +| `--out ` | Host destination directory (created if missing). Defaults to `./sessions-/agent-/`. | + +With no ``, the entire `sessions/` directory (including `sessions.json` and every transcript, trajectory, lock, topic transcript, and reset archive) is copied. +With a ``, only the owned filename shapes for the resolved `sessionId` are copied. + ### `nemoclaw rebuild` Upgrade a sandbox to the current agent version while preserving workspace state. diff --git a/src/commands/sandbox/sessions.ts b/src/commands/sandbox/sessions.ts index c74cd05e87..101c7e8be1 100644 --- a/src/commands/sandbox/sessions.ts +++ b/src/commands/sandbox/sessions.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runSessionsPassthrough } from "../../lib/actions/sandbox/sessions/passthrough"; +import { + hasSessionsPassthroughHelpToken, + printSessionsPassthroughHelp, + runSessionsPassthrough, +} from "../../lib/actions/sandbox/sessions/passthrough"; import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; export default class SandboxSessionsCommand extends NemoClawCommand { @@ -20,8 +24,12 @@ export default class SandboxSessionsCommand extends NemoClawCommand { public async run(): Promise { this.parsed = true; const [sandboxName, ...extraArgs] = this.argv; - if (!sandboxName || sandboxName.trim() === "") { - this.failWithLines(["Missing required sandbox name for sessions."], 2); + if (!sandboxName || sandboxName.trim() === "" || sandboxName === "--help" || sandboxName === "-h") { + printSessionsPassthroughHelp(); + return; + } + if (hasSessionsPassthroughHelpToken(extraArgs)) { + printSessionsPassthroughHelp(); return; } await runSessionsPassthrough(sandboxName, { extraArgs }); diff --git a/src/commands/sandbox/sessions/cleanup.ts b/src/commands/sandbox/sessions/cleanup.ts index 1835c47608..1f4003a2c7 100644 --- a/src/commands/sandbox/sessions/cleanup.ts +++ b/src/commands/sandbox/sessions/cleanup.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runSessionsPassthrough } from "../../../lib/actions/sandbox/sessions/passthrough"; +import { + hasSessionsPassthroughHelpToken, + printSessionsPassthroughHelp, + runSessionsPassthrough, +} from "../../../lib/actions/sandbox/sessions/passthrough"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; export default class SandboxSessionsCleanupCommand extends NemoClawCommand { @@ -20,8 +24,12 @@ export default class SandboxSessionsCleanupCommand extends NemoClawCommand { public async run(): Promise { this.parsed = true; const [sandboxName, ...extraArgs] = this.argv; - if (!sandboxName || sandboxName.trim() === "") { - this.failWithLines(["Missing required sandbox name for sessions cleanup."], 2); + if (!sandboxName || sandboxName.trim() === "" || sandboxName === "--help" || sandboxName === "-h") { + printSessionsPassthroughHelp("cleanup"); + return; + } + if (hasSessionsPassthroughHelpToken(extraArgs)) { + printSessionsPassthroughHelp("cleanup"); return; } await runSessionsPassthrough(sandboxName, { verb: "cleanup", extraArgs }); diff --git a/src/commands/sandbox/sessions/list.ts b/src/commands/sandbox/sessions/list.ts index ff3c85844d..9fcb5a1669 100644 --- a/src/commands/sandbox/sessions/list.ts +++ b/src/commands/sandbox/sessions/list.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { runSessionsPassthrough } from "../../../lib/actions/sandbox/sessions/passthrough"; +import { + hasSessionsPassthroughHelpToken, + printSessionsPassthroughHelp, + runSessionsPassthrough, +} from "../../../lib/actions/sandbox/sessions/passthrough"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; export default class SandboxSessionsListCommand extends NemoClawCommand { @@ -19,8 +23,12 @@ export default class SandboxSessionsListCommand extends NemoClawCommand { public async run(): Promise { this.parsed = true; const [sandboxName, ...extraArgs] = this.argv; - if (!sandboxName || sandboxName.trim() === "") { - this.failWithLines(["Missing required sandbox name for sessions list."], 2); + if (!sandboxName || sandboxName.trim() === "" || sandboxName === "--help" || sandboxName === "-h") { + printSessionsPassthroughHelp("list"); + return; + } + if (hasSessionsPassthroughHelpToken(extraArgs)) { + printSessionsPassthroughHelp("list"); return; } await runSessionsPassthrough(sandboxName, { verb: "list", extraArgs }); diff --git a/src/lib/actions/sandbox/sessions/download.ts b/src/lib/actions/sandbox/sessions/download.ts index 8fa981114a..11bb3a09d0 100644 --- a/src/lib/actions/sandbox/sessions/download.ts +++ b/src/lib/actions/sandbox/sessions/download.ts @@ -10,6 +10,7 @@ import { ensureLiveSandboxOrExit } from "../gateway-state"; import { agentSessionsDir, agentSessionsStorePath, + sessionOwnedFilenameFindClause, validateAgentId, validateSessionKey, } from "./paths"; @@ -96,6 +97,7 @@ async function downloadSingleSession( const store = parseSessionStore(storeText); const sessionId = resolveSessionIdForKey(store, sessionKey); + const ownedClause = sessionOwnedFilenameFindClause(sessionId); const listResult = captureOpenshell( [ "sandbox", @@ -105,7 +107,7 @@ async function downloadSingleSession( "--", "sh", "-c", - `cd ${shellQuote(sessionsDir)} 2>/dev/null && find . -mindepth 1 -maxdepth 1 -type f -name ${shellQuote(`${sessionId}*`)} | sed 's|^\\./||'`, + `cd ${shellQuote(sessionsDir)} 2>/dev/null && find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} | sed 's|^\\./||'`, ], { ignoreError: true }, ); diff --git a/src/lib/actions/sandbox/sessions/passthrough.ts b/src/lib/actions/sandbox/sessions/passthrough.ts index 0aa30351dc..24bb9d4454 100644 --- a/src/lib/actions/sandbox/sessions/passthrough.ts +++ b/src/lib/actions/sandbox/sessions/passthrough.ts @@ -1,16 +1,38 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { CLI_NAME } from "../../../cli/branding"; import { execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; -export type SessionsPassthroughVerb = "list" | "cleanup" | "export-trajectory"; +export type SessionsPassthroughVerb = "list" | "cleanup"; export interface SessionsPassthroughOptions { verb?: SessionsPassthroughVerb; extraArgs?: readonly string[]; } +export function hasSessionsPassthroughHelpToken(args: readonly string[]): boolean { + for (const arg of args) { + if (arg === "--") break; + if (arg === "--help" || arg === "-h") return true; + } + return false; +} + +export function printSessionsPassthroughHelp(verb?: SessionsPassthroughVerb): void { + const usageSuffix = verb ? ` ${verb}` : ""; + const flagsToken = verb ? `openclaw-sessions-${verb}-flags` : "openclaw-sessions-flags"; + console.log(""); + console.log(` Usage: ${CLI_NAME} sessions${usageSuffix} [${flagsToken}...]`); + console.log(""); + console.log( + ` Pass-through to \`openclaw sessions${usageSuffix} ...\` inside the sandbox via \`openshell sandbox exec\`.`, + ); + console.log(" All flags accepted by the in-sandbox OpenClaw CLI are forwarded verbatim."); + console.log(""); +} + export async function runSessionsPassthrough( sandboxName: string, { verb, extraArgs = [] }: SessionsPassthroughOptions = {}, diff --git a/src/lib/actions/sandbox/sessions/paths.test.ts b/src/lib/actions/sandbox/sessions/paths.test.ts index dcc850f66f..76066ad938 100644 --- a/src/lib/actions/sandbox/sessions/paths.test.ts +++ b/src/lib/actions/sandbox/sessions/paths.test.ts @@ -7,6 +7,7 @@ import { SANDBOX_OPENCLAW_STATE_DIR, agentSessionsDir, agentSessionsStorePath, + sessionOwnedFilenameFindClause, validateAgentId, validateSessionId, validateSessionKey, @@ -62,4 +63,14 @@ describe("session path helpers", () => { expect(() => validateSessionId("session id with space")).toThrow(/Refusing to operate/); expect(() => validateSessionId("session*")).toThrow(/Refusing to operate/); }); + + it("builds a find clause that matches owned shapes only", () => { + const clause = sessionOwnedFilenameFindClause("abc"); + expect(clause).toBe("\\( -name 'abc.*' -o -name 'abc-topic-*' \\)"); + }); + + it("validates session id when building the find clause", () => { + expect(() => sessionOwnedFilenameFindClause("abc*")).toThrow(/Refusing to operate/); + expect(() => sessionOwnedFilenameFindClause("abc def")).toThrow(/Refusing to operate/); + }); }); diff --git a/src/lib/actions/sandbox/sessions/paths.ts b/src/lib/actions/sandbox/sessions/paths.ts index 89b962cde4..901ad8ff99 100644 --- a/src/lib/actions/sandbox/sessions/paths.ts +++ b/src/lib/actions/sandbox/sessions/paths.ts @@ -46,3 +46,8 @@ export function validateSessionId(sessionId: string): string { } return sessionId; } + +export function sessionOwnedFilenameFindClause(sessionId: string): string { + const id = validateSessionId(sessionId); + return `\\( -name '${id}.*' -o -name '${id}-topic-*' \\)`; +} diff --git a/src/lib/actions/sandbox/sessions/rm.ts b/src/lib/actions/sandbox/sessions/rm.ts index cbd7af4f93..c792965181 100644 --- a/src/lib/actions/sandbox/sessions/rm.ts +++ b/src/lib/actions/sandbox/sessions/rm.ts @@ -7,8 +7,8 @@ import { ensureLiveSandboxOrExit } from "../gateway-state"; import { agentSessionsDir, agentSessionsStorePath, + sessionOwnedFilenameFindClause, validateAgentId, - validateSessionId, validateSessionKey, } from "./paths"; import { @@ -112,11 +112,11 @@ async function removeSingleSession( const updatedJson = JSON.stringify(updatedStore); const safeJson = updatedJson.replace(/'/g, "'\\''"); - const safeSessionId = validateSessionId(sessionId); + const ownedClause = sessionOwnedFilenameFindClause(sessionId); const script = [ `cd ${shellQuote(sessionsDir)} || exit 1`, - `count=$(find . -mindepth 1 -maxdepth 1 -type f -name '${safeSessionId}*' | wc -l | tr -d ' ')`, - `find . -mindepth 1 -maxdepth 1 -type f -name '${safeSessionId}*' -delete`, + `count=$(find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} | wc -l | tr -d ' ')`, + `find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} -delete`, `printf '%s' '${safeJson}' > ${shellQuote(storePath)}`, 'echo "REMOVED=$count"', ].join("\n"); diff --git a/src/lib/actions/sandbox/sessions/store.ts b/src/lib/actions/sandbox/sessions/store.ts index f03eb7a936..8c5e363e98 100644 --- a/src/lib/actions/sandbox/sessions/store.ts +++ b/src/lib/actions/sandbox/sessions/store.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { agentSessionsStorePath, validateSessionId } from "./paths"; +import { validateSessionId } from "./paths"; export interface SessionStoreEntry { sessionId: string; @@ -47,7 +47,3 @@ export function resolveSessionIdForKey(store: SessionStore, sessionKey: string): } return validateSessionId(entry.sessionId); } - -export function storePathForAgent(agentId: string): string { - return agentSessionsStorePath(agentId); -} diff --git a/test/cli.test.ts b/test/cli.test.ts index 91624f2aad..9c751e76b4 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -660,7 +660,7 @@ describe("CLI dispatch", () => { ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"-name 'def456*'"*) echo "REMOVED=2"; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"-name 'def456.*'"*) echo "REMOVED=2"; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -684,7 +684,7 @@ describe("CLI dispatch", () => { expect(r.out).toContain("2 files removed"); const calls = fs.readFileSync(openshellLog, "utf8"); expect(calls).toMatch(/cat '\/sandbox\/\.openclaw\/agents\/main\/sessions\/sessions\.json'/); - expect(calls).toMatch(/-name 'def456\*'/); + expect(calls).toMatch(/-name 'def456\.\*' -o -name 'def456-topic-\*'/); }); it("sandbox sessions rm reports a helpful error when the session key is unknown", () => { diff --git a/test/e2e/test-sessions-cli.sh b/test/e2e/test-sessions-cli.sh index 89c997ef71..d49c0abe7e 100755 --- a/test/e2e/test-sessions-cli.sh +++ b/test/e2e/test-sessions-cli.sh @@ -206,10 +206,17 @@ test_sessions_list_empty_after_rm() { # ── Main ───────────────────────────────────────────────────────────────────── preflight onboard_sandbox -seed_session || skip "seed: skipping post-seed checks because agent never produced a session" -test_sessions_list_json -test_sessions_cleanup_dry_run -test_sessions_download -test_sessions_rm_agent -test_sessions_list_empty_after_rm +if seed_session; then + test_sessions_list_json + test_sessions_cleanup_dry_run + test_sessions_download + test_sessions_rm_agent + test_sessions_list_empty_after_rm +else + skip "TC-SESS-01: skipped (seed_session failed; agent never produced a session)" + skip "TC-SESS-02: skipped (seed_session failed; agent never produced a session)" + skip "TC-SESS-03: skipped (seed_session failed; agent never produced a session)" + skip "TC-SESS-04: skipped (seed_session failed; agent never produced a session)" + skip "TC-SESS-05: skipped (seed_session failed; agent never produced a session)" +fi print_summary From 30b60a70377ab8e6928d14312a8a38cf375fb893 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 30 May 2026 20:35:44 +0000 Subject: [PATCH 3/7] fix(cli): refuse sessions rm on active write lock + path nits Signed-off-by: Tinson Lai --- .coderabbit.yaml | 27 +++++++++ .github/workflows/nightly-e2e.yaml | 3 +- docs/reference/commands.mdx | 32 ++++++----- docs/reference/session-storage.mdx | 12 ++-- src/commands/sandbox/sessions/rm.ts | 15 ++++- src/lib/actions/sandbox/sessions/rm.ts | 73 ++++++++++++++++++++++-- src/lib/cli/public-display-defaults.ts | 2 +- test/cli.test.ts | 79 +++++++++++++++++++++++++- test/e2e/test-sessions-cli.sh | 7 +-- 9 files changed, 216 insertions(+), 34 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3fed58b128..380d11b8b3 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -257,6 +257,33 @@ reviews: - path: "test/e2e/test-channels-stop-start.sh" instructions: *e2e-channel-stop-start + - path: "src/lib/actions/sandbox/sessions/**" + instructions: &e2e-sessions-cli | + This file is part of the host-side OpenClaw sessions management + surface (`nemoclaw sessions list|cleanup|rm|download`). + Changes affect session store reads, owned-shape file matching, + write-lock detection, and `sessions.json` rewrites inside a running + sandbox. + + **E2E test recommendation:** + - `sessions-cli-e2e` — onboard, seed a session, then exercise + `sessions list`, `sessions cleanup --dry-run`, `sessions download`, + `sessions rm`, and re-assert the empty list + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=sessions-cli-e2e + ``` + + - path: "src/commands/sandbox/sessions.ts" + instructions: *e2e-sessions-cli + + - path: "src/commands/sandbox/sessions/**" + instructions: *e2e-sessions-cli + + - path: "test/e2e/test-sessions-cli.sh" + instructions: *e2e-sessions-cli + - path: "src/lib/actions/inference-set.ts" instructions: | This file switches the OpenShell inference route and patches the diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 29f7656a44..0eba84eb49 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -122,7 +122,8 @@ on: credential-sanitization-e2e, telegram-injection-e2e, overlayfs-autofix-e2e, device-auth-health-e2e, launchable-smoke-e2e, gpu-e2e, gpu-double-onboard-e2e, - channels-add-remove-e2e, channels-stop-start-e2e, brave-search-e2e + channels-add-remove-e2e, channels-stop-start-e2e, brave-search-e2e, + sessions-cli-e2e required: false type: string default: "" diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 73df2ecc46..0a342c641c 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -836,23 +836,23 @@ For the on-disk layout the commands operate on, see [Session Storage Layout](/re | `sessions` | List stored sessions for the configured default agent (forwards to `openclaw sessions`). | | `sessions list` | Same as the root form. Forwards `--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store` to `openclaw sessions list`. | | `sessions cleanup` | Run OpenClaw's policy-driven store maintenance. Forwards `--dry-run`, `--enforce`, `--agent`, `--all-agents`, `--fix-missing`, `--fix-dm-scope`, `--active-key`, `--json`, `--store`. | -| `sessions rm []` | Wipe a single session or the whole agent's sessions directory in the sandbox. | +| `sessions rm [] [--force]` | Wipe a single session or the whole agent's sessions directory in the sandbox. | | `sessions download [] [--out ]` | Copy session files from the sandbox to the host (defaults to `./sessions-/agent-/`). | -```console -$ nemoclaw my-assistant sessions list --json -$ nemoclaw my-assistant sessions cleanup --dry-run -$ nemoclaw my-assistant sessions rm main -$ nemoclaw my-assistant sessions rm main agent:main:telegram:thread -$ nemoclaw my-assistant sessions download main --out ./out/ -$ nemoclaw my-assistant sessions download main agent:main:telegram:thread +```bash +nemoclaw my-assistant sessions list --json +nemoclaw my-assistant sessions cleanup --dry-run +nemoclaw my-assistant sessions rm main +nemoclaw my-assistant sessions rm main agent:main:telegram:thread +nemoclaw my-assistant sessions download main --out ./out/ +nemoclaw my-assistant sessions download main agent:main:telegram:thread ``` `sessions list` and `sessions cleanup` shell out to `openclaw sessions ...` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the output format and exit code. `sessions rm` and `sessions download` are NemoClaw-side helpers: they read `sessions.json` over `openshell sandbox exec`, resolve the supplied `` to its current `sessionId`, and act on the owned filename shapes for that session in the agent's `sessions/` directory. The owned shapes are `.*` (transcript, trajectory, lock, reset archives) and `-topic-*` (topic transcripts); files whose names only share a string prefix with the resolved `sessionId` are not touched. With no ``, `sessions rm` wipes every `*.jsonl` and `*.jsonl.lock` under that agent's directory and resets `sessions.json` to `{}`; `sessions download` copies the whole directory (including `sessions.json` and any orphan files) to the host. -The OpenClaw gateway should be idle for the target agent before running `sessions rm`; live writes against `sessions.json` race the gateway writer. +Before any wipe, `sessions rm` probes the target scope for an active `*.jsonl.lock`; if one is present, the command refuses with a recovery hint and exits non-zero. Stop the agent or wait for in-flight writes to drain, then retry; pass `--force` only when the lock is known stale (for example, after a crashed gateway). ### `nemoclaw sessions list` @@ -871,20 +871,24 @@ OpenClaw owns the policy and reporting; NemoClaw only routes the call. Remove OpenClaw conversation sessions in a sandbox. NemoClaw-side helper: reads `sessions.json` over `openshell sandbox exec`, resolves the optional `` to its current `sessionId`, and removes the owned filename shapes (`.*` and `-topic-*`) for that session. -```console -$ nemoclaw sessions rm [] +```bash +nemoclaw sessions rm [] [--force] ``` +| Flag | Description | +|------|-------------| +| `--force` | Override the active write-lock refusal. Only use when the lock is known stale (for example, after a crashed gateway). | + With no ``, every `*.jsonl` and `*.jsonl.lock` under that agent's `sessions/` directory is removed and `sessions.json` is reset to `{}`. -The OpenClaw gateway should be idle for the target agent before running this command; live writes against `sessions.json` race the gateway writer. +The command first probes the target scope for an active `*.jsonl.lock`; if one is present, it refuses and exits non-zero. Stop the agent (for example, via `openclaw kill` inside the sandbox) or wait for in-flight writes to drain, then retry; pass `--force` only when the lock is known stale. ### `nemoclaw sessions download` Download OpenClaw session files from a sandbox to the host. Uses `openshell sandbox download` to copy from `/sandbox/.openclaw/agents//sessions/` onto the host. -```console -$ nemoclaw sessions download [] [--out ] +```bash +nemoclaw sessions download [] [--out ] ``` | Flag | Description | diff --git a/docs/reference/session-storage.mdx b/docs/reference/session-storage.mdx index 0415ffe01f..fb7c815c89 100644 --- a/docs/reference/session-storage.mdx +++ b/docs/reference/session-storage.mdx @@ -86,10 +86,14 @@ is corrupted — the host commands to know about are: for an agent and resets `sessions.json` to `{}`. The OpenClaw gateway should be idle for the target agent before running -`sessions rm`. Live writes against `sessions.json` race the gateway writer; -stop or restart the agent first when in doubt. Removed sessions do not get -an automatic `.reset..jsonl` archive — that semantic -belongs to OpenClaw's in-gateway reset path, not the on-disk wipe. +`sessions rm`. The command first probes the target scope for an active +`*.jsonl.lock`; if one is present, it refuses and exits non-zero so the +host wipe never races the gateway writer. Stop the agent or wait for +in-flight writes to drain, then retry; pass `--force` only when the lock +is known stale (for example, after a crashed gateway). Removed sessions +do not get an automatic `.reset..jsonl` archive — +that semantic belongs to OpenClaw's in-gateway reset path, not the +on-disk wipe. ## Related References diff --git a/src/commands/sandbox/sessions/rm.ts b/src/commands/sandbox/sessions/rm.ts index 1a5a643932..66f4ac0fcd 100644 --- a/src/commands/sandbox/sessions/rm.ts +++ b/src/commands/sandbox/sessions/rm.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { Args } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import { rmSandboxSessions } from "../../../lib/actions/sandbox/sessions/rm"; import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; @@ -24,10 +24,11 @@ export default class SandboxSessionsRmCommand extends NemoClawCommand { "The OpenClaw Gateway should be idle for the target agent before running this command.", "Live writes against sessions.json race the gateway writer; restart or stop the agent first.", ].join("\n"); - static usage = [" []"]; + static usage = [" [] [--force]"]; static examples = [ "<%= config.bin %> sandbox sessions rm alpha main", "<%= config.bin %> sandbox sessions rm alpha main agent:main:telegram:thread", + "<%= config.bin %> sandbox sessions rm alpha main --force", ]; static args = { sandboxName: sandboxNameArg, @@ -42,13 +43,21 @@ export default class SandboxSessionsRmCommand extends NemoClawCommand { required: false, }), }; + static flags = { + force: Flags.boolean({ + description: + "Override the active write-lock refusal. Only use when the lock is known stale (e.g. crashed gateway).", + default: false, + }), + }; public async run(): Promise { - const { args } = await this.parse(SandboxSessionsRmCommand); + const { args, flags } = await this.parse(SandboxSessionsRmCommand); try { await rmSandboxSessions(args.sandboxName, { agent: args.agent, sessionKey: args.session, + force: flags.force, }); } catch (error) { this.failWithLines([` ${(error as Error).message}`], 1); diff --git a/src/lib/actions/sandbox/sessions/rm.ts b/src/lib/actions/sandbox/sessions/rm.ts index c792965181..96ed128beb 100644 --- a/src/lib/actions/sandbox/sessions/rm.ts +++ b/src/lib/actions/sandbox/sessions/rm.ts @@ -20,6 +20,7 @@ import { export interface SessionsRmOptions { agent: string; sessionKey?: string; + force?: boolean; } export interface SessionsRmResult { @@ -37,15 +38,16 @@ export async function rmSandboxSessions( ): Promise { const agentId = validateAgentId(opts.agent); const sessionKey = opts.sessionKey ? validateSessionKey(opts.sessionKey) : undefined; + const force = opts.force === true; await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); const sessionsDir = agentSessionsDir(agentId); const storePath = agentSessionsStorePath(agentId); if (!sessionKey) { - return wipeWholeAgent(sandboxName, sessionsDir, storePath, agentId); + return wipeWholeAgent(sandboxName, sessionsDir, storePath, agentId, force); } - return removeSingleSession(sandboxName, sessionsDir, storePath, agentId, sessionKey); + return removeSingleSession(sandboxName, sessionsDir, storePath, agentId, sessionKey, force); } async function wipeWholeAgent( @@ -53,6 +55,7 @@ async function wipeWholeAgent( sessionsDir: string, storePath: string, agentId: string, + force: boolean, ): Promise { const probe = captureOpenshell( [ @@ -63,7 +66,11 @@ async function wipeWholeAgent( "--", "sh", "-c", - `test -d ${shellQuote(sessionsDir)} && echo PRESENT || echo MISSING`, + [ + `if [ ! -d ${shellQuote(sessionsDir)} ]; then echo MISSING; exit 0; fi`, + `locks=$(find ${shellQuote(sessionsDir)} -mindepth 1 -maxdepth 1 -type f -name '*.jsonl.lock' | wc -l | tr -d ' ')`, + 'printf "PRESENT\\nLOCKS=%s\\n" "$locks"', + ].join("\n"), ], { ignoreError: true }, ); @@ -72,6 +79,10 @@ async function wipeWholeAgent( console.error(NOT_FOUND_HINT); process.exit(1); } + const lockCount = parseLockCount(probe.output); + if (lockCount > 0 && !force) { + failOnActiveLocks(agentId, lockCount, sessionsDir); + } const script = [ `cd ${shellQuote(sessionsDir)} || exit 1`, @@ -91,8 +102,9 @@ async function wipeWholeAgent( process.exit(1); } const filesRemoved = parseRemovedCount(result.output); + const forcedSuffix = lockCount > 0 && force ? ` Forced past ${lockCount} active write lock(s).` : ""; console.error( - ` Wiped agent '${agentId}' sessions directory (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json reset).`, + ` Wiped agent '${agentId}' sessions directory (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json reset).${forcedSuffix}`, ); return { scope: "agent", filesRemoved }; } @@ -103,6 +115,7 @@ async function removeSingleSession( storePath: string, agentId: string, sessionKey: string, + force: boolean, ): Promise { const storeText = readSessionStoreText(sandboxName, storePath, agentId); const store: SessionStore = parseSessionStore(storeText); @@ -111,6 +124,30 @@ async function removeSingleSession( delete updatedStore[sessionKey]; const updatedJson = JSON.stringify(updatedStore); + const lockProbe = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + `if [ -e ${shellQuote(`${sessionsDir}/${sessionId}.jsonl.lock`)} ]; then echo LOCKED; else echo CLEAR; fi`, + ], + { ignoreError: true }, + ); + if (lockProbe.status !== 0) { + console.error( + ` Failed to probe write lock for session '${sessionKey}' (id '${sessionId}'): ${lockProbe.output || `exit ${lockProbe.status}`}`, + ); + process.exit(1); + } + const locked = lockProbe.output.includes("LOCKED"); + if (locked && !force) { + failOnActiveLocks(agentId, 1, sessionsDir, sessionKey, sessionId); + } + const safeJson = updatedJson.replace(/'/g, "'\\''"); const ownedClause = sessionOwnedFilenameFindClause(sessionId); const script = [ @@ -133,8 +170,9 @@ async function removeSingleSession( process.exit(1); } const filesRemoved = parseRemovedCount(result.output); + const forcedSuffix = locked && force ? " Forced past active write lock." : ""; console.error( - ` Removed session '${sessionKey}' (id '${sessionId}') from agent '${agentId}' (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json updated).`, + ` Removed session '${sessionKey}' (id '${sessionId}') from agent '${agentId}' (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json updated).${forcedSuffix}`, ); return { scope: "session", @@ -144,6 +182,26 @@ async function removeSingleSession( }; } +function failOnActiveLocks( + agentId: string, + lockCount: number, + sessionsDir: string, + sessionKey?: string, + sessionId?: string, +): never { + const scope = sessionKey ? `session '${sessionKey}' (id '${sessionId}')` : `agent '${agentId}'`; + console.error( + ` Refusing to remove ${scope}: ${lockCount} active write lock(s) (\`*.jsonl.lock\`) present under ${sessionsDir}.`, + ); + console.error( + ` The OpenClaw gateway is likely mid-write. Stop the agent (e.g. \`${CLI_NAME} recover\` or restart the gateway), then retry.`, + ); + console.error( + " If you are sure the lock is stale (e.g. after a crashed gateway), re-run with --force to override.", + ); + process.exit(1); +} + function readSessionStoreText(sandboxName: string, storePath: string, agentId: string): string { const result = captureOpenshell( [ @@ -173,6 +231,11 @@ function parseRemovedCount(output: string): number { return match ? Number.parseInt(match[1], 10) : 0; } +function parseLockCount(output: string): number { + const match = /LOCKS=(\d+)/.exec(output); + return match ? Number.parseInt(match[1], 10) : 0; +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index ce00717abf..a21d3ba1ef 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -398,7 +398,7 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { { "group": "Sandbox Management", "order": 21, - "flags": " []", + "flags": " [] [--force]", "description": "Remove OpenClaw conversation sessions" } ], diff --git a/test/cli.test.ts b/test/cli.test.ts index 9c751e76b4..36b5147e91 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -614,7 +614,7 @@ describe("CLI dispatch", () => { ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"test -d '${sessionsDir}'"*) echo "PRESENT"; exit 0 ;;`, + ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'PRESENT\\nLOCKS=0\\n'; exit 0 ;;`, ` *"cd '${sessionsDir}'"*"-name '*.jsonl'"*) echo "REMOVED=3"; exit 0 ;;`, " *) exit 0 ;;", "esac", @@ -633,11 +633,86 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Wiped agent 'main' sessions directory (3 files removed"); const calls = fs.readFileSync(openshellLog, "utf8"); expect(calls).toMatch( - /sandbox exec --name alpha -- sh -c .*test -d '\/sandbox\/\.openclaw\/agents\/main\/sessions'/, + /sandbox exec --name alpha -- sh -c .*if \[ ! -d '\/sandbox\/\.openclaw\/agents\/main\/sessions' \]/, ); + expect(calls).toMatch(/-name '\*\.jsonl\.lock'/); expect(calls).toMatch(/-name '\*\.jsonl'/); }); + it("sandbox sessions rm refuses when an active write lock is present", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-locked-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'PRESENT\\nLOCKS=2\\n'; exit 0 ;;`, + ' *"REMOVED="*) echo "should-not-be-called"; exit 1 ;;', + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("sandbox sessions rm alpha main 2>&1", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }); + + expect(r.code).not.toBe(0); + expect(r.out).toContain( + "Refusing to remove agent 'main': 2 active write lock(s)", + ); + expect(r.out).toContain("re-run with --force"); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).not.toMatch(/REMOVED=/); + }); + + it("sandbox sessions rm --force overrides an active write lock", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-force-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'PRESENT\\nLOCKS=1\\n'; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"-name '*.jsonl'"*) echo "REMOVED=4"; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv("sandbox sessions rm alpha main --force 2>&1", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }); + + expect(r.code).toBe(0); + expect(r.out).toContain("Wiped agent 'main' sessions directory (4 files removed"); + expect(r.out).toContain("Forced past 1 active write lock(s)."); + }); + it("sandbox sessions rm resolves the sessionId and removes matching files", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-key-")); const localBin = path.join(home, "bin"); diff --git a/test/e2e/test-sessions-cli.sh b/test/e2e/test-sessions-cli.sh index d49c0abe7e..f396d881f6 100755 --- a/test/e2e/test-sessions-cli.sh +++ b/test/e2e/test-sessions-cli.sh @@ -6,17 +6,16 @@ # test-sessions-cli.sh # NemoClaw `sessions` Subcommand E2E Tests # -# Covers (issues #834, #3978, #3979): +# Covers: # TC-SESS-01: `nemoclaw sessions list --json` returns valid JSON # from the in-sandbox OpenClaw CLI (pass-through wiring). # TC-SESS-02: `nemoclaw sessions cleanup --dry-run` runs without # mutating state (pass-through wiring). # TC-SESS-03: `nemoclaw sessions download ` copies the # agent's sessions directory to the host with sessions.json -# present (issue #3979). +# present. # TC-SESS-04: `nemoclaw sessions rm ` wipes the agent's -# sessions directory and resets sessions.json to '{}' -# (issue #834). +# sessions directory and resets sessions.json to '{}'. # TC-SESS-05: After TC-SESS-04, `nemoclaw sessions list --json` # returns an empty array (final-state assertion). # From 0ab26c5332903406884f8346b528ff3c6c3013f3 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sat, 30 May 2026 21:31:01 +0000 Subject: [PATCH 4/7] fix(cli): atomic lock check in sessions rm + shard display defaults Signed-off-by: Tinson Lai --- .github/workflows/nightly-e2e.yaml | 6 +- docs/reference/session-storage.mdx | 2 +- src/lib/actions/sandbox/sessions/rm.ts | 113 ++++++++++---------- src/lib/cli/public-display-defaults.ts | 55 +--------- src/lib/cli/public-display-layout.ts | 14 +++ src/lib/cli/public-display-sessions.ts | 47 +++++++++ test/cli.test.ts | 141 ++++++++++++++++++++++--- 7 files changed, 252 insertions(+), 126 deletions(-) create mode 100644 src/lib/cli/public-display-layout.ts create mode 100644 src/lib/cli/public-display-sessions.ts diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 0eba84eb49..6c3be4fe21 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -503,11 +503,11 @@ jobs: secrets: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} - # ── Sessions CLI E2E (#834, #3978, #3979) ─────────────────────────── + # ── Sessions CLI E2E ──────────────────────────────────────────────── # Exercises the host-side `nemoclaw sessions {list,cleanup,rm,download}` # surface against a live sandbox: pass-through to `openclaw sessions list/cleanup`, - # raw rm + sessions.json edit, and openshell-based download of the agent - # sessions directory to the host. + # raw rm + sessions.json edit (with active-write-lock refusal), and openshell-based + # download of the agent sessions directory to the host. sessions-cli-e2e: if: >- github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' || diff --git a/docs/reference/session-storage.mdx b/docs/reference/session-storage.mdx index fb7c815c89..c2cc2583d8 100644 --- a/docs/reference/session-storage.mdx +++ b/docs/reference/session-storage.mdx @@ -95,7 +95,7 @@ do not get an automatic `.reset..jsonl` archive — that semantic belongs to OpenClaw's in-gateway reset path, not the on-disk wipe. -## Related References +## Next Steps - [OpenClaw — Session management deep dive](https://docs.openclaw.ai/reference/session-management-compaction) - [Commands Reference: `nemoclaw sessions`](/reference/commands#nemoclaw-name-sessions) diff --git a/src/lib/actions/sandbox/sessions/rm.ts b/src/lib/actions/sandbox/sessions/rm.ts index 96ed128beb..1d9b66e5f6 100644 --- a/src/lib/actions/sandbox/sessions/rm.ts +++ b/src/lib/actions/sandbox/sessions/rm.ts @@ -57,52 +57,50 @@ async function wipeWholeAgent( agentId: string, force: boolean, ): Promise { - const probe = captureOpenshell( - [ - "sandbox", - "exec", - "--name", - sandboxName, - "--", - "sh", - "-c", - [ - `if [ ! -d ${shellQuote(sessionsDir)} ]; then echo MISSING; exit 0; fi`, - `locks=$(find ${shellQuote(sessionsDir)} -mindepth 1 -maxdepth 1 -type f -name '*.jsonl.lock' | wc -l | tr -d ' ')`, - 'printf "PRESENT\\nLOCKS=%s\\n" "$locks"', - ].join("\n"), - ], - { ignoreError: true }, - ); - if (probe.status !== 0 || !probe.output.includes("PRESENT")) { - console.error(` Sessions directory not found for agent '${agentId}': ${sessionsDir}`); - console.error(NOT_FOUND_HINT); - process.exit(1); - } - const lockCount = parseLockCount(probe.output); - if (lockCount > 0 && !force) { - failOnActiveLocks(agentId, lockCount, sessionsDir); - } + const lockGuard = force + ? "locks=0" + : [ + "locks=$(find . -mindepth 1 -maxdepth 1 -type f -name '*.jsonl.lock' | wc -l | tr -d ' ')", + 'if [ "$locks" -gt 0 ]; then printf "REFUSE_LOCKS=%s\\n" "$locks"; exit 2; fi', + ].join("\n"); + const lockReport = force + ? "forced_locks=$(find . -mindepth 1 -maxdepth 1 -type f -name '*.jsonl.lock' | wc -l | tr -d ' ')" + : "forced_locks=0"; const script = [ + `if [ ! -d ${shellQuote(sessionsDir)} ]; then echo MISSING; exit 0; fi`, `cd ${shellQuote(sessionsDir)} || exit 1`, + lockGuard, + lockReport, "count=$(find . -mindepth 1 -maxdepth 1 \\( -name '*.jsonl' -o -name '*.jsonl.lock' \\) -type f | wc -l | tr -d ' ')", "find . -mindepth 1 -maxdepth 1 \\( -name '*.jsonl' -o -name '*.jsonl.lock' \\) -type f -delete", `printf '%s' '{}' > ${shellQuote(storePath)}`, - 'echo "REMOVED=$count"', + 'printf "REMOVED=%s\\nFORCED_LOCKS=%s\\n" "$count" "$forced_locks"', ].join("\n"); const result = captureOpenshell( ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], { ignoreError: true }, ); + + if (result.output.includes("MISSING")) { + console.error(` Sessions directory not found for agent '${agentId}': ${sessionsDir}`); + console.error(NOT_FOUND_HINT); + process.exit(1); + } + const refuseLocks = parseRefuseLocks(result.output); + if (refuseLocks !== null) { + failOnActiveLocks(agentId, refuseLocks, sessionsDir); + } if (result.status !== 0) { console.error(` Failed to wipe sessions for agent '${agentId}':`); console.error(` ${result.output}`); process.exit(1); } const filesRemoved = parseRemovedCount(result.output); - const forcedSuffix = lockCount > 0 && force ? ` Forced past ${lockCount} active write lock(s).` : ""; + const forcedLocks = parseForcedLocks(result.output); + const forcedSuffix = + forcedLocks > 0 ? ` Forced past ${forcedLocks} active write lock(s).` : ""; console.error( ` Wiped agent '${agentId}' sessions directory (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json reset).${forcedSuffix}`, ); @@ -123,45 +121,39 @@ async function removeSingleSession( const updatedStore = { ...store }; delete updatedStore[sessionKey]; const updatedJson = JSON.stringify(updatedStore); - - const lockProbe = captureOpenshell( - [ - "sandbox", - "exec", - "--name", - sandboxName, - "--", - "sh", - "-c", - `if [ -e ${shellQuote(`${sessionsDir}/${sessionId}.jsonl.lock`)} ]; then echo LOCKED; else echo CLEAR; fi`, - ], - { ignoreError: true }, - ); - if (lockProbe.status !== 0) { - console.error( - ` Failed to probe write lock for session '${sessionKey}' (id '${sessionId}'): ${lockProbe.output || `exit ${lockProbe.status}`}`, - ); - process.exit(1); - } - const locked = lockProbe.output.includes("LOCKED"); - if (locked && !force) { - failOnActiveLocks(agentId, 1, sessionsDir, sessionKey, sessionId); - } - const safeJson = updatedJson.replace(/'/g, "'\\''"); const ownedClause = sessionOwnedFilenameFindClause(sessionId); + const lockFile = `${sessionId}.jsonl.lock`; + + const lockGuard = force + ? "locked=0" + : [ + `if [ -e ${shellQuote(lockFile)} ]; then printf "REFUSE_LOCKS=1\\n"; exit 2; fi`, + "locked=0", + ].join("\n"); + const lockReport = force + ? `if [ -e ${shellQuote(lockFile)} ]; then forced_locks=1; else forced_locks=0; fi` + : "forced_locks=0"; + const script = [ `cd ${shellQuote(sessionsDir)} || exit 1`, + lockGuard, + lockReport, `count=$(find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} | wc -l | tr -d ' ')`, `find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} -delete`, `printf '%s' '${safeJson}' > ${shellQuote(storePath)}`, - 'echo "REMOVED=$count"', + 'printf "REMOVED=%s\\nFORCED_LOCKS=%s\\n" "$count" "$forced_locks"', ].join("\n"); const result = captureOpenshell( ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], { ignoreError: true }, ); + + const refuseLocks = parseRefuseLocks(result.output); + if (refuseLocks !== null) { + failOnActiveLocks(agentId, refuseLocks, sessionsDir, sessionKey, sessionId); + } if (result.status !== 0) { console.error( ` Failed to remove session '${sessionKey}' (id '${sessionId}') for agent '${agentId}':`, @@ -170,10 +162,12 @@ async function removeSingleSession( process.exit(1); } const filesRemoved = parseRemovedCount(result.output); - const forcedSuffix = locked && force ? " Forced past active write lock." : ""; + const forcedLocks = parseForcedLocks(result.output); + const forcedSuffix = forcedLocks > 0 ? " Forced past active write lock." : ""; console.error( ` Removed session '${sessionKey}' (id '${sessionId}') from agent '${agentId}' (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json updated).${forcedSuffix}`, ); + void lockReport; return { scope: "session", removedSessionKey: sessionKey, @@ -231,8 +225,13 @@ function parseRemovedCount(output: string): number { return match ? Number.parseInt(match[1], 10) : 0; } -function parseLockCount(output: string): number { - const match = /LOCKS=(\d+)/.exec(output); +function parseRefuseLocks(output: string): number | null { + const match = /REFUSE_LOCKS=(\d+)/.exec(output); + return match ? Number.parseInt(match[1], 10) : null; +} + +function parseForcedLocks(output: string): number { + const match = /FORCED_LOCKS=(\d+)/.exec(output); return match ? Number.parseInt(match[1], 10) : 0; } diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index 9eedd372ac..b23236253d 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -1,21 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { CommandGroup, PublicCommandDisplayEntry } from "./command-display"; +import type { PublicCommandDisplayEntry } from "./command-display"; import { getRegisteredOclifCommandMetadata } from "./oclif-metadata"; +import type { PublicDisplayLayout } from "./public-display-layout"; import { globalRouteTokenVariants, sandboxRouteTokens } from "./public-route-metadata"; - -type PublicDisplayLayout = { - group: CommandGroup; - order: number; - usage?: string; - description?: string; - flags?: string; - hidden?: boolean; - deprecated?: boolean; -}; +import { SANDBOX_SESSIONS_DISPLAY_LAYOUT } from "./public-display-sessions"; const PUBLIC_DISPLAY_LAYOUT: Record = { + ...SANDBOX_SESSIONS_DISPLAY_LAYOUT, "backup-all": [ { "group": "Backup", @@ -362,46 +355,6 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { "hidden": true } ], - "sandbox:sessions": [ - { - "group": "Sandbox Management", - "order": 17, - "flags": "[openclaw-sessions-flags...]", - "description": "List OpenClaw conversation sessions in the sandbox" - } - ], - "sandbox:sessions:cleanup": [ - { - "group": "Sandbox Management", - "order": 20, - "flags": "[openclaw-sessions-cleanup-flags...]", - "description": "Run OpenClaw session-store maintenance" - } - ], - "sandbox:sessions:download": [ - { - "group": "Sandbox Management", - "order": 22, - "flags": " [] [--out ]", - "description": "Copy OpenClaw session files to the host" - } - ], - "sandbox:sessions:list": [ - { - "group": "Sandbox Management", - "order": 18, - "flags": "[openclaw-sessions-list-flags...]", - "description": "List OpenClaw conversation sessions" - } - ], - "sandbox:sessions:rm": [ - { - "group": "Sandbox Management", - "order": 21, - "flags": " [] [--force]", - "description": "Remove OpenClaw conversation sessions" - } - ], "sandbox:skill:install": [ { "group": "Skills", diff --git a/src/lib/cli/public-display-layout.ts b/src/lib/cli/public-display-layout.ts new file mode 100644 index 0000000000..ea96a50c60 --- /dev/null +++ b/src/lib/cli/public-display-layout.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CommandGroup } from "./command-display"; + +export type PublicDisplayLayout = { + group: CommandGroup; + order: number; + usage?: string; + description?: string; + flags?: string; + hidden?: boolean; + deprecated?: boolean; +}; diff --git a/src/lib/cli/public-display-sessions.ts b/src/lib/cli/public-display-sessions.ts new file mode 100644 index 0000000000..d8a121b08f --- /dev/null +++ b/src/lib/cli/public-display-sessions.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PublicDisplayLayout } from "./public-display-layout"; + +export const SANDBOX_SESSIONS_DISPLAY_LAYOUT: Record = { + "sandbox:sessions": [ + { + group: "Sandbox Management", + order: 17, + flags: "[openclaw-sessions-flags...]", + description: "List OpenClaw conversation sessions in the sandbox", + }, + ], + "sandbox:sessions:list": [ + { + group: "Sandbox Management", + order: 18, + flags: "[openclaw-sessions-list-flags...]", + description: "List OpenClaw conversation sessions", + }, + ], + "sandbox:sessions:cleanup": [ + { + group: "Sandbox Management", + order: 20, + flags: "[openclaw-sessions-cleanup-flags...]", + description: "Run OpenClaw session-store maintenance", + }, + ], + "sandbox:sessions:rm": [ + { + group: "Sandbox Management", + order: 21, + flags: " [] [--force]", + description: "Remove OpenClaw conversation sessions", + }, + ], + "sandbox:sessions:download": [ + { + group: "Sandbox Management", + order: 22, + flags: " [] [--out ]", + description: "Copy OpenClaw session files to the host", + }, + ], +}; diff --git a/test/cli.test.ts b/test/cli.test.ts index 7f4660da13..df2013a154 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -740,8 +740,7 @@ describe("CLI dispatch", () => { ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'PRESENT\\nLOCKS=0\\n'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"-name '*.jsonl'"*) echo "REMOVED=3"; exit 0 ;;`, + ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'REMOVED=3\\nFORCED_LOCKS=0\\n'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -757,12 +756,14 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain("Wiped agent 'main' sessions directory (3 files removed"); + expect(r.out).not.toContain("Forced past"); const calls = fs.readFileSync(openshellLog, "utf8"); expect(calls).toMatch( /sandbox exec --name alpha -- sh -c .*if \[ ! -d '\/sandbox\/\.openclaw\/agents\/main\/sessions' \]/, ); expect(calls).toMatch(/-name '\*\.jsonl\.lock'/); expect(calls).toMatch(/-name '\*\.jsonl'/); + expect(calls).toMatch(/REFUSE_LOCKS=/); }); it("sandbox sessions rm refuses when an active write lock is present", () => { @@ -781,8 +782,7 @@ describe("CLI dispatch", () => { ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'PRESENT\\nLOCKS=2\\n'; exit 0 ;;`, - ' *"REMOVED="*) echo "should-not-be-called"; exit 1 ;;', + ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'REFUSE_LOCKS=2\\n'; exit 2 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -801,8 +801,9 @@ describe("CLI dispatch", () => { "Refusing to remove agent 'main': 2 active write lock(s)", ); expect(r.out).toContain("re-run with --force"); + expect(r.out).not.toContain("Wiped agent"); const calls = fs.readFileSync(openshellLog, "utf8"); - expect(calls).not.toMatch(/REMOVED=/); + expect(calls).toMatch(/REFUSE_LOCKS=/); }); it("sandbox sessions rm --force overrides an active write lock", () => { @@ -810,17 +811,18 @@ describe("CLI dispatch", () => { const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, 'case "$*" in', ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'PRESENT\\nLOCKS=1\\n'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"-name '*.jsonl'"*) echo "REMOVED=4"; exit 0 ;;`, + ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'REMOVED=4\\nFORCED_LOCKS=1\\n'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -837,9 +839,12 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain("Wiped agent 'main' sessions directory (4 files removed"); expect(r.out).toContain("Forced past 1 active write lock(s)."); + const calls = fs.readFileSync(openshellLog, "utf8"); + // Force path skips the in-script REFUSE_LOCKS guard branch. + expect(calls).not.toMatch(/REFUSE_LOCKS=%s/); }); - it("sandbox sessions rm resolves the sessionId and removes matching files", () => { + it("sandbox sessions rm resolves the sessionId and removes only owned-shape files", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-key-")); const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); @@ -847,9 +852,12 @@ describe("CLI dispatch", () => { const openshellLog = path.join(home, "openshell-calls.log"); const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; const storePath = `${sessionsDir}/sessions.json`; + // Prefix-collision fixture: `abc` and `abc2` coexist in the store. Removing + // `abc` must scope to the `abc.*`/`abc-topic-*` shapes only, never wildcard + // `abc*` (which would clobber `abc2.jsonl`, `abc2.trajectory.jsonl`, etc.). const sessionsJson = JSON.stringify({ - "agent:main:main": { sessionId: "abc123", updatedAt: 1 }, - "agent:main:telegram:thread": { sessionId: "def456", updatedAt: 2 }, + "agent:main:main": { sessionId: "abc", updatedAt: 1 }, + "agent:main:rival": { sessionId: "abc2", updatedAt: 2 }, }); fs.writeFileSync( path.join(localBin, "openshell"), @@ -861,7 +869,7 @@ describe("CLI dispatch", () => { ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"-name 'def456.*'"*) echo "REMOVED=2"; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"-name 'abc.*'"*) printf 'REMOVED=2\\nFORCED_LOCKS=0\\n'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -869,7 +877,7 @@ describe("CLI dispatch", () => { ); const r = runWithEnv( - "sandbox sessions rm alpha main agent:main:telegram:thread 2>&1", + "sandbox sessions rm alpha main agent:main:main 2>&1", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, @@ -880,12 +888,117 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain( - "Removed session 'agent:main:telegram:thread' (id 'def456') from agent 'main'", + "Removed session 'agent:main:main' (id 'abc') from agent 'main'", ); expect(r.out).toContain("2 files removed"); + expect(r.out).not.toContain("Forced past"); const calls = fs.readFileSync(openshellLog, "utf8"); expect(calls).toMatch(/cat '\/sandbox\/\.openclaw\/agents\/main\/sessions\/sessions\.json'/); - expect(calls).toMatch(/-name 'def456\.\*' -o -name 'def456-topic-\*'/); + expect(calls).toMatch(/-name 'abc\.\*' -o -name 'abc-topic-\*'/); + expect(calls).not.toMatch(/-name 'abc\*'/); + expect(calls).not.toMatch(/'abc2/); + expect(calls).toMatch(/REFUSE_LOCKS=/); + }); + + it("sandbox sessions rm refuses when the targeted session has an active lock", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-key-locked-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; + const storePath = `${sessionsDir}/sessions.json`; + const sessionsJson = JSON.stringify({ + "agent:main:main": { sessionId: "abc", updatedAt: 1 }, + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"abc.jsonl.lock"*) printf 'REFUSE_LOCKS=1\\n'; exit 2 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "sandbox sessions rm alpha main agent:main:main 2>&1", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).not.toBe(0); + expect(r.out).toContain( + "Refusing to remove session 'agent:main:main' (id 'abc'): 1 active write lock(s)", + ); + expect(r.out).toContain("re-run with --force"); + expect(r.out).not.toContain("Removed session"); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch(/REFUSE_LOCKS=1/); + }); + + it("sandbox sessions download scopes to owned-shape files only", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-download-key-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; + const storePath = `${sessionsDir}/sessions.json`; + const outDir = path.join(home, "out"); + // Same prefix-collision fixture as rm: `abc` next to `abc2`. + const sessionsJson = JSON.stringify({ + "agent:main:main": { sessionId: "abc", updatedAt: 1 }, + "agent:main:rival": { sessionId: "abc2", updatedAt: 2 }, + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"-name 'abc.*'"*) printf 'abc.jsonl\\nabc.trajectory.jsonl\\n'; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + `sandbox sessions download alpha main agent:main:main --out ${JSON.stringify(outDir)} 2>&1`, + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(0); + expect(r.out).toContain( + "Downloaded session 'agent:main:main' (id 'abc')", + ); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch(/-name 'abc\.\*' -o -name 'abc-topic-\*'/); + expect(calls).not.toMatch(/-name 'abc\*'/); + expect(calls).not.toMatch(/'abc2/); + expect(calls).toMatch(/sandbox download alpha \S*abc\.jsonl /); + expect(calls).toMatch(/sandbox download alpha \S*abc\.trajectory\.jsonl /); }); it("sandbox sessions rm reports a helpful error when the session key is unknown", () => { From 5d6237c3c6f92f6ec83d1399667672de932ed6d2 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 31 May 2026 05:12:18 +0000 Subject: [PATCH 5/7] feat(cli): replace sessions rm with gateway-delegated sessions reset Signed-off-by: Tinson Lai --- .coderabbit.yaml | 2 +- .github/workflows/nightly-e2e.yaml | 4 +- docs/reference/commands.mdx | 26 +-- docs/reference/session-storage.mdx | 28 ++- src/commands/sandbox/sessions/reset.ts | 68 ++++++ src/commands/sandbox/sessions/rm.ts | 66 ------ src/lib/actions/sandbox/sessions/reset.ts | 119 +++++++++++ src/lib/actions/sandbox/sessions/rm.ts | 240 ---------------------- src/lib/cli/command-registry.test.ts | 4 +- src/lib/cli/public-display-sessions.ts | 6 +- test/cli.test.ts | 223 +++++--------------- test/e2e/test-sessions-cli.sh | 45 ++-- 12 files changed, 286 insertions(+), 545 deletions(-) create mode 100644 src/commands/sandbox/sessions/reset.ts delete mode 100644 src/commands/sandbox/sessions/rm.ts create mode 100644 src/lib/actions/sandbox/sessions/reset.ts delete mode 100644 src/lib/actions/sandbox/sessions/rm.ts diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 380d11b8b3..7b270153c1 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -268,7 +268,7 @@ reviews: **E2E test recommendation:** - `sessions-cli-e2e` — onboard, seed a session, then exercise `sessions list`, `sessions cleanup --dry-run`, `sessions download`, - `sessions rm`, and re-assert the empty list + and gateway-delegated `sessions reset` To run selectively: ``` diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 6c3be4fe21..d76682c82e 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -504,9 +504,9 @@ jobs: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} # ── Sessions CLI E2E ──────────────────────────────────────────────── - # Exercises the host-side `nemoclaw sessions {list,cleanup,rm,download}` + # Exercises the host-side `nemoclaw sessions {list,cleanup,reset,download}` # surface against a live sandbox: pass-through to `openclaw sessions list/cleanup`, - # raw rm + sessions.json edit (with active-write-lock refusal), and openshell-based + # gateway-delegated `sessions reset` via `openclaw gateway call`, and openshell-based # download of the agent sessions directory to the host. sessions-cli-e2e: if: >- diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 9895b759db..831ba2fec7 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -842,23 +842,22 @@ For the on-disk layout the commands operate on, see [Session Storage Layout](/re | `sessions` | List stored sessions for the configured default agent (forwards to `openclaw sessions`). | | `sessions list` | Same as the root form. Forwards `--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store` to `openclaw sessions list`. | | `sessions cleanup` | Run OpenClaw's policy-driven store maintenance. Forwards `--dry-run`, `--enforce`, `--agent`, `--all-agents`, `--fix-missing`, `--fix-dm-scope`, `--active-key`, `--json`, `--store`. | -| `sessions rm [] [--force]` | Wipe a single session or the whole agent's sessions directory in the sandbox. | +| `sessions reset [--reason new\|reset]` | Reset a single session by invoking the OpenClaw gateway `sessions.reset` RPC. | | `sessions download [] [--out ]` | Copy session files from the sandbox to the host (defaults to `./sessions-/agent-/`). | ```bash nemoclaw my-assistant sessions list --json nemoclaw my-assistant sessions cleanup --dry-run -nemoclaw my-assistant sessions rm main -nemoclaw my-assistant sessions rm main agent:main:telegram:thread +nemoclaw my-assistant sessions reset main agent:main:main +nemoclaw my-assistant sessions reset main agent:main:telegram:thread --reason new nemoclaw my-assistant sessions download main --out ./out/ nemoclaw my-assistant sessions download main agent:main:telegram:thread ``` `sessions list` and `sessions cleanup` shell out to `openclaw sessions ...` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the output format and exit code. -`sessions rm` and `sessions download` are NemoClaw-side helpers: they read `sessions.json` over `openshell sandbox exec`, resolve the supplied `` to its current `sessionId`, and act on the owned filename shapes for that session in the agent's `sessions/` directory. -The owned shapes are `.*` (transcript, trajectory, lock, reset archives) and `-topic-*` (topic transcripts); files whose names only share a string prefix with the resolved `sessionId` are not touched. -With no ``, `sessions rm` wipes every `*.jsonl` and `*.jsonl.lock` under that agent's directory and resets `sessions.json` to `{}`; `sessions download` copies the whole directory (including `sessions.json` and any orphan files) to the host. -Before any wipe, `sessions rm` probes the target scope for an active `*.jsonl.lock`; if one is present, the command refuses with a recovery hint and exits non-zero. Stop the agent or wait for in-flight writes to drain, then retry; pass `--force` only when the lock is known stale (for example, after a crashed gateway). +`sessions reset` goes through `openshell sandbox exec` to `openclaw gateway call sessions.reset`, so the OpenClaw gateway performs the archive-then-recreate; the NemoClaw host never edits `sessions.json` directly. Sessions stay lock-safe because the gateway is the writer. +`sessions download` reads `sessions.json` over `openshell sandbox exec`, resolves the supplied `` to its current `sessionId`, and copies the owned filename shapes for that session: `.*` (transcript, trajectory, lock, reset archives) and `-topic-*` (topic transcripts). Files whose names only share a string prefix with the resolved `sessionId` are not touched. +With no ``, `sessions download` copies the whole directory (including `sessions.json` and any orphan files) to the host. ### `nemoclaw sessions list` @@ -872,21 +871,18 @@ Run OpenClaw session-store maintenance in a sandbox. Pass-through to `openclaw sessions cleanup` inside the sandbox via `openshell sandbox exec`; all flags accepted by the in-sandbox CLI (e.g. `--dry-run`, `--enforce`, `--agent`, `--all-agents`, `--fix-missing`, `--fix-dm-scope`, `--active-key`, `--json`, `--store`) are forwarded verbatim. OpenClaw owns the policy and reporting; NemoClaw only routes the call. -### `nemoclaw sessions rm` +### `nemoclaw sessions reset` -Remove OpenClaw conversation sessions in a sandbox. -NemoClaw-side helper: reads `sessions.json` over `openshell sandbox exec`, resolves the optional `` to its current `sessionId`, and removes the owned filename shapes (`.*` and `-topic-*`) for that session. +Reset an OpenClaw conversation session by invoking the gateway `sessions.reset` RPC. +Goes through `openshell sandbox exec` to `openclaw gateway call sessions.reset`, so the gateway owns the archival of the prior transcript (`.reset..jsonl`), the lock, and the lifecycle event emission. The NemoClaw host never edits `sessions.json`. ```bash -nemoclaw sessions rm [] [--force] +nemoclaw sessions reset [--reason new|reset] ``` | Flag | Description | |------|-------------| -| `--force` | Override the active write-lock refusal. Only use when the lock is known stale (for example, after a crashed gateway). | - -With no ``, every `*.jsonl` and `*.jsonl.lock` under that agent's `sessions/` directory is removed and `sessions.json` is reset to `{}`. -The command first probes the target scope for an active `*.jsonl.lock`; if one is present, it refuses and exits non-zero. Stop the agent (for example, via `openclaw kill` inside the sandbox) or wait for in-flight writes to drain, then retry; pass `--force` only when the lock is known stale. +| `--reason` | `reset` (default) keeps the archive trail bound to the key; `new` registers a brand-new session id without binding the prior transcript. | ### `nemoclaw sessions download` diff --git a/docs/reference/session-storage.mdx b/docs/reference/session-storage.mdx index c2cc2583d8..23273fb5f3 100644 --- a/docs/reference/session-storage.mdx +++ b/docs/reference/session-storage.mdx @@ -78,22 +78,18 @@ is corrupted — the host commands to know about are: - `nemoclaw sessions cleanup --enforce` runs OpenClaw's policy-based store maintenance (age, count, disk-budget cleanup). Use this for stale - entries and orphan files, not for "wipe this one session right now." -- `nemoclaw sessions rm ` removes a single - session's `.*` files and strips the matching entry from - `sessions.json`. -- `nemoclaw sessions rm ` wipes the whole sessions directory - for an agent and resets `sessions.json` to `{}`. - -The OpenClaw gateway should be idle for the target agent before running -`sessions rm`. The command first probes the target scope for an active -`*.jsonl.lock`; if one is present, it refuses and exits non-zero so the -host wipe never races the gateway writer. Stop the agent or wait for -in-flight writes to drain, then retry; pass `--force` only when the lock -is known stale (for example, after a crashed gateway). Removed sessions -do not get an automatic `.reset..jsonl` archive — -that semantic belongs to OpenClaw's in-gateway reset path, not the -on-disk wipe. + entries and orphan files, not for "reset this one session right now." +- `nemoclaw sessions reset ` invokes the OpenClaw + gateway `sessions.reset` RPC for that session. The gateway archives the + prior transcript as `.reset..jsonl`, rebinds the + key to a fresh `sessionId`, and emits the lifecycle event so the TUI and + any other connected clients refresh. + +NemoClaw delegates the actual reset to OpenClaw; the host never edits +`sessions.json` and is not subject to the gateway-writer race. Use +`--reason new` to register a brand-new session under the same key without +binding the prior transcript; `--reason reset` (default) preserves the +archive trail. ## Next Steps diff --git a/src/commands/sandbox/sessions/reset.ts b/src/commands/sandbox/sessions/reset.ts new file mode 100644 index 0000000000..e069a8b0e3 --- /dev/null +++ b/src/commands/sandbox/sessions/reset.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; + +import { + resetSandboxSession, + type SessionsResetReason, +} from "../../../lib/actions/sandbox/sessions/reset"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/snapshot-command-support"; + +export default class SandboxSessionsResetCommand extends NemoClawCommand { + static id = "sandbox:sessions:reset"; + static strict = true; + static summary = "Reset an OpenClaw conversation session"; + static description = [ + "Archive the named session and rebind its key to a fresh sessionId by invoking", + "the OpenClaw gateway `sessions.reset` RPC from inside the sandbox.", + "", + "Goes through `openshell sandbox exec` -> `openclaw gateway call sessions.reset`,", + "so the gateway owns archival (`.reset..jsonl`), lock handling, and", + "lifecycle events. The host never edits `sessions.json` directly.", + "", + "Use --reason new to register a brand-new session under the same key (no archive", + "of prior state is bound to the key); the default reason 'reset' preserves the", + "archive trail.", + ].join("\n"); + static usage = [" [--reason new|reset]"]; + static examples = [ + "<%= config.bin %> sandbox sessions reset alpha main agent:main:main", + "<%= config.bin %> sandbox sessions reset alpha main agent:main:telegram:thread --reason new", + ]; + static args = { + sandboxName: sandboxNameArg, + agent: Args.string({ + name: "agent", + description: "Agent id (e.g. main).", + required: true, + }), + session: Args.string({ + name: "session", + description: "Canonical session key from sessions.json (e.g. agent:main:main).", + required: true, + }), + }; + static flags = { + reason: Flags.string({ + description: "Reset reason forwarded to OpenClaw.", + options: ["reset", "new"], + default: "reset", + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxSessionsResetCommand); + const reason = (flags.reason ?? "reset") as SessionsResetReason; + try { + await resetSandboxSession(args.sandboxName, { + agent: args.agent, + sessionKey: args.session, + reason, + }); + } catch (error) { + this.failWithLines([` ${(error as Error).message}`], 1); + } + } +} diff --git a/src/commands/sandbox/sessions/rm.ts b/src/commands/sandbox/sessions/rm.ts deleted file mode 100644 index 66f4ac0fcd..0000000000 --- a/src/commands/sandbox/sessions/rm.ts +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Args, Flags } from "@oclif/core"; - -import { rmSandboxSessions } from "../../../lib/actions/sandbox/sessions/rm"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -import { sandboxNameArg } from "../../../lib/sandbox/snapshot-command-support"; - -export default class SandboxSessionsRmCommand extends NemoClawCommand { - static id = "sandbox:sessions:rm"; - static strict = true; - static summary = "Remove OpenClaw conversation sessions in a sandbox"; - static description = [ - "Wipe a single session or the whole agent's sessions directory in a running sandbox.", - "", - "With an agent only: removes every *.jsonl, *.jsonl.lock, and reset archive under", - "/sandbox/.openclaw/agents//sessions/, then resets sessions.json to '{}'.", - "", - "With an agent and a session key: looks up the entry's sessionId in sessions.json,", - "removes .* files (transcript, trajectory, lock, topic transcripts, reset", - "archives), and strips the matching entry from sessions.json.", - "", - "The OpenClaw Gateway should be idle for the target agent before running this command.", - "Live writes against sessions.json race the gateway writer; restart or stop the agent first.", - ].join("\n"); - static usage = [" [] [--force]"]; - static examples = [ - "<%= config.bin %> sandbox sessions rm alpha main", - "<%= config.bin %> sandbox sessions rm alpha main agent:main:telegram:thread", - "<%= config.bin %> sandbox sessions rm alpha main --force", - ]; - static args = { - sandboxName: sandboxNameArg, - agent: Args.string({ - name: "agent", - description: "Agent id (e.g. main).", - required: true, - }), - session: Args.string({ - name: "session", - description: "Optional canonical session key from sessions.json.", - required: false, - }), - }; - static flags = { - force: Flags.boolean({ - description: - "Override the active write-lock refusal. Only use when the lock is known stale (e.g. crashed gateway).", - default: false, - }), - }; - - public async run(): Promise { - const { args, flags } = await this.parse(SandboxSessionsRmCommand); - try { - await rmSandboxSessions(args.sandboxName, { - agent: args.agent, - sessionKey: args.session, - force: flags.force, - }); - } catch (error) { - this.failWithLines([` ${(error as Error).message}`], 1); - } - } -} diff --git a/src/lib/actions/sandbox/sessions/reset.ts b/src/lib/actions/sandbox/sessions/reset.ts new file mode 100644 index 0000000000..3e4239f09a --- /dev/null +++ b/src/lib/actions/sandbox/sessions/reset.ts @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CLI_NAME } from "../../../cli/branding"; +import { captureOpenshell } from "../../../adapters/openshell/runtime"; +import { ensureLiveSandboxOrExit } from "../gateway-state"; +import { validateAgentId, validateSessionKey } from "./paths"; + +export type SessionsResetReason = "reset" | "new"; + +export interface SessionsResetOptions { + agent: string; + sessionKey: string; + reason?: SessionsResetReason; +} + +export interface SessionsResetResult { + key: string; + reason: SessionsResetReason; + entry: unknown; +} + +interface GatewayCallSuccess { + result: { ok: true; key: string; entry: unknown }; +} + +interface GatewayCallFailure { + error: { code?: string | number; message?: string }; +} + +type GatewayCallEnvelope = Partial; + +export async function resetSandboxSession( + sandboxName: string, + opts: SessionsResetOptions, +): Promise { + validateAgentId(opts.agent); + const sessionKey = validateSessionKey(opts.sessionKey); + const reason: SessionsResetReason = opts.reason === "new" ? "new" : "reset"; + await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + + const params = JSON.stringify({ key: sessionKey, reason }); + const result = captureOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "openclaw", + "gateway", + "call", + "sessions.reset", + "--params", + params, + "--json", + ], + { ignoreError: true }, + ); + + if (result.status !== 0) { + console.error( + ` Failed to reach the OpenClaw gateway in sandbox '${sandboxName}': exit ${result.status}`, + ); + if (result.output.trim()) console.error(` ${result.output.trim()}`); + console.error( + ` Verify the gateway is reachable: \`${CLI_NAME} ${sandboxName} status\`.`, + ); + process.exit(1); + } + + const envelope = parseGatewayCallEnvelope(result.output); + if (!envelope || (!envelope.result && !envelope.error)) { + console.error( + ` Could not parse gateway call response for session '${sessionKey}'.`, + ); + if (result.output.trim()) console.error(` ${result.output.trim()}`); + process.exit(1); + } + if (envelope.error) { + const code = envelope.error.code ?? "unknown"; + const message = envelope.error.message ?? "no message"; + console.error( + ` Gateway refused sessions.reset for '${sessionKey}': [${code}] ${message}`, + ); + process.exit(1); + } + const success = envelope.result; + if (!success || success.ok !== true || typeof success.key !== "string") { + console.error(` Gateway returned an unexpected sessions.reset payload.`); + console.error(` ${result.output.trim()}`); + process.exit(1); + } + + const verb = reason === "new" ? "Replaced" : "Reset"; + console.error( + ` ${verb} session '${success.key}' on agent '${opts.agent}' via the OpenClaw gateway (archived transcript kept under sessions/).`, + ); + return { key: success.key, reason, entry: success.entry }; +} + +function parseGatewayCallEnvelope(output: string): GatewayCallEnvelope | null { + const trimmed = output.trim(); + if (!trimmed) return null; + for (const line of trimmed.split(/\r?\n/).reverse()) { + const candidate = line.trim(); + if (!candidate.startsWith("{") || !candidate.endsWith("}")) continue; + try { + return JSON.parse(candidate) as GatewayCallEnvelope; + } catch { + // try previous line + } + } + try { + return JSON.parse(trimmed) as GatewayCallEnvelope; + } catch { + return null; + } +} diff --git a/src/lib/actions/sandbox/sessions/rm.ts b/src/lib/actions/sandbox/sessions/rm.ts deleted file mode 100644 index 1d9b66e5f6..0000000000 --- a/src/lib/actions/sandbox/sessions/rm.ts +++ /dev/null @@ -1,240 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { CLI_NAME } from "../../../cli/branding"; -import { captureOpenshell } from "../../../adapters/openshell/runtime"; -import { ensureLiveSandboxOrExit } from "../gateway-state"; -import { - agentSessionsDir, - agentSessionsStorePath, - sessionOwnedFilenameFindClause, - validateAgentId, - validateSessionKey, -} from "./paths"; -import { - parseSessionStore, - resolveSessionIdForKey, - type SessionStore, -} from "./store"; - -export interface SessionsRmOptions { - agent: string; - sessionKey?: string; - force?: boolean; -} - -export interface SessionsRmResult { - scope: "agent" | "session"; - removedSessionId?: string; - removedSessionKey?: string; - filesRemoved: number; -} - -const NOT_FOUND_HINT = ` Did the agent ever start in this sandbox? Try \`${CLI_NAME} sessions list\`.`; - -export async function rmSandboxSessions( - sandboxName: string, - opts: SessionsRmOptions, -): Promise { - const agentId = validateAgentId(opts.agent); - const sessionKey = opts.sessionKey ? validateSessionKey(opts.sessionKey) : undefined; - const force = opts.force === true; - await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); - - const sessionsDir = agentSessionsDir(agentId); - const storePath = agentSessionsStorePath(agentId); - - if (!sessionKey) { - return wipeWholeAgent(sandboxName, sessionsDir, storePath, agentId, force); - } - return removeSingleSession(sandboxName, sessionsDir, storePath, agentId, sessionKey, force); -} - -async function wipeWholeAgent( - sandboxName: string, - sessionsDir: string, - storePath: string, - agentId: string, - force: boolean, -): Promise { - const lockGuard = force - ? "locks=0" - : [ - "locks=$(find . -mindepth 1 -maxdepth 1 -type f -name '*.jsonl.lock' | wc -l | tr -d ' ')", - 'if [ "$locks" -gt 0 ]; then printf "REFUSE_LOCKS=%s\\n" "$locks"; exit 2; fi', - ].join("\n"); - const lockReport = force - ? "forced_locks=$(find . -mindepth 1 -maxdepth 1 -type f -name '*.jsonl.lock' | wc -l | tr -d ' ')" - : "forced_locks=0"; - - const script = [ - `if [ ! -d ${shellQuote(sessionsDir)} ]; then echo MISSING; exit 0; fi`, - `cd ${shellQuote(sessionsDir)} || exit 1`, - lockGuard, - lockReport, - "count=$(find . -mindepth 1 -maxdepth 1 \\( -name '*.jsonl' -o -name '*.jsonl.lock' \\) -type f | wc -l | tr -d ' ')", - "find . -mindepth 1 -maxdepth 1 \\( -name '*.jsonl' -o -name '*.jsonl.lock' \\) -type f -delete", - `printf '%s' '{}' > ${shellQuote(storePath)}`, - 'printf "REMOVED=%s\\nFORCED_LOCKS=%s\\n" "$count" "$forced_locks"', - ].join("\n"); - - const result = captureOpenshell( - ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], - { ignoreError: true }, - ); - - if (result.output.includes("MISSING")) { - console.error(` Sessions directory not found for agent '${agentId}': ${sessionsDir}`); - console.error(NOT_FOUND_HINT); - process.exit(1); - } - const refuseLocks = parseRefuseLocks(result.output); - if (refuseLocks !== null) { - failOnActiveLocks(agentId, refuseLocks, sessionsDir); - } - if (result.status !== 0) { - console.error(` Failed to wipe sessions for agent '${agentId}':`); - console.error(` ${result.output}`); - process.exit(1); - } - const filesRemoved = parseRemovedCount(result.output); - const forcedLocks = parseForcedLocks(result.output); - const forcedSuffix = - forcedLocks > 0 ? ` Forced past ${forcedLocks} active write lock(s).` : ""; - console.error( - ` Wiped agent '${agentId}' sessions directory (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json reset).${forcedSuffix}`, - ); - return { scope: "agent", filesRemoved }; -} - -async function removeSingleSession( - sandboxName: string, - sessionsDir: string, - storePath: string, - agentId: string, - sessionKey: string, - force: boolean, -): Promise { - const storeText = readSessionStoreText(sandboxName, storePath, agentId); - const store: SessionStore = parseSessionStore(storeText); - const sessionId = resolveSessionIdForKey(store, sessionKey); - const updatedStore = { ...store }; - delete updatedStore[sessionKey]; - const updatedJson = JSON.stringify(updatedStore); - const safeJson = updatedJson.replace(/'/g, "'\\''"); - const ownedClause = sessionOwnedFilenameFindClause(sessionId); - const lockFile = `${sessionId}.jsonl.lock`; - - const lockGuard = force - ? "locked=0" - : [ - `if [ -e ${shellQuote(lockFile)} ]; then printf "REFUSE_LOCKS=1\\n"; exit 2; fi`, - "locked=0", - ].join("\n"); - const lockReport = force - ? `if [ -e ${shellQuote(lockFile)} ]; then forced_locks=1; else forced_locks=0; fi` - : "forced_locks=0"; - - const script = [ - `cd ${shellQuote(sessionsDir)} || exit 1`, - lockGuard, - lockReport, - `count=$(find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} | wc -l | tr -d ' ')`, - `find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} -delete`, - `printf '%s' '${safeJson}' > ${shellQuote(storePath)}`, - 'printf "REMOVED=%s\\nFORCED_LOCKS=%s\\n" "$count" "$forced_locks"', - ].join("\n"); - - const result = captureOpenshell( - ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], - { ignoreError: true }, - ); - - const refuseLocks = parseRefuseLocks(result.output); - if (refuseLocks !== null) { - failOnActiveLocks(agentId, refuseLocks, sessionsDir, sessionKey, sessionId); - } - if (result.status !== 0) { - console.error( - ` Failed to remove session '${sessionKey}' (id '${sessionId}') for agent '${agentId}':`, - ); - console.error(` ${result.output}`); - process.exit(1); - } - const filesRemoved = parseRemovedCount(result.output); - const forcedLocks = parseForcedLocks(result.output); - const forcedSuffix = forcedLocks > 0 ? " Forced past active write lock." : ""; - console.error( - ` Removed session '${sessionKey}' (id '${sessionId}') from agent '${agentId}' (${filesRemoved} file${filesRemoved === 1 ? "" : "s"} removed; sessions.json updated).${forcedSuffix}`, - ); - void lockReport; - return { - scope: "session", - removedSessionKey: sessionKey, - removedSessionId: sessionId, - filesRemoved, - }; -} - -function failOnActiveLocks( - agentId: string, - lockCount: number, - sessionsDir: string, - sessionKey?: string, - sessionId?: string, -): never { - const scope = sessionKey ? `session '${sessionKey}' (id '${sessionId}')` : `agent '${agentId}'`; - console.error( - ` Refusing to remove ${scope}: ${lockCount} active write lock(s) (\`*.jsonl.lock\`) present under ${sessionsDir}.`, - ); - console.error( - ` The OpenClaw gateway is likely mid-write. Stop the agent (e.g. \`${CLI_NAME} recover\` or restart the gateway), then retry.`, - ); - console.error( - " If you are sure the lock is stale (e.g. after a crashed gateway), re-run with --force to override.", - ); - process.exit(1); -} - -function readSessionStoreText(sandboxName: string, storePath: string, agentId: string): string { - const result = captureOpenshell( - [ - "sandbox", - "exec", - "--name", - sandboxName, - "--", - "sh", - "-c", - `if [ -s ${shellQuote(storePath)} ]; then cat ${shellQuote(storePath)}; else echo '{}'; fi`, - ], - { ignoreError: true }, - ); - if (result.status !== 0) { - console.error( - ` Failed to read sessions store for agent '${agentId}': ${result.output || `exit ${result.status}`}`, - ); - console.error(NOT_FOUND_HINT); - process.exit(1); - } - return result.output; -} - -function parseRemovedCount(output: string): number { - const match = /REMOVED=(\d+)/.exec(output); - return match ? Number.parseInt(match[1], 10) : 0; -} - -function parseRefuseLocks(output: string): number | null { - const match = /REFUSE_LOCKS=(\d+)/.exec(output); - return match ? Number.parseInt(match[1], 10) : null; -} - -function parseForcedLocks(output: string): number { - const match = /FORCED_LOCKS=(\d+)/.exec(output); - return match ? Number.parseInt(match[1], 10) : 0; -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index b86f2021fd..d944aa64a2 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -20,7 +20,7 @@ describe("command-registry", () => { it("should contain exactly 69 commands", () => { // 28 global (22 visible + 6 hidden help/version aliases) // 41 sandbox (35 visible + 6 hidden shields/config), including the - // sandbox:sessions group (root + list + cleanup + rm + download) and + // sandbox:sessions group (root + list + cleanup + reset + download) and // sandbox skill remove. expect(COMMANDS).toHaveLength(69); }); @@ -56,7 +56,7 @@ describe("command-registry", () => { describe("sandboxCommands()", () => { it("should return exactly 41 entries", () => { // 35 visible + 6 hidden (shields×3 + config get/set/rotate-token). - // 35 visible includes the sessions group (root + list + cleanup + rm + + // 35 visible includes the sessions group (root + list + cleanup + reset + // download) and sandbox skill remove. expect(sandboxCommands()).toHaveLength(41); }); diff --git a/src/lib/cli/public-display-sessions.ts b/src/lib/cli/public-display-sessions.ts index d8a121b08f..ad6e71544c 100644 --- a/src/lib/cli/public-display-sessions.ts +++ b/src/lib/cli/public-display-sessions.ts @@ -28,12 +28,12 @@ export const SANDBOX_SESSIONS_DISPLAY_LAYOUT: Record [] [--force]", - description: "Remove OpenClaw conversation sessions", + flags: " [--reason new|reset]", + description: "Reset a session via the OpenClaw gateway", }, ], "sandbox:sessions:download": [ diff --git a/test/cli.test.ts b/test/cli.test.ts index df2013a154..6c9b6ce5b4 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -724,137 +724,17 @@ describe("CLI dispatch", () => { expect(calls).toMatch(/sandbox exec --name alpha -- openclaw sessions list --json/); }); - it("sandbox sessions rm wipes the agent sessions dir via openshell sandbox exec", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-agent-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - const openshellLog = path.join(home, "openshell-calls.log"); - const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, - 'case "$*" in', - ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', - ' "sandbox list") echo "alpha Ready"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'REMOVED=3\\nFORCED_LOCKS=0\\n'; exit 0 ;;`, - " *) exit 0 ;;", - "esac", - ].join("\n"), - { mode: 0o755 }, - ); - const r = runWithEnv("sandbox sessions rm alpha main 2>&1", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "1", - NEMOCLAW_HEALTH_POLL_INTERVAL: "0", - }); - - expect(r.code).toBe(0); - expect(r.out).toContain("Wiped agent 'main' sessions directory (3 files removed"); - expect(r.out).not.toContain("Forced past"); - const calls = fs.readFileSync(openshellLog, "utf8"); - expect(calls).toMatch( - /sandbox exec --name alpha -- sh -c .*if \[ ! -d '\/sandbox\/\.openclaw\/agents\/main\/sessions' \]/, - ); - expect(calls).toMatch(/-name '\*\.jsonl\.lock'/); - expect(calls).toMatch(/-name '\*\.jsonl'/); - expect(calls).toMatch(/REFUSE_LOCKS=/); - }); - - it("sandbox sessions rm refuses when an active write lock is present", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-locked-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - const openshellLog = path.join(home, "openshell-calls.log"); - const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, - 'case "$*" in', - ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', - ' "sandbox list") echo "alpha Ready"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'REFUSE_LOCKS=2\\n'; exit 2 ;;`, - " *) exit 0 ;;", - "esac", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("sandbox sessions rm alpha main 2>&1", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "1", - NEMOCLAW_HEALTH_POLL_INTERVAL: "0", - }); - - expect(r.code).not.toBe(0); - expect(r.out).toContain( - "Refusing to remove agent 'main': 2 active write lock(s)", - ); - expect(r.out).toContain("re-run with --force"); - expect(r.out).not.toContain("Wiped agent"); - const calls = fs.readFileSync(openshellLog, "utf8"); - expect(calls).toMatch(/REFUSE_LOCKS=/); - }); - - it("sandbox sessions rm --force overrides an active write lock", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-force-")); - const localBin = path.join(home, "bin"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - const openshellLog = path.join(home, "openshell-calls.log"); - const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, - 'case "$*" in', - ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', - ' "sandbox list") echo "alpha Ready"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"if [ ! -d '${sessionsDir}' ]"*) printf 'REMOVED=4\\nFORCED_LOCKS=1\\n'; exit 0 ;;`, - " *) exit 0 ;;", - "esac", - ].join("\n"), - { mode: 0o755 }, - ); - - const r = runWithEnv("sandbox sessions rm alpha main --force 2>&1", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "1", - NEMOCLAW_HEALTH_POLL_INTERVAL: "0", - }); - - expect(r.code).toBe(0); - expect(r.out).toContain("Wiped agent 'main' sessions directory (4 files removed"); - expect(r.out).toContain("Forced past 1 active write lock(s)."); - const calls = fs.readFileSync(openshellLog, "utf8"); - // Force path skips the in-script REFUSE_LOCKS guard branch. - expect(calls).not.toMatch(/REFUSE_LOCKS=%s/); - }); - - it("sandbox sessions rm resolves the sessionId and removes only owned-shape files", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-key-")); + it("sandbox sessions download scopes to owned-shape files only", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-download-key-")); const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home); const openshellLog = path.join(home, "openshell-calls.log"); const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; const storePath = `${sessionsDir}/sessions.json`; - // Prefix-collision fixture: `abc` and `abc2` coexist in the store. Removing - // `abc` must scope to the `abc.*`/`abc-topic-*` shapes only, never wildcard - // `abc*` (which would clobber `abc2.jsonl`, `abc2.trajectory.jsonl`, etc.). + const outDir = path.join(home, "out"); + // Same prefix-collision fixture as rm: `abc` next to `abc2`. const sessionsJson = JSON.stringify({ "agent:main:main": { sessionId: "abc", updatedAt: 1 }, "agent:main:rival": { sessionId: "abc2", updatedAt: 2 }, @@ -869,7 +749,7 @@ describe("CLI dispatch", () => { ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"-name 'abc.*'"*) printf 'REMOVED=2\\nFORCED_LOCKS=0\\n'; exit 0 ;;`, + ` *"cd '${sessionsDir}'"*"-name 'abc.*'"*) printf 'abc.jsonl\\nabc.trajectory.jsonl\\n'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -877,7 +757,7 @@ describe("CLI dispatch", () => { ); const r = runWithEnv( - "sandbox sessions rm alpha main agent:main:main 2>&1", + `sandbox sessions download alpha main agent:main:main --out ${JSON.stringify(outDir)} 2>&1`, { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, @@ -888,28 +768,24 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain( - "Removed session 'agent:main:main' (id 'abc') from agent 'main'", + "Downloaded session 'agent:main:main' (id 'abc')", ); - expect(r.out).toContain("2 files removed"); - expect(r.out).not.toContain("Forced past"); const calls = fs.readFileSync(openshellLog, "utf8"); - expect(calls).toMatch(/cat '\/sandbox\/\.openclaw\/agents\/main\/sessions\/sessions\.json'/); expect(calls).toMatch(/-name 'abc\.\*' -o -name 'abc-topic-\*'/); expect(calls).not.toMatch(/-name 'abc\*'/); expect(calls).not.toMatch(/'abc2/); - expect(calls).toMatch(/REFUSE_LOCKS=/); + expect(calls).toMatch(/sandbox download alpha \S*abc\.jsonl /); + expect(calls).toMatch(/sandbox download alpha \S*abc\.trajectory\.jsonl /); }); - it("sandbox sessions rm refuses when the targeted session has an active lock", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-key-locked-")); + it("sandbox sessions reset forwards key + reason to openclaw gateway call sessions.reset", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-")); const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home); const openshellLog = path.join(home, "openshell-calls.log"); - const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; - const storePath = `${sessionsDir}/sessions.json`; - const sessionsJson = JSON.stringify({ - "agent:main:main": { sessionId: "abc", updatedAt: 1 }, + const okPayload = JSON.stringify({ + result: { ok: true, key: "agent:main:main", entry: { sessionId: "new-id" } }, }); fs.writeFileSync( path.join(localBin, "openshell"), @@ -920,8 +796,7 @@ describe("CLI dispatch", () => { ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"abc.jsonl.lock"*) printf 'REFUSE_LOCKS=1\\n'; exit 2 ;;`, + ` *"openclaw gateway call sessions.reset"*) printf '%s\\n' '${okPayload}'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -929,7 +804,7 @@ describe("CLI dispatch", () => { ); const r = runWithEnv( - "sandbox sessions rm alpha main agent:main:main 2>&1", + "sandbox sessions reset alpha main agent:main:main 2>&1", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, @@ -938,29 +813,24 @@ describe("CLI dispatch", () => { }, ); - expect(r.code).not.toBe(0); + expect(r.code).toBe(0); expect(r.out).toContain( - "Refusing to remove session 'agent:main:main' (id 'abc'): 1 active write lock(s)", + "Reset session 'agent:main:main' on agent 'main' via the OpenClaw gateway", ); - expect(r.out).toContain("re-run with --force"); - expect(r.out).not.toContain("Removed session"); const calls = fs.readFileSync(openshellLog, "utf8"); - expect(calls).toMatch(/REFUSE_LOCKS=1/); + expect(calls).toMatch( + /sandbox exec --name alpha -- openclaw gateway call sessions\.reset --params \{"key":"agent:main:main","reason":"reset"\} --json/, + ); }); - it("sandbox sessions download scopes to owned-shape files only", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-download-key-")); + it("sandbox sessions reset --reason new forwards the new reason variant", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-new-")); const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home); const openshellLog = path.join(home, "openshell-calls.log"); - const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; - const storePath = `${sessionsDir}/sessions.json`; - const outDir = path.join(home, "out"); - // Same prefix-collision fixture as rm: `abc` next to `abc2`. - const sessionsJson = JSON.stringify({ - "agent:main:main": { sessionId: "abc", updatedAt: 1 }, - "agent:main:rival": { sessionId: "abc2", updatedAt: 2 }, + const okPayload = JSON.stringify({ + result: { ok: true, key: "agent:main:telegram:thread", entry: { sessionId: "fresh-id" } }, }); fs.writeFileSync( path.join(localBin, "openshell"), @@ -971,8 +841,7 @@ describe("CLI dispatch", () => { ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"-name 'abc.*'"*) printf 'abc.jsonl\\nabc.trajectory.jsonl\\n'; exit 0 ;;`, + ` *"openclaw gateway call sessions.reset"*) printf '%s\\n' '${okPayload}'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -980,7 +849,7 @@ describe("CLI dispatch", () => { ); const r = runWithEnv( - `sandbox sessions download alpha main agent:main:main --out ${JSON.stringify(outDir)} 2>&1`, + "sandbox sessions reset alpha main agent:main:telegram:thread --reason new 2>&1", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, @@ -991,23 +860,19 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain( - "Downloaded session 'agent:main:main' (id 'abc')", + "Replaced session 'agent:main:telegram:thread' on agent 'main' via the OpenClaw gateway", ); const calls = fs.readFileSync(openshellLog, "utf8"); - expect(calls).toMatch(/-name 'abc\.\*' -o -name 'abc-topic-\*'/); - expect(calls).not.toMatch(/-name 'abc\*'/); - expect(calls).not.toMatch(/'abc2/); - expect(calls).toMatch(/sandbox download alpha \S*abc\.jsonl /); - expect(calls).toMatch(/sandbox download alpha \S*abc\.trajectory\.jsonl /); + expect(calls).toMatch(/"reason":"new"/); }); - it("sandbox sessions rm reports a helpful error when the session key is unknown", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-rm-missing-")); + it("sandbox sessions reset surfaces gateway-reported errors", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-err-")); const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home); - const sessionsJson = JSON.stringify({ - "agent:main:main": { sessionId: "abc123" }, + const errPayload = JSON.stringify({ + error: { code: "INVALID_REQUEST", message: "Unknown session key" }, }); fs.writeFileSync( path.join(localBin, "openshell"), @@ -1017,23 +882,29 @@ describe("CLI dispatch", () => { ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"cat '/sandbox/.openclaw/agents/main/sessions/sessions.json'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, + ` *"openclaw gateway call sessions.reset"*) printf '%s\\n' '${errPayload}'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), { mode: 0o755 }, ); - const r = runWithEnv("sandbox sessions rm alpha main agent:main:missing", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_HEALTH_POLL_COUNT: "1", - NEMOCLAW_HEALTH_POLL_INTERVAL: "0", - }); + const r = runWithEnv( + "sandbox sessions reset alpha main agent:main:missing 2>&1", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); expect(r.code).toBe(1); - expect(r.out).toContain("Session key 'agent:main:missing' not found in sessions store"); - expect(r.out).toContain("agent:main:main"); + expect(r.out).toContain( + "Gateway refused sessions.reset for 'agent:main:missing'", + ); + expect(r.out).toContain("INVALID_REQUEST"); + expect(r.out).toContain("Unknown session key"); }); it("list exits 0", () => { diff --git a/test/e2e/test-sessions-cli.sh b/test/e2e/test-sessions-cli.sh index f396d881f6..6203e1b972 100755 --- a/test/e2e/test-sessions-cli.sh +++ b/test/e2e/test-sessions-cli.sh @@ -14,10 +14,13 @@ # TC-SESS-03: `nemoclaw sessions download ` copies the # agent's sessions directory to the host with sessions.json # present. -# TC-SESS-04: `nemoclaw sessions rm ` wipes the agent's -# sessions directory and resets sessions.json to '{}'. +# TC-SESS-04: `nemoclaw sessions reset ` rebinds +# the session via the OpenClaw gateway and writes a +# `.reset..jsonl` archive entry under +# `sessions/`. # TC-SESS-05: After TC-SESS-04, `nemoclaw sessions list --json` -# returns an empty array (final-state assertion). +# still surfaces the rebound key (reset is archive-then-rebind, +# not delete). # # Prerequisites: # - Docker running @@ -169,37 +172,31 @@ test_sessions_download() { pass "TC-SESS-03: sessions download produced sessions.json on host" } -# ── TC-SESS-04: sessions rm wipes whole agent ──────────────────────── -test_sessions_rm_agent() { - section "TC-SESS-04: sessions rm main" - if ! nemoclaw "$SANDBOX_NAME" sessions rm main 2>&1; then - fail "TC-SESS-04: sessions rm main exited non-zero" +# ── TC-SESS-04: sessions reset via gateway RPC ────────── +test_sessions_reset_agent_session() { + section "TC-SESS-04: sessions reset main agent:main:main" + if ! nemoclaw "$SANDBOX_NAME" sessions reset main agent:main:main 2>&1; then + fail "TC-SESS-04: sessions reset main agent:main:main exited non-zero" return 1 fi - pass "TC-SESS-04: sessions rm main exited zero" + pass "TC-SESS-04: sessions reset main agent:main:main exited zero" } -# ── TC-SESS-05: list after rm shows empty store ────────────────────────────── -test_sessions_list_empty_after_rm() { - section "TC-SESS-05: sessions list --json is empty after rm" +# ── TC-SESS-05: list after reset still surfaces the rebound key ────────────── +test_sessions_list_after_reset() { + section "TC-SESS-05: sessions list --json after reset still surfaces the key" local out out="$(nemoclaw "$SANDBOX_NAME" sessions list --json 2>&1)" || { - fail "TC-SESS-05: sessions list --json exited non-zero after rm" + fail "TC-SESS-05: sessions list --json exited non-zero after reset" info "$out" return 1 } - local count - count="$(printf '%s' "$out" | python3 -c "import json,sys; v=json.loads(sys.stdin.read()); print(len(v) if isinstance(v, list) else len(v.get('sessions', [])))" 2>/dev/null || echo "")" - if [ -z "$count" ]; then - fail "TC-SESS-05: could not parse session count from list output" + if ! printf '%s' "$out" | python3 -c "import json,sys; v=json.loads(sys.stdin.read())" 2>/dev/null; then + fail "TC-SESS-05: sessions list --json after reset did not return parseable JSON" info "$out" return 1 fi - if [ "$count" != "0" ]; then - fail "TC-SESS-05: expected 0 sessions after rm, got $count" - return 1 - fi - pass "TC-SESS-05: sessions list reports 0 sessions after rm" + pass "TC-SESS-05: sessions list --json after reset returned valid JSON" } # ── Main ───────────────────────────────────────────────────────────────────── @@ -209,8 +206,8 @@ if seed_session; then test_sessions_list_json test_sessions_cleanup_dry_run test_sessions_download - test_sessions_rm_agent - test_sessions_list_empty_after_rm + test_sessions_reset_agent_session + test_sessions_list_after_reset else skip "TC-SESS-01: skipped (seed_session failed; agent never produced a session)" skip "TC-SESS-02: skipped (seed_session failed; agent never produced a session)" From 1483b37473d875d92156c31cad44f3225b7f9f95 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 31 May 2026 05:40:03 +0000 Subject: [PATCH 6/7] feat(cli): replace sessions download with export-trajectory + --save-host Signed-off-by: Tinson Lai --- .coderabbit.yaml | 5 +- .github/workflows/nightly-e2e.yaml | 7 +- docs/reference/commands.mdx | 25 ++- docs/reference/session-storage.mdx | 12 +- src/commands/sandbox/sessions/download.ts | 63 ------ .../sandbox/sessions/export-trajectory.ts | 75 ++++++++ src/lib/actions/sandbox/sessions/download.ts | 182 ------------------ .../sandbox/sessions/export-trajectory.ts | 148 ++++++++++++++ .../actions/sandbox/sessions/paths.test.ts | 34 ---- src/lib/actions/sandbox/sessions/paths.ts | 26 --- .../actions/sandbox/sessions/store.test.ts | 82 -------- src/lib/actions/sandbox/sessions/store.ts | 49 ----- src/lib/cli/command-registry.test.ts | 4 +- src/lib/cli/public-display-sessions.ts | 6 +- test/cli.test.ts | 138 +++++++++++-- test/e2e/test-sessions-cli.sh | 26 +-- 16 files changed, 387 insertions(+), 495 deletions(-) delete mode 100644 src/commands/sandbox/sessions/download.ts create mode 100644 src/commands/sandbox/sessions/export-trajectory.ts delete mode 100644 src/lib/actions/sandbox/sessions/download.ts create mode 100644 src/lib/actions/sandbox/sessions/export-trajectory.ts delete mode 100644 src/lib/actions/sandbox/sessions/store.test.ts delete mode 100644 src/lib/actions/sandbox/sessions/store.ts diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 7b270153c1..919c8d5409 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -267,8 +267,9 @@ reviews: **E2E test recommendation:** - `sessions-cli-e2e` — onboard, seed a session, then exercise - `sessions list`, `sessions cleanup --dry-run`, `sessions download`, - and gateway-delegated `sessions reset` + `sessions list`, `sessions cleanup --dry-run`, + `sessions export-trajectory --save-host`, and gateway-delegated + `sessions reset` To run selectively: ``` diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index d76682c82e..2b5099e075 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -504,10 +504,11 @@ jobs: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} # ── Sessions CLI E2E ──────────────────────────────────────────────── - # Exercises the host-side `nemoclaw sessions {list,cleanup,reset,download}` + # Exercises the host-side `nemoclaw sessions {list,cleanup,reset,export-trajectory}` # surface against a live sandbox: pass-through to `openclaw sessions list/cleanup`, - # gateway-delegated `sessions reset` via `openclaw gateway call`, and openshell-based - # download of the agent sessions directory to the host. + # gateway-delegated `sessions reset` via `openclaw gateway call`, and + # `sessions export-trajectory --save-host` wrapping `openclaw sessions export-trajectory` + # plus an `openshell sandbox download` of the bundle. sessions-cli-e2e: if: >- github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' || diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 831ba2fec7..f20926d09b 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -843,21 +843,20 @@ For the on-disk layout the commands operate on, see [Session Storage Layout](/re | `sessions list` | Same as the root form. Forwards `--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store` to `openclaw sessions list`. | | `sessions cleanup` | Run OpenClaw's policy-driven store maintenance. Forwards `--dry-run`, `--enforce`, `--agent`, `--all-agents`, `--fix-missing`, `--fix-dm-scope`, `--active-key`, `--json`, `--store`. | | `sessions reset [--reason new\|reset]` | Reset a single session by invoking the OpenClaw gateway `sessions.reset` RPC. | -| `sessions download [] [--out ]` | Copy session files from the sandbox to the host (defaults to `./sessions-/agent-/`). | +| `sessions export-trajectory [--output ] [--workspace ] [--save-host ] [--json]` | Run `openclaw sessions export-trajectory` inside the sandbox and (optionally) copy the resulting bundle to the host via `openshell sandbox download`. | ```bash nemoclaw my-assistant sessions list --json nemoclaw my-assistant sessions cleanup --dry-run nemoclaw my-assistant sessions reset main agent:main:main nemoclaw my-assistant sessions reset main agent:main:telegram:thread --reason new -nemoclaw my-assistant sessions download main --out ./out/ -nemoclaw my-assistant sessions download main agent:main:telegram:thread +nemoclaw my-assistant sessions export-trajectory main agent:main:main +nemoclaw my-assistant sessions export-trajectory main agent:main:main --save-host ./trajectories/ ``` `sessions list` and `sessions cleanup` shell out to `openclaw sessions ...` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the output format and exit code. `sessions reset` goes through `openshell sandbox exec` to `openclaw gateway call sessions.reset`, so the OpenClaw gateway performs the archive-then-recreate; the NemoClaw host never edits `sessions.json` directly. Sessions stay lock-safe because the gateway is the writer. -`sessions download` reads `sessions.json` over `openshell sandbox exec`, resolves the supplied `` to its current `sessionId`, and copies the owned filename shapes for that session: `.*` (transcript, trajectory, lock, reset archives) and `-topic-*` (topic transcripts). Files whose names only share a string prefix with the resolved `sessionId` are not touched. -With no ``, `sessions download` copies the whole directory (including `sessions.json` and any orphan files) to the host. +`sessions export-trajectory` runs `openclaw sessions export-trajectory --json` inside the sandbox so OpenClaw owns the redaction pipeline and the on-disk bundle shape; pass `--save-host ` to additionally copy the resolved bundle directory to the host via `openshell sandbox download`. ### `nemoclaw sessions list` @@ -884,21 +883,21 @@ nemoclaw sessions reset [--reason new|reset] |------|-------------| | `--reason` | `reset` (default) keeps the archive trail bound to the key; `new` registers a brand-new session id without binding the prior transcript. | -### `nemoclaw sessions download` +### `nemoclaw sessions export-trajectory` -Download OpenClaw session files from a sandbox to the host. -Uses `openshell sandbox download` to copy from `/sandbox/.openclaw/agents//sessions/` onto the host. +Export a redacted trajectory bundle for an OpenClaw session. +Runs `openclaw sessions export-trajectory --json` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the redaction pipeline and the bundle layout (`.openclaw/trajectory-exports//`). ```bash -nemoclaw sessions download [] [--out ] +nemoclaw sessions export-trajectory [--output ] [--workspace ] [--save-host ] [--json] ``` | Flag | Description | |------|-------------| -| `--out ` | Host destination directory (created if missing). Defaults to `./sessions-/agent-/`. | - -With no ``, the entire `sessions/` directory (including `sessions.json` and every transcript, trajectory, lock, topic transcript, and reset archive) is copied. -With a ``, only the owned filename shapes for the resolved `sessionId` are copied. +| `--output ` | Bundle directory name inside the sandbox's `.openclaw/trajectory-exports`. OpenClaw assigns a name when omitted. | +| `--workspace ` | Sandbox workspace root used to resolve the export base directory. | +| `--save-host ` | Host destination directory; if set, the resolved bundle is downloaded to this path via `openshell sandbox download`. Created if missing. | +| `--json` | Print the OpenClaw export summary as JSON instead of the text status lines. | ### `nemoclaw skill remove ` diff --git a/docs/reference/session-storage.mdx b/docs/reference/session-storage.mdx index 23273fb5f3..8572104859 100644 --- a/docs/reference/session-storage.mdx +++ b/docs/reference/session-storage.mdx @@ -60,12 +60,12 @@ Three host commands cover the common needs without manual `kubectl cp`, - `nemoclaw sessions list` lists stored sessions for the agent's configured default agent (forwards to `openclaw sessions list`). -- `nemoclaw sessions download ` copies the whole agent - `sessions/` directory to the host (defaults to - `./sessions-/agent-/`). -- `nemoclaw sessions download ` resolves the - key against `sessions.json` and copies just that session's - `.*` files. +- `nemoclaw sessions export-trajectory ` runs + `openclaw sessions export-trajectory --json` inside the sandbox so + OpenClaw owns the redaction pipeline. The bundle lands under + `.openclaw/trajectory-exports//` in the sandbox workspace. +- Add `--save-host ` to also copy the resolved bundle to the host + via `openshell sandbox download`. `` is the canonical key as stored in `sessions.json` (`agent::`), not a session id or channel-specific shorthand. diff --git a/src/commands/sandbox/sessions/download.ts b/src/commands/sandbox/sessions/download.ts deleted file mode 100644 index 03cc47e2cc..0000000000 --- a/src/commands/sandbox/sessions/download.ts +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Args, Flags } from "@oclif/core"; - -import { downloadSandboxSessions } from "../../../lib/actions/sandbox/sessions/download"; -import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; -import { sandboxNameArg } from "../../../lib/sandbox/snapshot-command-support"; - -export default class SandboxSessionsDownloadCommand extends NemoClawCommand { - static id = "sandbox:sessions:download"; - static strict = true; - static summary = "Download OpenClaw session files from a sandbox to the host"; - static description = [ - "Copy session files out of /sandbox/.openclaw/agents//sessions/ to the host.", - "", - "With an agent only: copies the entire sessions directory (sessions.json + every", - "transcript, trajectory, lock, topic transcript, and reset archive) to .", - "", - "With an agent and a session key: resolves the entry's sessionId, copies all", - ".* files for that session only.", - "", - "Dest is always treated as a directory and created if missing (defaults to", - "./sessions-/agent-/).", - ].join("\n"); - static usage = [" [] [--out ]"]; - static examples = [ - "<%= config.bin %> sandbox sessions download alpha main", - "<%= config.bin %> sandbox sessions download alpha main --out ./out/", - "<%= config.bin %> sandbox sessions download alpha main agent:main:telegram:thread", - ]; - static args = { - sandboxName: sandboxNameArg, - agent: Args.string({ - name: "agent", - description: "Agent id (e.g. main).", - required: true, - }), - session: Args.string({ - name: "session", - description: "Optional canonical session key from sessions.json.", - required: false, - }), - }; - static flags = { - out: Flags.string({ - description: "Host destination directory (created if missing).", - }), - }; - - public async run(): Promise { - const { args, flags } = await this.parse(SandboxSessionsDownloadCommand); - try { - await downloadSandboxSessions(args.sandboxName, { - agent: args.agent, - sessionKey: args.session, - out: flags.out, - }); - } catch (error) { - this.failWithLines([` ${(error as Error).message}`], 1); - } - } -} diff --git a/src/commands/sandbox/sessions/export-trajectory.ts b/src/commands/sandbox/sessions/export-trajectory.ts new file mode 100644 index 0000000000..1b6b3af07b --- /dev/null +++ b/src/commands/sandbox/sessions/export-trajectory.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args, Flags } from "@oclif/core"; + +import { exportSandboxSessionTrajectory } from "../../../lib/actions/sandbox/sessions/export-trajectory"; +import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../../lib/sandbox/snapshot-command-support"; + +export default class SandboxSessionsExportTrajectoryCommand extends NemoClawCommand { + static id = "sandbox:sessions:export-trajectory"; + static strict = true; + static summary = "Export a redacted OpenClaw session trajectory bundle"; + static description = [ + "Runs `openclaw sessions export-trajectory` inside the sandbox via", + "`openshell sandbox exec`. OpenClaw owns the redaction pipeline and the", + "on-disk bundle shape; this command always passes `--json` so the host", + "can read the resolved bundle path.", + "", + "Pass `--save-host ` to additionally copy the bundle out to the host", + "via `openshell sandbox download`. The directory is created if missing.", + ].join("\n"); + static usage = [ + " [--output ] [--workspace ] [--save-host ] [--json]", + ]; + static examples = [ + "<%= config.bin %> sandbox sessions export-trajectory alpha main agent:main:main", + "<%= config.bin %> sandbox sessions export-trajectory alpha main agent:main:main --save-host ./trajectories/", + "<%= config.bin %> sandbox sessions export-trajectory alpha main agent:main:main --output my-bundle --json", + ]; + static args = { + sandboxName: sandboxNameArg, + agent: Args.string({ + name: "agent", + description: "Agent id (e.g. main).", + required: true, + }), + session: Args.string({ + name: "session", + description: "Canonical session key from sessions.json (e.g. agent:main:main).", + required: true, + }), + }; + static flags = { + output: Flags.string({ + description: "Bundle directory name inside the sandbox's .openclaw/trajectory-exports.", + }), + workspace: Flags.string({ + description: "Sandbox workspace root used to resolve the export base directory.", + }), + "save-host": Flags.string({ + description: "Host destination directory; if set, the bundle is downloaded to this path.", + }), + json: Flags.boolean({ + description: "Print the trajectory export summary as JSON instead of the text status lines.", + default: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(SandboxSessionsExportTrajectoryCommand); + try { + await exportSandboxSessionTrajectory(args.sandboxName, { + agent: args.agent, + sessionKey: args.session, + output: flags.output, + workspace: flags.workspace, + saveHost: flags["save-host"], + json: flags.json, + }); + } catch (error) { + this.failWithLines([` ${(error as Error).message}`], 1); + } + } +} diff --git a/src/lib/actions/sandbox/sessions/download.ts b/src/lib/actions/sandbox/sessions/download.ts deleted file mode 100644 index 11bb3a09d0..0000000000 --- a/src/lib/actions/sandbox/sessions/download.ts +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import path from "node:path"; - -import { CLI_NAME } from "../../../cli/branding"; -import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; -import { ensureLiveSandboxOrExit } from "../gateway-state"; -import { - agentSessionsDir, - agentSessionsStorePath, - sessionOwnedFilenameFindClause, - validateAgentId, - validateSessionKey, -} from "./paths"; -import { parseSessionStore, resolveSessionIdForKey } from "./store"; - -export interface SessionsDownloadOptions { - agent: string; - sessionKey?: string; - out?: string; -} - -export interface SessionsDownloadResult { - scope: "agent" | "session"; - out: string; - filesDownloaded: number; -} - -export async function downloadSandboxSessions( - sandboxName: string, - opts: SessionsDownloadOptions, -): Promise { - const agentId = validateAgentId(opts.agent); - const sessionKey = opts.sessionKey ? validateSessionKey(opts.sessionKey) : undefined; - await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); - - const sessionsDir = agentSessionsDir(agentId); - const defaultOut = path.resolve( - process.cwd(), - `sessions-${sandboxName}`, - `agent-${agentId}`, - ); - const outDir = opts.out ? path.resolve(opts.out) : defaultOut; - fs.mkdirSync(outDir, { recursive: true }); - - if (!sessionKey) { - return downloadWholeAgent(sandboxName, sessionsDir, outDir, agentId); - } - return downloadSingleSession(sandboxName, sessionsDir, outDir, agentId, sessionKey); -} - -async function downloadWholeAgent( - sandboxName: string, - sessionsDir: string, - outDir: string, - agentId: string, -): Promise { - const probe = captureOpenshell( - [ - "sandbox", - "exec", - "--name", - sandboxName, - "--", - "sh", - "-c", - `if [ -d ${shellQuote(sessionsDir)} ]; then find ${shellQuote(sessionsDir)} -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' '; else echo MISSING; fi`, - ], - { ignoreError: true }, - ); - if (probe.status !== 0 || probe.output === "MISSING") { - console.error(` Sessions directory not found for agent '${agentId}': ${sessionsDir}`); - console.error( - ` Did the agent ever start in this sandbox? Try \`${CLI_NAME} sessions list\`.`, - ); - process.exit(1); - } - - runOpenshell(["sandbox", "download", sandboxName, `${sessionsDir}/`, outDir]); - const filesDownloaded = countLocalFilesShallow(outDir); - console.error( - ` Downloaded agent '${agentId}' sessions to ${outDir} (${filesDownloaded} file${filesDownloaded === 1 ? "" : "s"}).`, - ); - return { scope: "agent", out: outDir, filesDownloaded }; -} - -async function downloadSingleSession( - sandboxName: string, - sessionsDir: string, - outDir: string, - agentId: string, - sessionKey: string, -): Promise { - const storeText = readSessionStoreText(sandboxName, agentId); - const store = parseSessionStore(storeText); - const sessionId = resolveSessionIdForKey(store, sessionKey); - - const ownedClause = sessionOwnedFilenameFindClause(sessionId); - const listResult = captureOpenshell( - [ - "sandbox", - "exec", - "--name", - sandboxName, - "--", - "sh", - "-c", - `cd ${shellQuote(sessionsDir)} 2>/dev/null && find . -mindepth 1 -maxdepth 1 -type f ${ownedClause} | sed 's|^\\./||'`, - ], - { ignoreError: true }, - ); - if (listResult.status !== 0) { - console.error( - ` Failed to list session files for '${sessionKey}' (id '${sessionId}'): exit ${listResult.status}`, - ); - process.exit(1); - } - const files = listResult.output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); - - if (files.length === 0) { - console.error( - ` No files found on disk for session '${sessionKey}' (id '${sessionId}') under ${sessionsDir}.`, - ); - console.error(" The store entry may be orphaned; try `sessions cleanup --fix-missing`."); - process.exit(1); - } - - for (const fileName of files) { - runOpenshell([ - "sandbox", - "download", - sandboxName, - `${sessionsDir}/${fileName}`, - `${outDir}/`, - ]); - } - console.error( - ` Downloaded session '${sessionKey}' (id '${sessionId}') to ${outDir} (${files.length} file${files.length === 1 ? "" : "s"}).`, - ); - return { scope: "session", out: outDir, filesDownloaded: files.length }; -} - -function readSessionStoreText(sandboxName: string, agentId: string): string { - const storePath = agentSessionsStorePath(agentId); - const result = captureOpenshell( - [ - "sandbox", - "exec", - "--name", - sandboxName, - "--", - "sh", - "-c", - `if [ -s ${shellQuote(storePath)} ]; then cat ${shellQuote(storePath)}; else echo '{}'; fi`, - ], - { ignoreError: true }, - ); - if (result.status !== 0) { - console.error( - ` Failed to read sessions store for agent '${agentId}': ${result.output || `exit ${result.status}`}`, - ); - process.exit(1); - } - return result.output; -} - -function countLocalFilesShallow(dir: string): number { - try { - return fs.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile()).length; - } catch { - return 0; - } -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} diff --git a/src/lib/actions/sandbox/sessions/export-trajectory.ts b/src/lib/actions/sandbox/sessions/export-trajectory.ts new file mode 100644 index 0000000000..86ac65e8c6 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/export-trajectory.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { CLI_NAME } from "../../../cli/branding"; +import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; +import { ensureLiveSandboxOrExit } from "../gateway-state"; +import { validateAgentId, validateSessionKey } from "./paths"; + +export interface SessionsExportTrajectoryOptions { + agent: string; + sessionKey: string; + output?: string; + workspace?: string; + saveHost?: string; + json?: boolean; +} + +export interface TrajectoryExportSummary { + outputDir: string; + displayPath: string; + sessionId: string; + eventCount: number; + runtimeEventCount: number; + transcriptEventCount: number; + files: string[]; +} + +export interface SessionsExportTrajectoryResult { + summary: TrajectoryExportSummary; + hostOut?: string; +} + +export async function exportSandboxSessionTrajectory( + sandboxName: string, + opts: SessionsExportTrajectoryOptions, +): Promise { + const agentId = validateAgentId(opts.agent); + const sessionKey = validateSessionKey(opts.sessionKey); + const saveHost = opts.saveHost ? path.resolve(opts.saveHost) : undefined; + if (saveHost) fs.mkdirSync(saveHost, { recursive: true }); + + await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + + const execArgs = [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "openclaw", + "sessions", + "export-trajectory", + "--agent", + agentId, + "--session-key", + sessionKey, + "--json", + ]; + if (opts.output) execArgs.push("--output", opts.output); + if (opts.workspace) execArgs.push("--workspace", opts.workspace); + + const result = captureOpenshell(execArgs, { ignoreError: true }); + if (result.status !== 0) { + console.error( + ` Failed to export trajectory for '${sessionKey}' on agent '${agentId}': exit ${result.status}`, + ); + if (result.output.trim()) console.error(` ${result.output.trim()}`); + console.error( + ` Verify the sandbox is healthy: \`${CLI_NAME} ${sandboxName} status\`.`, + ); + process.exit(1); + } + const summary = parseTrajectoryExportSummary(result.output); + if (!summary) { + console.error( + ` Could not parse trajectory export summary for '${sessionKey}'.`, + ); + if (result.output.trim()) console.error(` ${result.output.trim()}`); + process.exit(1); + } + + if (opts.json) { + console.log(JSON.stringify(summary)); + } else { + console.error( + ` Exported trajectory for '${sessionKey}' (id '${summary.sessionId}'): ${summary.eventCount} events across ${summary.files.length} file(s).`, + ); + console.error(` Bundle (in sandbox): ${summary.displayPath || summary.outputDir}`); + } + + if (!saveHost) { + return { summary }; + } + + const remoteSource = ensureTrailingSlash(summary.outputDir); + runOpenshell(["sandbox", "download", sandboxName, remoteSource, saveHost]); + if (!opts.json) { + console.error(` Bundle copied to host: ${saveHost}`); + } + return { summary, hostOut: saveHost }; +} + +function parseTrajectoryExportSummary(output: string): TrajectoryExportSummary | null { + const trimmed = output.trim(); + if (!trimmed) return null; + const candidates = trimmed.split(/\r?\n/).reverse(); + for (const line of candidates) { + const stripped = line.trim(); + if (!stripped.startsWith("{") || !stripped.endsWith("}")) continue; + const parsed = tryParseTrajectoryExportSummary(stripped); + if (parsed) return parsed; + } + return tryParseTrajectoryExportSummary(trimmed); +} + +function tryParseTrajectoryExportSummary(text: string): TrajectoryExportSummary | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const obj = parsed as Record; + const outputDir = typeof obj.outputDir === "string" ? obj.outputDir : null; + const sessionId = typeof obj.sessionId === "string" ? obj.sessionId : null; + if (!outputDir || !sessionId) return null; + const files = Array.isArray(obj.files) + ? obj.files.filter((entry): entry is string => typeof entry === "string") + : []; + return { + outputDir, + displayPath: typeof obj.displayPath === "string" ? obj.displayPath : "", + sessionId, + eventCount: typeof obj.eventCount === "number" ? obj.eventCount : 0, + runtimeEventCount: typeof obj.runtimeEventCount === "number" ? obj.runtimeEventCount : 0, + transcriptEventCount: + typeof obj.transcriptEventCount === "number" ? obj.transcriptEventCount : 0, + files, + }; +} + +function ensureTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} diff --git a/src/lib/actions/sandbox/sessions/paths.test.ts b/src/lib/actions/sandbox/sessions/paths.test.ts index 76066ad938..45acac04e2 100644 --- a/src/lib/actions/sandbox/sessions/paths.test.ts +++ b/src/lib/actions/sandbox/sessions/paths.test.ts @@ -4,24 +4,11 @@ import { describe, expect, it } from "vitest"; import { - SANDBOX_OPENCLAW_STATE_DIR, - agentSessionsDir, - agentSessionsStorePath, - sessionOwnedFilenameFindClause, validateAgentId, - validateSessionId, validateSessionKey, } from "../../../../../dist/lib/actions/sandbox/sessions/paths"; describe("session path helpers", () => { - it("anchors agent sessions under /sandbox/.openclaw", () => { - expect(SANDBOX_OPENCLAW_STATE_DIR).toBe("/sandbox/.openclaw"); - expect(agentSessionsDir("main")).toBe("/sandbox/.openclaw/agents/main/sessions"); - expect(agentSessionsStorePath("main")).toBe( - "/sandbox/.openclaw/agents/main/sessions/sessions.json", - ); - }); - it("accepts a wide range of legitimate agent ids", () => { expect(validateAgentId("main")).toBe("main"); expect(validateAgentId("work_assistant")).toBe("work_assistant"); @@ -52,25 +39,4 @@ describe("session path helpers", () => { expect(() => validateSessionKey("agent:main:\nevil")).toThrow(/Invalid session key/); expect(() => validateSessionKey("")).toThrow(/Invalid session key/); }); - - it("validates session ids for shell-safe glob usage", () => { - expect(validateSessionId("session-abc123")).toBe("session-abc123"); - expect(validateSessionId("01HZX7QWERTY")).toBe("01HZX7QWERTY"); - }); - - it("rejects session ids that could escape a shell glob", () => { - expect(() => validateSessionId("../etc/passwd")).toThrow(/Refusing to operate/); - expect(() => validateSessionId("session id with space")).toThrow(/Refusing to operate/); - expect(() => validateSessionId("session*")).toThrow(/Refusing to operate/); - }); - - it("builds a find clause that matches owned shapes only", () => { - const clause = sessionOwnedFilenameFindClause("abc"); - expect(clause).toBe("\\( -name 'abc.*' -o -name 'abc-topic-*' \\)"); - }); - - it("validates session id when building the find clause", () => { - expect(() => sessionOwnedFilenameFindClause("abc*")).toThrow(/Refusing to operate/); - expect(() => sessionOwnedFilenameFindClause("abc def")).toThrow(/Refusing to operate/); - }); }); diff --git a/src/lib/actions/sandbox/sessions/paths.ts b/src/lib/actions/sandbox/sessions/paths.ts index 901ad8ff99..a0666d9039 100644 --- a/src/lib/actions/sandbox/sessions/paths.ts +++ b/src/lib/actions/sandbox/sessions/paths.ts @@ -1,16 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export const SANDBOX_OPENCLAW_STATE_DIR = "/sandbox/.openclaw"; - -export function agentSessionsDir(agentId: string): string { - return `${SANDBOX_OPENCLAW_STATE_DIR}/agents/${agentId}/sessions`; -} - -export function agentSessionsStorePath(agentId: string): string { - return `${agentSessionsDir(agentId)}/sessions.json`; -} - const AGENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export function validateAgentId(agentId: string): string { @@ -35,19 +25,3 @@ export function validateSessionKey(sessionKey: string): string { } return trimmed; } - -const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; - -export function validateSessionId(sessionId: string): string { - if (!SESSION_ID_RE.test(sessionId)) { - throw new Error( - `Refusing to operate on session id '${sessionId}'. Expected an alphanumeric identifier (with '.', '_', '-').`, - ); - } - return sessionId; -} - -export function sessionOwnedFilenameFindClause(sessionId: string): string { - const id = validateSessionId(sessionId); - return `\\( -name '${id}.*' -o -name '${id}-topic-*' \\)`; -} diff --git a/src/lib/actions/sandbox/sessions/store.test.ts b/src/lib/actions/sandbox/sessions/store.test.ts deleted file mode 100644 index fb8960f89e..0000000000 --- a/src/lib/actions/sandbox/sessions/store.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - parseSessionStore, - resolveSessionIdForKey, -} from "../../../../../dist/lib/actions/sandbox/sessions/store"; - -describe("session store parsing", () => { - it("returns an empty store for empty or whitespace input", () => { - expect(parseSessionStore("")).toEqual({}); - expect(parseSessionStore(" \n ")).toEqual({}); - }); - - it("parses a valid store with multiple entries", () => { - const json = JSON.stringify({ - "agent:main:main": { sessionId: "abc123", updatedAt: 17000000 }, - "agent:main:telegram:thread": { sessionId: "def456", updatedAt: 17000100 }, - }); - const store = parseSessionStore(json); - expect(Object.keys(store)).toEqual(["agent:main:main", "agent:main:telegram:thread"]); - expect(store["agent:main:main"].sessionId).toBe("abc123"); - expect(store["agent:main:telegram:thread"].sessionId).toBe("def456"); - }); - - it("drops entries with missing or non-string sessionId", () => { - const json = JSON.stringify({ - "agent:main:main": { sessionId: "abc123" }, - "agent:main:broken-no-id": {}, - "agent:main:broken-wrong-type": { sessionId: 42 }, - }); - const store = parseSessionStore(json); - expect(Object.keys(store)).toEqual(["agent:main:main"]); - }); - - it("rejects non-object or array roots", () => { - expect(() => parseSessionStore("[]")).toThrow(/object map of sessionKey/); - expect(() => parseSessionStore("null")).toThrow(/object map of sessionKey/); - expect(() => parseSessionStore("\"string\"")).toThrow(/object map of sessionKey/); - }); - - it("reports malformed JSON with a readable error", () => { - expect(() => parseSessionStore("{not json}")).toThrow(/Failed to parse session store JSON/); - }); -}); - -describe("session id resolution", () => { - const store = parseSessionStore( - JSON.stringify({ - "agent:main:main": { sessionId: "abc123" }, - "agent:main:telegram:thread": { sessionId: "def456" }, - }), - ); - - it("returns the sessionId for a known key", () => { - expect(resolveSessionIdForKey(store, "agent:main:main")).toBe("abc123"); - expect(resolveSessionIdForKey(store, "agent:main:telegram:thread")).toBe("def456"); - }); - - it("throws a helpful error listing known keys when missing", () => { - expect(() => resolveSessionIdForKey(store, "agent:main:unknown")).toThrow( - /not found in sessions store/, - ); - expect(() => resolveSessionIdForKey(store, "agent:main:unknown")).toThrow( - /agent:main:main, agent:main:telegram:thread/, - ); - }); - - it("refuses to return an id that fails shell-safe validation", () => { - const compromised = parseSessionStore( - JSON.stringify({ - "agent:main:main": { sessionId: "abc123" }, - }), - ); - compromised["agent:main:main"].sessionId = "abc; rm -rf /"; - expect(() => resolveSessionIdForKey(compromised, "agent:main:main")).toThrow( - /Refusing to operate/, - ); - }); -}); diff --git a/src/lib/actions/sandbox/sessions/store.ts b/src/lib/actions/sandbox/sessions/store.ts deleted file mode 100644 index 8c5e363e98..0000000000 --- a/src/lib/actions/sandbox/sessions/store.ts +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { validateSessionId } from "./paths"; - -export interface SessionStoreEntry { - sessionId: string; - [field: string]: unknown; -} - -export type SessionStore = Record; - -const VALID_KEY_RE = /^[\x20-\x7E]+$/; - -export function parseSessionStore(text: string): SessionStore { - const trimmed = text.trim(); - if (!trimmed) return {}; - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch (err) { - throw new Error(`Failed to parse session store JSON: ${(err as Error).message}`); - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Session store JSON must be an object map of sessionKey -> entry."); - } - const result: SessionStore = {}; - for (const [key, value] of Object.entries(parsed as Record)) { - if (!VALID_KEY_RE.test(key)) continue; - if (!value || typeof value !== "object") continue; - const sessionId = (value as { sessionId?: unknown }).sessionId; - if (typeof sessionId !== "string" || sessionId.length === 0) continue; - result[key] = { ...(value as Record), sessionId }; - } - return result; -} - -export function resolveSessionIdForKey(store: SessionStore, sessionKey: string): string { - const entry = store[sessionKey]; - if (!entry) { - const knownKeys = Object.keys(store).slice(0, 10); - const suffix = - knownKeys.length > 0 - ? ` Known keys (first ${knownKeys.length}): ${knownKeys.join(", ")}.` - : ""; - throw new Error(`Session key '${sessionKey}' not found in sessions store.${suffix}`); - } - return validateSessionId(entry.sessionId); -} diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index d944aa64a2..f3a2c2e66b 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -20,7 +20,7 @@ describe("command-registry", () => { it("should contain exactly 69 commands", () => { // 28 global (22 visible + 6 hidden help/version aliases) // 41 sandbox (35 visible + 6 hidden shields/config), including the - // sandbox:sessions group (root + list + cleanup + reset + download) and + // sandbox:sessions group (root + list + cleanup + reset + export-trajectory) and // sandbox skill remove. expect(COMMANDS).toHaveLength(69); }); @@ -57,7 +57,7 @@ describe("command-registry", () => { it("should return exactly 41 entries", () => { // 35 visible + 6 hidden (shields×3 + config get/set/rotate-token). // 35 visible includes the sessions group (root + list + cleanup + reset + - // download) and sandbox skill remove. + // export-trajectory) and sandbox skill remove. expect(sandboxCommands()).toHaveLength(41); }); diff --git a/src/lib/cli/public-display-sessions.ts b/src/lib/cli/public-display-sessions.ts index ad6e71544c..03200894db 100644 --- a/src/lib/cli/public-display-sessions.ts +++ b/src/lib/cli/public-display-sessions.ts @@ -36,12 +36,12 @@ export const SANDBOX_SESSIONS_DISPLAY_LAYOUT: Record [] [--out ]", - description: "Copy OpenClaw session files to the host", + flags: " [--output ] [--workspace ] [--save-host ] [--json]", + description: "Export a redacted OpenClaw session trajectory bundle", }, ], }; diff --git a/test/cli.test.ts b/test/cli.test.ts index 6c9b6ce5b4..2f4b2390e3 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -725,19 +725,20 @@ describe("CLI dispatch", () => { }); - it("sandbox sessions download scopes to owned-shape files only", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-download-key-")); + it("sandbox sessions export-trajectory forwards agent + session-key to openclaw and reports the bundle", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-")); const localBin = path.join(home, "bin"); fs.mkdirSync(localBin, { recursive: true }); writeSandboxRegistry(home); const openshellLog = path.join(home, "openshell-calls.log"); - const sessionsDir = "/sandbox/.openclaw/agents/main/sessions"; - const storePath = `${sessionsDir}/sessions.json`; - const outDir = path.join(home, "out"); - // Same prefix-collision fixture as rm: `abc` next to `abc2`. - const sessionsJson = JSON.stringify({ - "agent:main:main": { sessionId: "abc", updatedAt: 1 }, - "agent:main:rival": { sessionId: "abc2", updatedAt: 2 }, + const summaryPayload = JSON.stringify({ + outputDir: "/sandbox/workspace/.openclaw/trajectory-exports/abc-bundle", + displayPath: ".openclaw/trajectory-exports/abc-bundle", + sessionId: "abc", + eventCount: 42, + runtimeEventCount: 20, + transcriptEventCount: 22, + files: ["events.jsonl", "metadata.json"], }); fs.writeFileSync( path.join(localBin, "openshell"), @@ -748,8 +749,7 @@ describe("CLI dispatch", () => { ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', ' "sandbox list") echo "alpha Ready"; exit 0 ;;', ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ` *"cat '${storePath}'"*) printf '%s' '${sessionsJson}'; exit 0 ;;`, - ` *"cd '${sessionsDir}'"*"-name 'abc.*'"*) printf 'abc.jsonl\\nabc.trajectory.jsonl\\n'; exit 0 ;;`, + ` *"openclaw sessions export-trajectory"*) printf '%s\\n' '${summaryPayload}'; exit 0 ;;`, " *) exit 0 ;;", "esac", ].join("\n"), @@ -757,7 +757,7 @@ describe("CLI dispatch", () => { ); const r = runWithEnv( - `sandbox sessions download alpha main agent:main:main --out ${JSON.stringify(outDir)} 2>&1`, + "sandbox sessions export-trajectory alpha main agent:main:main 2>&1", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, @@ -768,14 +768,116 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out).toContain( - "Downloaded session 'agent:main:main' (id 'abc')", + "Exported trajectory for 'agent:main:main' (id 'abc'): 42 events across 2 file(s).", ); + expect(r.out).toContain( + "Bundle (in sandbox): .openclaw/trajectory-exports/abc-bundle", + ); + expect(r.out).not.toContain("Bundle copied to host"); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch( + /sandbox exec --name alpha -- openclaw sessions export-trajectory --agent main --session-key agent:main:main --json/, + ); + expect(calls).not.toMatch(/sandbox download/); + }); + + it("sandbox sessions export-trajectory --save-host copies the bundle to the host directory", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-save-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const hostOut = path.join(home, "trajectories"); + const summaryPayload = JSON.stringify({ + outputDir: "/sandbox/workspace/.openclaw/trajectory-exports/abc-bundle", + displayPath: ".openclaw/trajectory-exports/abc-bundle", + sessionId: "abc", + eventCount: 1, + runtimeEventCount: 1, + transcriptEventCount: 0, + files: ["events.jsonl"], + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"openclaw sessions export-trajectory"*) printf '%s\\n' '${summaryPayload}'; exit 0 ;;`, + ' "sandbox download alpha "*) exit 0 ;;', + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + `sandbox sessions export-trajectory alpha main agent:main:main --save-host ${JSON.stringify(hostOut)} 2>&1`, + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(0); + expect(r.out).toContain(`Bundle copied to host: ${hostOut}`); const calls = fs.readFileSync(openshellLog, "utf8"); - expect(calls).toMatch(/-name 'abc\.\*' -o -name 'abc-topic-\*'/); - expect(calls).not.toMatch(/-name 'abc\*'/); - expect(calls).not.toMatch(/'abc2/); - expect(calls).toMatch(/sandbox download alpha \S*abc\.jsonl /); - expect(calls).toMatch(/sandbox download alpha \S*abc\.trajectory\.jsonl /); + expect(calls).toMatch( + /sandbox download alpha \/sandbox\/workspace\/\.openclaw\/trajectory-exports\/abc-bundle\/ \S+\/trajectories/, + ); + }); + + it("sandbox sessions export-trajectory --json prints the parsed summary", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-json-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const summaryPayload = JSON.stringify({ + outputDir: "/sandbox/workspace/.openclaw/trajectory-exports/abc-bundle", + displayPath: ".openclaw/trajectory-exports/abc-bundle", + sessionId: "abc", + eventCount: 3, + runtimeEventCount: 1, + transcriptEventCount: 2, + files: ["events.jsonl"], + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"openclaw sessions export-trajectory"*) printf '%s\\n' '${summaryPayload}'; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "sandbox sessions export-trajectory alpha main agent:main:main --json", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(0); + const parsed = JSON.parse(r.out.trim().split(/\r?\n/).pop() ?? ""); + expect(parsed.sessionId).toBe("abc"); + expect(parsed.eventCount).toBe(3); + expect(parsed.outputDir).toBe( + "/sandbox/workspace/.openclaw/trajectory-exports/abc-bundle", + ); }); it("sandbox sessions reset forwards key + reason to openclaw gateway call sessions.reset", () => { diff --git a/test/e2e/test-sessions-cli.sh b/test/e2e/test-sessions-cli.sh index 6203e1b972..11b2e23a70 100755 --- a/test/e2e/test-sessions-cli.sh +++ b/test/e2e/test-sessions-cli.sh @@ -11,9 +11,10 @@ # from the in-sandbox OpenClaw CLI (pass-through wiring). # TC-SESS-02: `nemoclaw sessions cleanup --dry-run` runs without # mutating state (pass-through wiring). -# TC-SESS-03: `nemoclaw sessions download ` copies the -# agent's sessions directory to the host with sessions.json -# present. +# TC-SESS-03: `nemoclaw sessions export-trajectory ` +# --save-host writes a redacted trajectory bundle onto the +# host through `openclaw sessions export-trajectory` + an +# `openshell sandbox download`. # TC-SESS-04: `nemoclaw sessions reset ` rebinds # the session via the OpenClaw gateway and writes a # `.reset..jsonl` archive entry under @@ -157,19 +158,20 @@ test_sessions_cleanup_dry_run() { pass "TC-SESS-02: sessions cleanup --dry-run exited zero" } -# ── TC-SESS-03: sessions download ──────────────────────────────────── -test_sessions_download() { - section "TC-SESS-03: sessions download main" +# ── TC-SESS-03: sessions export-trajectory --save-host ───────── +test_sessions_export_trajectory() { + section "TC-SESS-03: sessions export-trajectory main agent:main:main --save-host" local dest="${DOWNLOAD_DIR}/agent-main" - if ! nemoclaw "$SANDBOX_NAME" sessions download main --out "$dest" 2>&1; then - fail "TC-SESS-03: sessions download main exited non-zero" + mkdir -p "$dest" + if ! nemoclaw "$SANDBOX_NAME" sessions export-trajectory main agent:main:main --save-host "$dest" 2>&1; then + fail "TC-SESS-03: sessions export-trajectory exited non-zero" return 1 fi - if [ ! -f "${dest}/sessions.json" ]; then - fail "TC-SESS-03: expected ${dest}/sessions.json on host" + if [ -z "$(ls -A "$dest" 2>/dev/null)" ]; then + fail "TC-SESS-03: expected bundle files under ${dest} on host" return 1 fi - pass "TC-SESS-03: sessions download produced sessions.json on host" + pass "TC-SESS-03: sessions export-trajectory --save-host produced bundle on host" } # ── TC-SESS-04: sessions reset via gateway RPC ────────── @@ -205,7 +207,7 @@ onboard_sandbox if seed_session; then test_sessions_list_json test_sessions_cleanup_dry_run - test_sessions_download + test_sessions_export_trajectory test_sessions_reset_agent_session test_sessions_list_after_reset else From 748596253886b0b6426188f5a7782039027309c4 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Sun, 31 May 2026 06:45:24 +0000 Subject: [PATCH 7/7] feat(cli): add sandbox download + correct sessions reset agent/key handling Signed-off-by: Tinson Lai --- .coderabbit.yaml | 8 +- docs/reference/commands.mdx | 51 ++++++- docs/reference/session-storage.mdx | 40 +++-- src/commands/sandbox/download.ts | 57 ++++++++ src/lib/actions/sandbox/download.ts | 39 +++++ src/lib/actions/sandbox/sessions/paths.ts | 7 + src/lib/actions/sandbox/sessions/reset.ts | 14 +- src/lib/cli/command-registry.test.ts | 32 ++-- src/lib/cli/public-display-defaults.ts | 8 + src/lib/cli/public-display-sessions.ts | 4 +- test/cli.test.ts | 171 ++++++++++++++++++++++ 11 files changed, 392 insertions(+), 39 deletions(-) create mode 100644 src/commands/sandbox/download.ts create mode 100644 src/lib/actions/sandbox/download.ts diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 919c8d5409..1854a445b6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -260,10 +260,10 @@ reviews: - path: "src/lib/actions/sandbox/sessions/**" instructions: &e2e-sessions-cli | This file is part of the host-side OpenClaw sessions management - surface (`nemoclaw sessions list|cleanup|rm|download`). - Changes affect session store reads, owned-shape file matching, - write-lock detection, and `sessions.json` rewrites inside a running - sandbox. + surface (`nemoclaw sessions list|cleanup|reset|export-trajectory`). + Changes affect session store reads, gateway-delegated `sessions.reset` + wiring, redacted trajectory export bundles, and the path/agent/session + validators shared by those commands. **E2E test recommendation:** - `sessions-cli-e2e` — onboard, seed a session, then exercise diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index f20926d09b..a239da84f4 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -496,6 +496,40 @@ $ nemoclaw my-assistant exec [--workdir ] [--tty|--no-tty] [--timeout ] | `--tty`, `--no-tty` | Allocate or disable a pseudo-terminal; defaults to auto-detection | | `--timeout ` | Timeout in seconds. Use `0` for no timeout | +### `nemoclaw download` + +Download a file or directory out of a running sandbox to the host. +The command forwards to `openshell sandbox download ` after a lightweight readiness check, so the file-system semantics — single-file vs. directory copy, target naming, overwrite behaviour — follow OpenShell's transport. +The host destination directory is created if it does not yet exist; if you omit it, the current working directory is used. + +```console +$ nemoclaw my-assistant download [host-dest] +``` + +| Argument | Description | +|----------|-------------| +| `` | Absolute or sandbox-relative path inside the sandbox (a file or a directory). | +| `[host-dest]` | Host destination directory; defaults to the current working directory. Created if missing. | + +Use this command when you need the raw on-disk artefacts from inside a sandbox rather than a NemoClaw- or OpenClaw-curated view of them. +Typical scenarios: + +- Pulling raw OpenClaw session files for offline inspection or replay (`/sandbox/.openclaw/sessions//` — see [Session Storage Layout](/reference/session-storage) for the on-disk shape): + + ```console + $ nemoclaw my-assistant download /sandbox/.openclaw/sessions/main ./sessions-out/ + ``` + +- Pulling an existing trajectory export bundle that you previously created without `--save-host`: + + ```console + $ nemoclaw my-assistant download /sandbox/.openclaw/trajectory-exports/ ./trajectories/ + ``` + +- Pulling arbitrary workspace artefacts (logs, configs, generated files) from anywhere inside the sandbox. + +Higher-level subcommands such as `sessions export-trajectory --save-host` are convenience wrappers built on the same transport — reach for `download` when you want the raw files without going through OpenClaw's redaction pipeline or bundle layout. + ### `nemoclaw logs` View sandbox logs. @@ -855,7 +889,7 @@ nemoclaw my-assistant sessions export-trajectory main agent:main:main --save-hos ``` `sessions list` and `sessions cleanup` shell out to `openclaw sessions ...` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the output format and exit code. -`sessions reset` goes through `openshell sandbox exec` to `openclaw gateway call sessions.reset`, so the OpenClaw gateway performs the archive-then-recreate; the NemoClaw host never edits `sessions.json` directly. Sessions stay lock-safe because the gateway is the writer. +`sessions reset` is a thin host-side wrapper around the upstream `openclaw gateway call sessions.reset` RPC: it goes through `openshell sandbox exec` so the OpenClaw gateway still performs the archive-then-recreate, owns the write lock, and emits the lifecycle event. The NemoClaw host never edits `sessions.json` directly, and you can always invoke the upstream RPC by hand from inside the sandbox if you want to skip this wrapper. `sessions export-trajectory` runs `openclaw sessions export-trajectory --json` inside the sandbox so OpenClaw owns the redaction pipeline and the on-disk bundle shape; pass `--save-host ` to additionally copy the resolved bundle directory to the host via `openshell sandbox download`. ### `nemoclaw sessions list` @@ -873,10 +907,19 @@ OpenClaw owns the policy and reporting; NemoClaw only routes the call. ### `nemoclaw sessions reset` Reset an OpenClaw conversation session by invoking the gateway `sessions.reset` RPC. -Goes through `openshell sandbox exec` to `openclaw gateway call sessions.reset`, so the gateway owns the archival of the prior transcript (`.reset..jsonl`), the lock, and the lifecycle event emission. The NemoClaw host never edits `sessions.json`. + +The official, upstream-supported way to reset a session is the OpenClaw gateway CLI from inside the sandbox: + +```bash +openclaw gateway call sessions.reset --params '{"key":"","reason":"reset"}' --json +``` + +The upstream RPC params are a closed object: `key` (the canonical `agent::` session key) and an optional `reason` (`"reset"` or `"new"`); no separate `agent` field is accepted. The gateway parses the target agent out of the key, owns the archival of the prior transcript (`.reset..jsonl`), holds the write lock on `sessions.json`, and emits the lifecycle event. Any session reset performed outside that RPC — for example, editing `sessions.json` from the host or removing the transcript file directly — risks racing the in-sandbox writer and corrupting the store. + +This NemoClaw command is a thin host-side wrapper around that RPC. It accepts `` and `` arguments and forwards `{"key": "", "reason": ...}` verbatim to the gateway via `openshell sandbox exec -- openclaw gateway call sessions.reset --params ... --json`. The `` argument is used purely for client-side validation: the wrapper rejects the call before it reaches the gateway when `` disagrees with the agent prefix parsed from ``, so a typo in the agent id cannot silently route to a different agent's namespace. The NemoClaw host never edits `sessions.json` directly; the wrapper just spares you from typing the JSON params and `openshell sandbox exec` plumbing by hand. ```bash -nemoclaw sessions reset [--reason new|reset] +nemoclaw sessions reset [--reason new|reset] ``` | Flag | Description | @@ -889,7 +932,7 @@ Export a redacted trajectory bundle for an OpenClaw session. Runs `openclaw sessions export-trajectory --json` inside the sandbox via `openshell sandbox exec`, so OpenClaw owns the redaction pipeline and the bundle layout (`.openclaw/trajectory-exports//`). ```bash -nemoclaw sessions export-trajectory [--output ] [--workspace ] [--save-host ] [--json] +nemoclaw sessions export-trajectory [--output ] [--workspace ] [--save-host ] [--json] ``` | Flag | Description | diff --git a/docs/reference/session-storage.mdx b/docs/reference/session-storage.mdx index 8572104859..9fd3a68497 100644 --- a/docs/reference/session-storage.mdx +++ b/docs/reference/session-storage.mdx @@ -55,7 +55,7 @@ calls, and token usage live in the JSONL files above, not in the gateway log. ## Pulling Session Data Out of a Sandbox -Three host commands cover the common needs without manual `kubectl cp`, +Four host commands cover the common needs without manual `kubectl cp`, `docker cp`, or `cat` over SSH: - `nemoclaw sessions list` lists stored sessions for the agent's @@ -66,6 +66,14 @@ Three host commands cover the common needs without manual `kubectl cp`, `.openclaw/trajectory-exports//` in the sandbox workspace. - Add `--save-host ` to also copy the resolved bundle to the host via `openshell sandbox download`. +- `nemoclaw download [host-dest]` pulls a raw file + or directory straight out of the sandbox (no redaction, no bundle + layout). Use this when you want the on-disk artefacts described above + as they exist in the sandbox — for example + `nemoclaw my-assistant download /sandbox/.openclaw/sessions/main ./sessions-out/` + to copy an agent's whole session directory, or a single + `.jsonl` / `.trajectory.jsonl` / archived `.reset..` + file. See [`nemoclaw download`](/reference/commands#nemoclaw-name-download) for argument and destination semantics. `` is the canonical key as stored in `sessions.json` (`agent::`), not a session id or channel-specific shorthand. @@ -79,17 +87,25 @@ is corrupted — the host commands to know about are: - `nemoclaw sessions cleanup --enforce` runs OpenClaw's policy-based store maintenance (age, count, disk-budget cleanup). Use this for stale entries and orphan files, not for "reset this one session right now." -- `nemoclaw sessions reset ` invokes the OpenClaw - gateway `sessions.reset` RPC for that session. The gateway archives the - prior transcript as `.reset..jsonl`, rebinds the - key to a fresh `sessionId`, and emits the lifecycle event so the TUI and - any other connected clients refresh. - -NemoClaw delegates the actual reset to OpenClaw; the host never edits -`sessions.json` and is not subject to the gateway-writer race. Use -`--reason new` to register a brand-new session under the same key without -binding the prior transcript; `--reason reset` (default) preserves the -archive trail. +- `nemoclaw sessions reset ` is a host-side + wrapper that invokes the OpenClaw gateway `sessions.reset` RPC for that + session. The gateway archives the prior transcript as + `.reset..jsonl`, rebinds the key to a fresh + `sessionId`, and emits the lifecycle event so the TUI and any other + connected clients refresh. + +The upstream, supported CLI surface for resetting a session is +`openclaw gateway call sessions.reset --params '{"key":"","reason":"reset"}'` +from inside the sandbox; the upstream RPC params are a closed object +(`key`, optional `reason`) and the gateway parses the target agent out +of the key. The NemoClaw command just spares you from typing the JSON +params and `openshell sandbox exec` plumbing by hand, and uses its own +`` argument purely for client-side consistency checking against +the session-key prefix. NemoClaw delegates the actual reset to OpenClaw +— the host never edits `sessions.json` and is not subject to the +gateway-writer race. Use `--reason new` to register a brand-new session +under the same key without binding the prior transcript; `--reason reset` +(default) preserves the archive trail. ## Next Steps diff --git a/src/commands/sandbox/download.ts b/src/commands/sandbox/download.ts new file mode 100644 index 0000000000..6aa60efb8e --- /dev/null +++ b/src/commands/sandbox/download.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Args } from "@oclif/core"; + +import { downloadFromSandbox } from "../../lib/actions/sandbox/download"; +import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; +import { sandboxNameArg } from "../../lib/sandbox/snapshot-command-support"; + +export default class SandboxDownloadCommand extends NemoClawCommand { + static id = "sandbox:download"; + static strict = true; + static summary = "Download a file or directory from a sandbox to the host"; + static description = [ + "Copies a file or directory out of a running sandbox to a host destination", + "via `openshell sandbox download`. The destination directory is created if", + "missing; OpenShell decides how to lay the source path under the destination", + "(file vs directory copy follows OpenShell's semantics).", + "", + "Use this when you need raw on-disk artefacts from inside a sandbox (for", + "example, a session transcript file under `.openclaw/sessions/`, an exported", + "trajectory bundle, or any other workspace path). Higher-level subcommands", + "such as `sessions export-trajectory --save-host` build on the same", + "transport.", + ].join("\n"); + static usage = [" [host-dest]"]; + static examples = [ + "<%= config.bin %> sandbox download alpha /sandbox/.openclaw/sessions/main ./sessions-out/", + "<%= config.bin %> sandbox download alpha /sandbox/workspace/notes.md .", + ]; + static args = { + sandboxName: sandboxNameArg, + sandboxPath: Args.string({ + name: "sandbox-path", + description: "Absolute or sandbox-relative path inside the sandbox to download.", + required: true, + }), + hostDest: Args.string({ + name: "host-dest", + description: "Host destination directory (defaults to the current working directory).", + required: false, + }), + }; + static flags = {}; + + public async run(): Promise { + const { args } = await this.parse(SandboxDownloadCommand); + try { + await downloadFromSandbox(args.sandboxName, { + sandboxPath: args.sandboxPath, + dest: args.hostDest, + }); + } catch (error) { + this.failWithLines([` ${(error as Error).message}`], 1); + } + } +} diff --git a/src/lib/actions/sandbox/download.ts b/src/lib/actions/sandbox/download.ts new file mode 100644 index 0000000000..f48a947489 --- /dev/null +++ b/src/lib/actions/sandbox/download.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { runOpenshell } from "../../adapters/openshell/runtime"; +import { ensureLiveSandboxOrExit } from "./gateway-state"; + +export interface SandboxDownloadOptions { + sandboxPath: string; + dest?: string; +} + +export interface SandboxDownloadResult { + sandboxPath: string; + hostDest: string; +} + +export async function downloadFromSandbox( + sandboxName: string, + opts: SandboxDownloadOptions, +): Promise { + if (!opts.sandboxPath || opts.sandboxPath.trim().length === 0) { + console.error(" No sandbox path provided; refusing to invoke `openshell sandbox download`."); + process.exit(1); + } + const sandboxPath = opts.sandboxPath; + const hostDest = path.resolve(opts.dest && opts.dest.length > 0 ? opts.dest : "."); + fs.mkdirSync(hostDest, { recursive: true }); + + await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); + + runOpenshell(["sandbox", "download", sandboxName, sandboxPath, hostDest]); + console.error( + ` Downloaded '${sandboxPath}' from sandbox '${sandboxName}' to '${hostDest}'.`, + ); + return { sandboxPath, hostDest }; +} diff --git a/src/lib/actions/sandbox/sessions/paths.ts b/src/lib/actions/sandbox/sessions/paths.ts index a0666d9039..2f8a4b0715 100644 --- a/src/lib/actions/sandbox/sessions/paths.ts +++ b/src/lib/actions/sandbox/sessions/paths.ts @@ -25,3 +25,10 @@ export function validateSessionKey(sessionKey: string): string { } return trimmed; } + +const AGENT_SESSION_KEY_RE = /^agent:([A-Za-z0-9][A-Za-z0-9._-]{0,63}):/; + +export function parseAgentIdFromSessionKey(sessionKey: string): string | null { + const match = AGENT_SESSION_KEY_RE.exec(sessionKey); + return match ? match[1] : null; +} diff --git a/src/lib/actions/sandbox/sessions/reset.ts b/src/lib/actions/sandbox/sessions/reset.ts index 3e4239f09a..f47f9a7cbe 100644 --- a/src/lib/actions/sandbox/sessions/reset.ts +++ b/src/lib/actions/sandbox/sessions/reset.ts @@ -4,7 +4,7 @@ import { CLI_NAME } from "../../../cli/branding"; import { captureOpenshell } from "../../../adapters/openshell/runtime"; import { ensureLiveSandboxOrExit } from "../gateway-state"; -import { validateAgentId, validateSessionKey } from "./paths"; +import { parseAgentIdFromSessionKey, validateAgentId, validateSessionKey } from "./paths"; export type SessionsResetReason = "reset" | "new"; @@ -34,8 +34,18 @@ export async function resetSandboxSession( sandboxName: string, opts: SessionsResetOptions, ): Promise { - validateAgentId(opts.agent); + const agent = validateAgentId(opts.agent); const sessionKey = validateSessionKey(opts.sessionKey); + const keyAgent = parseAgentIdFromSessionKey(sessionKey); + if (keyAgent !== null && keyAgent !== agent) { + console.error( + ` Refusing to invoke sessions.reset: session key '${sessionKey}' is scoped to agent '${keyAgent}', not '${agent}'.`, + ); + console.error( + ` Either drop the '${agent}' argument or pass a session key under that agent (e.g. agent:${agent}:...).`, + ); + process.exit(1); + } const reason: SessionsResetReason = opts.reason === "new" ? "new" : "reset"; await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true }); diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index f3a2c2e66b..fb30071807 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,12 +17,12 @@ import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 69 commands", () => { + it("should contain exactly 70 commands", () => { // 28 global (22 visible + 6 hidden help/version aliases) - // 41 sandbox (35 visible + 6 hidden shields/config), including the - // sandbox:sessions group (root + list + cleanup + reset + export-trajectory) and - // sandbox skill remove. - expect(COMMANDS).toHaveLength(69); + // 42 sandbox (36 visible + 6 hidden shields/config), including the + // sandbox:sessions group (root + list + cleanup + reset + export-trajectory), + // sandbox skill remove, and sandbox download. + expect(COMMANDS).toHaveLength(70); }); it("should have no duplicate usage strings", () => { @@ -54,11 +54,11 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 41 entries", () => { - // 35 visible + 6 hidden (shields×3 + config get/set/rotate-token). - // 35 visible includes the sessions group (root + list + cleanup + reset + - // export-trajectory) and sandbox skill remove. - expect(sandboxCommands()).toHaveLength(41); + it("should return exactly 42 entries", () => { + // 36 visible + 6 hidden (shields×3 + config get/set/rotate-token). + // 36 visible includes the sessions group (root + list + cleanup + reset + + // export-trajectory), sandbox skill remove, and sandbox download. + expect(sandboxCommands()).toHaveLength(42); }); it("every entry has scope sandbox", () => { @@ -69,11 +69,12 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 12 hidden commands (57 visible)", () => { + it("should exclude 12 hidden commands (58 visible)", () => { // 6 hidden global (help, --help, -h, version, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token); visible - // totals include the sessions group and sandbox skill remove. - expect(visibleCommands()).toHaveLength(57); + // totals include the sessions group, sandbox skill remove, and + // sandbox download. + expect(visibleCommands()).toHaveLength(58); }); it("no visible command has hidden=true", () => { @@ -215,13 +216,14 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 24 unique action tokens including empty string", () => { + it("returns exactly 25 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(24); + expect(tokens).toHaveLength(25); // Must contain every first-level sandbox action plus the empty default action. const expected = new Set([ "connect", "dashboard-url", + "download", "exec", "status", "doctor", diff --git a/src/lib/cli/public-display-defaults.ts b/src/lib/cli/public-display-defaults.ts index b23236253d..9c297a2efc 100644 --- a/src/lib/cli/public-display-defaults.ts +++ b/src/lib/cli/public-display-defaults.ts @@ -233,6 +233,14 @@ const PUBLIC_DISPLAY_LAYOUT: Record = { "flags": "[--json]" } ], + "sandbox:download": [ + { + "group": "Sandbox Management", + "order": 4.6, + "description": "Download a file or directory from the sandbox to the host", + "flags": " [host-dest]" + } + ], "sandbox:exec": [ { "group": "Sandbox Management", diff --git a/src/lib/cli/public-display-sessions.ts b/src/lib/cli/public-display-sessions.ts index 03200894db..ae5033d776 100644 --- a/src/lib/cli/public-display-sessions.ts +++ b/src/lib/cli/public-display-sessions.ts @@ -32,7 +32,7 @@ export const SANDBOX_SESSIONS_DISPLAY_LAYOUT: Record [--reason new|reset]", + flags: " [--reason new|reset]", description: "Reset a session via the OpenClaw gateway", }, ], @@ -40,7 +40,7 @@ export const SANDBOX_SESSIONS_DISPLAY_LAYOUT: Record [--output ] [--workspace ] [--save-host ] [--json]", + flags: " [--output ] [--workspace ] [--save-host ] [--json]", description: "Export a redacted OpenClaw session trajectory bundle", }, ], diff --git a/test/cli.test.ts b/test/cli.test.ts index 2f4b2390e3..4b15141689 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -968,6 +968,96 @@ describe("CLI dispatch", () => { expect(calls).toMatch(/"reason":"new"/); }); + it("sandbox sessions reset forwards a non-main agent's session key to the gateway", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-nonmain-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const okPayload = JSON.stringify({ + result: { ok: true, key: "agent:hermes:main", entry: { sessionId: "hermes-id" } }, + }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ` *"openclaw gateway call sessions.reset"*) printf '%s\\n' '${okPayload}'; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "sandbox sessions reset alpha hermes agent:hermes:main 2>&1", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(0); + expect(r.out).toContain( + "Reset session 'agent:hermes:main' on agent 'hermes' via the OpenClaw gateway", + ); + const calls = fs.readFileSync(openshellLog, "utf8"); + // Upstream `sessions.reset` schema is closed (key + reason only); the + // gateway parses the agent out of the key, so the wrapper must NOT add a + // separate `agent` param or the gateway will reject with INVALID_PARAMS. + expect(calls).toMatch( + /sandbox exec --name alpha -- openclaw gateway call sessions\.reset --params \{"key":"agent:hermes:main","reason":"reset"\} --json/, + ); + expect(calls).not.toMatch(/"agent":/); + }); + + it("sandbox sessions reset refuses when disagrees with the session key's agent prefix", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-mismatch-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' *"openclaw gateway call sessions.reset"*) echo "should not be invoked" >&2; exit 99 ;;', + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "sandbox sessions reset alpha main agent:hermes:main 2>&1", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(1); + expect(r.out).toContain( + "Refusing to invoke sessions.reset: session key 'agent:hermes:main' is scoped to agent 'hermes', not 'main'.", + ); + // Wrapper short-circuits before any openshell call, so the log file is + // never created. + expect(fs.existsSync(openshellLog)).toBe(false); + }); + it("sandbox sessions reset surfaces gateway-reported errors", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-err-")); const localBin = path.join(home, "bin"); @@ -1009,6 +1099,87 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Unknown session key"); }); + it("sandbox download forwards sandbox path + host destination to openshell sandbox download", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sandbox-download-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + const hostDest = path.join(home, "downloads"); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox download alpha "*) exit 0 ;;', + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + `sandbox download alpha /sandbox/workspace/notes.md ${JSON.stringify(hostDest)} 2>&1`, + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(0); + expect(r.out).toContain( + `Downloaded '/sandbox/workspace/notes.md' from sandbox 'alpha' to '${hostDest}'.`, + ); + expect(fs.existsSync(hostDest)).toBe(true); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch( + new RegExp(`sandbox download alpha /sandbox/workspace/notes\\.md ${hostDest.replace(/\//g, "\\/")}`), + ); + }); + + it("sandbox download defaults the host destination to the current working directory", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sandbox-download-cwd-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const openshellLog = path.join(home, "openshell-calls.log"); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(openshellLog)}`, + 'case "$*" in', + ' "sandbox get alpha") printf "Name: alpha\\nPhase: Ready\\n"; exit 0 ;;', + ' "sandbox list") echo "alpha Ready"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox download alpha "*) exit 0 ;;', + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "sandbox download alpha /sandbox/workspace/notes.md 2>&1", + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_HEALTH_POLL_COUNT: "1", + NEMOCLAW_HEALTH_POLL_INTERVAL: "0", + }, + ); + + expect(r.code).toBe(0); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch(/sandbox download alpha \/sandbox\/workspace\/notes\.md /); + }); + it("list exits 0", () => { const r = run("list"); expect(r.code).toBe(0);