diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3fed58b128..1854a445b6 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -257,6 +257,34 @@ 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|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 + `sessions list`, `sessions cleanup --dry-run`, + `sessions export-trajectory --save-host`, and gateway-delegated + `sessions reset` + + 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 eeb501fe75..2b5099e075 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: "" @@ -502,6 +503,31 @@ jobs: secrets: 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,export-trajectory}` + # surface against a live sandbox: pass-through to `openclaw sessions list/cleanup`, + # 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' || + 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: false + 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 +1953,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 +2061,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 +2226,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 d1a45c5a4e..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. @@ -831,6 +865,83 @@ 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 reset [--reason new\|reset]` | Reset a single session by invoking the OpenClaw gateway `sessions.reset` RPC. | +| `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 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` 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` + +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 reset` + +Reset an OpenClaw conversation session by invoking the gateway `sessions.reset` RPC. + +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] +``` + +| Flag | Description | +|------|-------------| +| `--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 export-trajectory` + +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] +``` + +| Flag | Description | +|------|-------------| +| `--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 ` Remove an installed skill from a running sandbox by skill name. diff --git a/docs/reference/session-storage.mdx b/docs/reference/session-storage.mdx new file mode 100644 index 0000000000..9fd3a68497 --- /dev/null +++ b/docs/reference/session-storage.mdx @@ -0,0 +1,114 @@ +--- +# 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 + +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 + configured default agent (forwards to `openclaw sessions list`). +- `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`. +- `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. + +## 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 "reset this one session right now." +- `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 + +- [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/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/commands/sandbox/sessions.ts b/src/commands/sandbox/sessions.ts new file mode 100644 index 0000000000..101c7e8be1 --- /dev/null +++ b/src/commands/sandbox/sessions.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + hasSessionsPassthroughHelpToken, + printSessionsPassthroughHelp, + 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() === "" || 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 new file mode 100644 index 0000000000..1f4003a2c7 --- /dev/null +++ b/src/commands/sandbox/sessions/cleanup.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + hasSessionsPassthroughHelpToken, + printSessionsPassthroughHelp, + 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() === "" || 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/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/commands/sandbox/sessions/list.ts b/src/commands/sandbox/sessions/list.ts new file mode 100644 index 0000000000..9fcb5a1669 --- /dev/null +++ b/src/commands/sandbox/sessions/list.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + hasSessionsPassthroughHelpToken, + printSessionsPassthroughHelp, + 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() === "" || sandboxName === "--help" || sandboxName === "-h") { + printSessionsPassthroughHelp("list"); + return; + } + if (hasSessionsPassthroughHelpToken(extraArgs)) { + printSessionsPassthroughHelp("list"); + return; + } + await runSessionsPassthrough(sandboxName, { verb: "list", extraArgs }); + } +} 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/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/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/passthrough.ts b/src/lib/actions/sandbox/sessions/passthrough.ts new file mode 100644 index 0000000000..24bb9d4454 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/passthrough.ts @@ -0,0 +1,45 @@ +// 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 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 = {}, +): 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..45acac04e2 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/paths.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + validateAgentId, + validateSessionKey, +} from "../../../../../dist/lib/actions/sandbox/sessions/paths"; + +describe("session path helpers", () => { + 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/); + }); +}); diff --git a/src/lib/actions/sandbox/sessions/paths.ts b/src/lib/actions/sandbox/sessions/paths.ts new file mode 100644 index 0000000000..2f8a4b0715 --- /dev/null +++ b/src/lib/actions/sandbox/sessions/paths.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 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 new file mode 100644 index 0000000000..f47f9a7cbe --- /dev/null +++ b/src/lib/actions/sandbox/sessions/reset.ts @@ -0,0 +1,129 @@ +// 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 { parseAgentIdFromSessionKey, 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 { + 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 }); + + 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/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 85dcdc9070..fb30071807 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,12 @@ import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 64 commands", () => { + it("should contain exactly 70 commands", () => { // 28 global (22 visible + 6 hidden help/version aliases) - // 36 sandbox (30 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(64); + // 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", () => { @@ -52,9 +54,11 @@ describe("command-registry", () => { }); describe("sandboxCommands()", () => { - it("should return exactly 36 entries", () => { - // 30 visible + 6 hidden (shields×3 + config get/set/rotate-token) - expect(sandboxCommands()).toHaveLength(36); + 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", () => { @@ -65,10 +69,12 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 12 hidden commands (52 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) - expect(visibleCommands()).toHaveLength(52); + // 6 hidden sandbox (shields×3, config get/set/rotate-token); visible + // totals include the sessions group, sandbox skill remove, and + // sandbox download. + expect(visibleCommands()).toHaveLength(58); }); it("no visible command has hidden=true", () => { @@ -210,13 +216,14 @@ describe("command-registry", () => { }); describe("sandboxActionTokens()", () => { - it("returns exactly 23 unique action tokens including empty string", () => { + it("returns exactly 25 unique action tokens including empty string", () => { const tokens = sandboxActionTokens(); - expect(tokens).toHaveLength(23); + 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", @@ -228,6 +235,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 9f3c1b8127..9c297a2efc 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", @@ -240,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-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..ae5033d776 --- /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:reset": [ + { + group: "Sandbox Management", + order: 21, + flags: " [--reason new|reset]", + description: "Reset a session via the OpenClaw gateway", + }, + ], + "sandbox:sessions:export-trajectory": [ + { + group: "Sandbox Management", + order: 22, + 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 84e4d76e0c..4b15141689 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -689,6 +689,497 @@ 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 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 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"), + [ + "#!/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 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + const r = runWithEnv( + "sandbox sessions export-trajectory 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).toBe(0); + expect(r.out).toContain( + "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( + /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", () => { + 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 okPayload = JSON.stringify({ + result: { ok: true, key: "agent:main:main", entry: { sessionId: "new-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 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).toBe(0); + expect(r.out).toContain( + "Reset session 'agent:main:main' on agent 'main' via the OpenClaw gateway", + ); + const calls = fs.readFileSync(openshellLog, "utf8"); + expect(calls).toMatch( + /sandbox exec --name alpha -- openclaw gateway call sessions\.reset --params \{"key":"agent:main:main","reason":"reset"\} --json/, + ); + }); + + 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 okPayload = JSON.stringify({ + result: { ok: true, key: "agent:main:telegram:thread", entry: { sessionId: "fresh-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 main agent:main:telegram:thread --reason new 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( + "Replaced session 'agent:main:telegram:thread' on agent 'main' via the OpenClaw gateway", + ); + const calls = fs.readFileSync(openshellLog, "utf8"); + 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"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + const errPayload = JSON.stringify({ + error: { code: "INVALID_REQUEST", message: "Unknown session key" }, + }); + 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 gateway call sessions.reset"*) printf '%s\\n' '${errPayload}'; exit 0 ;;`, + " *) exit 0 ;;", + "esac", + ].join("\n"), + { mode: 0o755 }, + ); + + 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( + "Gateway refused sessions.reset for 'agent:main:missing'", + ); + expect(r.out).toContain("INVALID_REQUEST"); + 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); diff --git a/test/e2e/test-sessions-cli.sh b/test/e2e/test-sessions-cli.sh new file mode 100755 index 0000000000..11b2e23a70 --- /dev/null +++ b/test/e2e/test-sessions-cli.sh @@ -0,0 +1,220 @@ +#!/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: +# 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 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 +# `sessions/`. +# TC-SESS-05: After TC-SESS-04, `nemoclaw sessions list --json` +# still surfaces the rebound key (reset is archive-then-rebind, +# not delete). +# +# 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 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" + 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 [ -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 export-trajectory --save-host produced bundle on host" +} + +# ── 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 reset main agent:main:main exited zero" +} + +# ── 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 reset" + info "$out" + return 1 + } + 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 + pass "TC-SESS-05: sessions list --json after reset returned valid JSON" +} + +# ── Main ───────────────────────────────────────────────────────────────────── +preflight +onboard_sandbox +if seed_session; then + test_sessions_list_json + test_sessions_cleanup_dry_run + test_sessions_export_trajectory + 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)" + 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