diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index f047a41b3d4..6b7c49670e2 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1101,13 +1101,19 @@ nemohermes my-assistant sessions delete agent:main:slack:c-9 --json ### `nemohermes sessions export [keys...]` -Export the OpenClaw session JSONL from a running sandbox to the host, replacing the two-hop `docker exec kubectl cp` plus `docker cp` workaround. +Export the agent's session history from a running sandbox to the host. +The command routes by the sandbox's agent kind recorded in the registry. -The command always enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` (plus optional `.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. +For an OpenClaw sandbox, the command enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` (plus optional `.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload. With no positional keys, the command exports every session for the agent. Pass one or more keys (alias or canonical `agent::`) to filter. +For a Hermes sandbox, the command invokes the in-sandbox `hermes sessions export` against a staging path under `/sandbox/.nemoclaw-staging` (the same downloadable workspace boundary OpenClaw uses), then downloads the resulting single JSONL stream to the host. +Hermes stores session history in a SQLite database, so the command refuses positional keys, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. +`--agent` accepts only `hermes` as a no-op alias on a Hermes sandbox and rejects any other value. +The host destination defaults to `./sessions-.jsonl`; `--out` picks a different path. + ```bash nemohermes my-assistant sessions export nemohermes my-assistant sessions export main --agent main @@ -1117,15 +1123,15 @@ nemohermes my-assistant sessions export --format tar --out ./bundles/alpha.tgz - | Flag | Description | |------|-------------| -| `--agent ` | Agent id when `` are aliases rather than the canonical `agent::` form. | -| `--format ` | `dir` (default) writes a directory of session files; `tar` writes a single `.tgz` bundle for sharing/upload. | -| `--out ` | Host destination. Defaults to `./sessions-/` for `dir`, or `./sessions--.tgz` for `tar`. | -| `--include-trajectory` | Include the (large) `*.trajectory.jsonl` files in the export. Excluded by default. | +| `--agent ` | OpenClaw: agent id when `` are aliases rather than the canonical `agent::` form. Hermes: accepts only `hermes` as a no-op alias and rejects any other value. | +| `--format ` | OpenClaw only. `dir` (default) writes a directory of session files; `tar` writes a single `.tgz` bundle for sharing/upload. | +| `--out ` | Host destination. OpenClaw defaults to `./sessions-/` for `dir` or `./sessions--.tgz` for `tar`; Hermes defaults to `./sessions-.jsonl`. | +| `--include-trajectory` | OpenClaw only. Include the (large) `*.trajectory.jsonl` files in the export. Excluded by default. | | `--json` | Print the export manifest as JSON instead of a status line. | Mismatched `--agent` plus canonical-key combinations are refused before any download runs. Session keys that begin with `-` are rejected at the command boundary instead of being silently dropped. -Session JSONL can contain pasted secrets (API keys, tokens), so exported files are written owner-only (`0600`); for `tar` format the in-sandbox staging tarball is additionally created with `umask 077` and removed after the host download completes. +Session JSONL can contain pasted secrets (API keys, tokens), so exported files are written owner-only (`0600`); the in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. ### `nemohermes download [host-dest]` diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index eba825c231d..aa19c339672 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1390,13 +1390,19 @@ $$nemoclaw my-assistant sessions delete agent:main:slack:c-9 --json ### `$$nemoclaw sessions export [keys...]` -Export the OpenClaw session JSONL from a running sandbox to the host, replacing the two-hop `docker exec kubectl cp` plus `docker cp` workaround. +Export the agent's session history from a running sandbox to the host. +The command routes by the sandbox's agent kind recorded in the registry. -The command always enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` (plus optional `.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. +For an OpenClaw sandbox, the command enumerates the session store through `openclaw sessions list --agent --json` and copies only the matching `.jsonl` (plus optional `.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping. By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload. With no positional keys, the command exports every session for the agent. Pass one or more keys (alias or canonical `agent::`) to filter. +For a Hermes sandbox, the command invokes the in-sandbox `hermes sessions export` against a staging path under `/sandbox/.nemoclaw-staging` (the same downloadable workspace boundary OpenClaw uses), then downloads the resulting single JSONL stream to the host. +Hermes stores session history in a SQLite database, so the command refuses positional keys, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. +`--agent` accepts only `hermes` as a no-op alias on a Hermes sandbox and rejects any other value. +The host destination defaults to `./sessions-.jsonl`; `--out` picks a different path. + ```bash $$nemoclaw my-assistant sessions export $$nemoclaw my-assistant sessions export main --agent main @@ -1406,15 +1412,15 @@ $$nemoclaw my-assistant sessions export --format tar --out ./bundles/alpha.tgz - | Flag | Description | |------|-------------| -| `--agent ` | Agent id when `` are aliases rather than the canonical `agent::` form. | -| `--format ` | `dir` (default) writes a directory of session files; `tar` writes a single `.tgz` bundle for sharing/upload. | -| `--out ` | Host destination. Defaults to `./sessions-/` for `dir`, or `./sessions--.tgz` for `tar`. | -| `--include-trajectory` | Include the (large) `*.trajectory.jsonl` files in the export. Excluded by default. | +| `--agent ` | OpenClaw: agent id when `` are aliases rather than the canonical `agent::` form. Hermes: accepts only `hermes` as a no-op alias and rejects any other value. | +| `--format ` | OpenClaw only. `dir` (default) writes a directory of session files; `tar` writes a single `.tgz` bundle for sharing/upload. | +| `--out ` | Host destination. OpenClaw defaults to `./sessions-/` for `dir` or `./sessions--.tgz` for `tar`; Hermes defaults to `./sessions-.jsonl`. | +| `--include-trajectory` | OpenClaw only. Include the (large) `*.trajectory.jsonl` files in the export. Excluded by default. | | `--json` | Print the export manifest as JSON instead of a status line. | Mismatched `--agent` plus canonical-key combinations are refused before any download runs. Session keys that begin with `-` are rejected at the command boundary instead of being silently dropped. -Session JSONL can contain pasted secrets (API keys, tokens), so exported files are written owner-only (`0600`); for `tar` format the in-sandbox staging tarball is additionally created with `umask 077` and removed after the host download completes. +Session JSONL can contain pasted secrets (API keys, tokens), so exported files are written owner-only (`0600`); the in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. ### `$$nemoclaw download [host-dest]` diff --git a/src/commands/sandbox/sessions/export.ts b/src/commands/sandbox/sessions/export.ts index 0cd5003017b..c6a4863c928 100644 --- a/src/commands/sandbox/sessions/export.ts +++ b/src/commands/sandbox/sessions/export.ts @@ -9,19 +9,28 @@ import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command"; export default class SandboxSessionsExportCommand extends NemoClawCommand { static id = "sandbox:sessions:export"; static strict = false; - static summary = "Export OpenClaw session JSONL out of a running sandbox"; + static summary = "Export agent session JSONL out of a running sandbox"; static description = [ - "Tar the OpenClaw session store inside the sandbox and download the bundle to", - "the host via `openshell sandbox download`. By default every session for the", - "agent is exported; pass one or more positional keys to filter.", + "Routes by the sandbox's agent kind, recorded in the registry.", "", - "Keys may be either an alias (e.g. `main`, `telegram:t-1`) or the canonical", - "`agent::` form. Use --agent to scope aliases to a non-default", - "agent; mismatched --agent + canonical-key combinations are refused.", - "", - "Trajectory files are excluded by default (large) and re-added with", + "OpenClaw sandbox: tar the per-session JSONL files inside the sandbox and", + "download the bundle to the host via `openshell sandbox download`. By default", + "every session for the agent is exported; pass one or more positional keys to", + "filter. Keys may be either an alias (e.g. `main`, `telegram:t-1`) or the", + "canonical `agent::` form. Use --agent to scope aliases to a", + "non-default agent; mismatched --agent + canonical-key combinations are", + "refused. Trajectory files are excluded by default (large) and re-added with", "--include-trajectory.", "", + "Hermes sandbox: invoke the in-sandbox `hermes sessions export` against a", + "staging path under /sandbox/.nemoclaw-staging, then download the resulting", + "single JSONL stream to the host. Hermes stores the session history in a", + "SQLite database, so positional keys, --format tar, and --include-trajectory", + "are OpenClaw-only and are rejected with a clear error when the sandbox is", + "Hermes. --agent accepts only `hermes` (a no-op alias) on a Hermes sandbox", + "and rejects any other value. The host destination defaults to", + "./sessions-.jsonl; --out picks a different path.", + "", "Note: session JSONL can contain pasted secrets (API keys, tokens). The", "downloaded bundle is written owner-only (0600); keep it private and avoid", "committing or sharing it without review.", diff --git a/src/lib/actions/sandbox/sessions/export.test.ts b/src/lib/actions/sandbox/sessions/export.test.ts index 039e4a31c90..f2dc47d7101 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -15,11 +15,17 @@ vi.mock("../../../adapters/openshell/runtime", () => ({ runOpenshell: vi.fn(), })); +vi.mock("../../../state/registry", () => ({ + getSandbox: vi.fn(() => null), +})); + import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; +import * as registry from "../../../state/registry"; import { buildSandboxTarArgv, exportSandboxSessions, parseSessionIndex } from "./export"; const captureMock = captureOpenshell as unknown as ReturnType; const runMock = runOpenshell as unknown as ReturnType; +const getSandboxMock = registry.getSandbox as unknown as ReturnType; let consoleErrorSpy: ReturnType; let consoleLogSpy: ReturnType; @@ -28,6 +34,8 @@ beforeEach(() => { captureMock.mockReset(); runMock.mockReset(); runMock.mockReturnValue({ status: 0, stdout: "", stderr: "" }); + getSandboxMock.mockReset(); + getSandboxMock.mockReturnValue(null); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); }); @@ -446,3 +454,219 @@ describe("exportSandboxSessions", () => { expect(parsed).toHaveProperty("bundleBytes"); }); }); + +describe("exportSandboxSessions (hermes sandbox)", () => { + let mkdtempSpy: ReturnType; + let chmodSpy: ReturnType; + let renameSpy: ReturnType; + let rmSpy: ReturnType; + + beforeEach(() => { + mkdtempSpy = vi + .spyOn(fs, "mkdtempSync") + .mockImplementation((prefix) => `${prefix as string}stubdir`); + chmodSpy = vi.spyOn(fs, "chmodSync").mockImplementation(() => {}); + renameSpy = vi.spyOn(fs, "renameSync").mockImplementation(() => {}); + rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(() => {}); + }); + + afterEach(() => { + mkdtempSpy.mockRestore(); + chmodSpy.mockRestore(); + renameSpy.mockRestore(); + rmSpy.mockRestore(); + }); + + it("routes to `hermes sessions export` instead of `openclaw sessions list` when the registry marks the sandbox as hermes", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + + const result = await exportSandboxSessions({ sandboxName: "alpha" }); + + expect(captureMock).not.toHaveBeenCalled(); + + const execCall = runMock.mock.calls[0]?.[0] as string[]; + expect(execCall.slice(0, 7)).toEqual(["sandbox", "exec", "--name", "alpha", "--", "sh", "-c"]); + const shellCommand = execCall[7] as string; + expect(shellCommand).toMatch( + /^umask 077 && mkdir -p \/sandbox\/\.nemoclaw-staging && chmod 700 \/sandbox\/\.nemoclaw-staging && hermes sessions export \/sandbox\/\.nemoclaw-staging\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600 \/sandbox\/\.nemoclaw-staging\/sessions-export-hermes-[0-9a-f]+\.jsonl$/, + ); + + const downloadCall = runMock.mock.calls[1]?.[0] as string[]; + expect(downloadCall.slice(0, 3)).toEqual(["sandbox", "download", "alpha"]); + expect(downloadCall[3]).toMatch( + /^\/sandbox\/\.nemoclaw-staging\/sessions-export-hermes-[0-9a-f]+\.jsonl$/, + ); + const hostStagingPath = downloadCall.at(-1) as string; + expect(hostStagingPath).toContain(".sessions-export-hermes-"); + expect(hostStagingPath.endsWith("sessions-alpha.jsonl")).toBe(true); + expect(hostStagingPath).not.toBe("./sessions-alpha.jsonl"); + + const cleanupCall = runMock.mock.calls.at(-1); + expect(cleanupCall?.[0]).toContain("rm"); + expect(cleanupCall?.[0]).toContain("-f"); + + expect(chmodSpy).toHaveBeenCalledWith(hostStagingPath, 0o600); + expect(renameSpy).toHaveBeenCalledWith(hostStagingPath, "./sessions-alpha.jsonl"); + + expect(result).toMatchObject({ + sandboxName: "alpha", + agent: "hermes", + format: "jsonl", + selectedKeys: "all", + resolvedSessionIds: [], + resolvedFiles: ["sessions-alpha.jsonl"], + hostDest: "./sessions-alpha.jsonl", + sessions: [], + }); + }); + + it("honours --out for the host destination on a hermes sandbox", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + + const result = await exportSandboxSessions({ + sandboxName: "alpha", + out: "./hermes-bundle.jsonl", + }); + + const downloadCall = runMock.mock.calls[1]?.[0] as string[]; + const hostStagingPath = downloadCall.at(-1) as string; + expect(hostStagingPath).toContain(".sessions-export-hermes-"); + expect(hostStagingPath.endsWith("hermes-bundle.jsonl")).toBe(true); + expect(renameSpy).toHaveBeenCalledWith(hostStagingPath, "./hermes-bundle.jsonl"); + expect(result.hostDest).toBe("./hermes-bundle.jsonl"); + expect(result.resolvedFiles).toEqual(["hermes-bundle.jsonl"]); + }); + + it("aborts the export and skips download when the in-sandbox `hermes sessions export` exits non-zero, while still cleaning up the staging file", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + runMock.mockReturnValueOnce(makeRun(1)); + + await expect(exportSandboxSessions({ sandboxName: "alpha" })).rejects.toThrow( + /Failed to export hermes sessions/, + ); + + expect(runMock).toHaveBeenCalledTimes(2); + const execCall = runMock.mock.calls[0]?.[0] as string[]; + expect(execCall.slice(0, 3)).toEqual(["sandbox", "exec", "--name"]); + const cleanupCall = runMock.mock.calls[1]?.[0] as string[]; + expect(cleanupCall).toContain("rm"); + expect(cleanupCall).toContain("-f"); + expect(renameSpy).not.toHaveBeenCalled(); + }); + + it("cleans up the in-sandbox staging file even when the host download exits non-zero", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + runMock.mockReturnValueOnce(makeRun(0)).mockReturnValueOnce(makeRun(1)); + + await expect(exportSandboxSessions({ sandboxName: "alpha" })).rejects.toThrow( + /Failed to download/, + ); + const cleanupCall = runMock.mock.calls.at(-1); + expect(cleanupCall?.[0]).toContain("rm"); + expect(cleanupCall?.[0]).toContain("-f"); + expect(renameSpy).not.toHaveBeenCalled(); + }); + + it("fails closed when chmod on the staging file errors so a permissive host cannot end up with a world-readable session bundle", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + chmodSpy.mockImplementation(() => { + throw new Error("EPERM"); + }); + + await expect(exportSandboxSessions({ sandboxName: "alpha" })).rejects.toThrow(/EPERM/); + expect(renameSpy).not.toHaveBeenCalled(); + const cleanupCall = runMock.mock.calls.at(-1); + expect(cleanupCall?.[0]).toContain("rm"); + expect(cleanupCall?.[0]).toContain("-f"); + }); + + it("refuses non-hermes --agent values, positional keys, --include-trajectory, and --format tar on a hermes sandbox so users see a clear error rather than a silent half-export", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + await expect(exportSandboxSessions({ sandboxName: "alpha", agent: "main" })).rejects.toThrow( + /--agent main is OpenClaw-specific/, + ); + await expect(exportSandboxSessions({ sandboxName: "alpha", keys: ["main"] })).rejects.toThrow( + /positional session keys are OpenClaw-specific/, + ); + await expect( + exportSandboxSessions({ sandboxName: "alpha", includeTrajectory: true }), + ).rejects.toThrow(/--include-trajectory is OpenClaw-specific/); + await expect(exportSandboxSessions({ sandboxName: "alpha", format: "tar" })).rejects.toThrow( + /--format tar is OpenClaw-specific/, + ); + expect(runMock).not.toHaveBeenCalled(); + }); + + it("accepts `--agent hermes` as a no-op alias on a hermes sandbox and still routes to `hermes sessions export`", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + + const result = await exportSandboxSessions({ sandboxName: "alpha", agent: "hermes" }); + + expect(captureMock).not.toHaveBeenCalled(); + const execCall = runMock.mock.calls[0]?.[0] as string[]; + const shellCommand = execCall[7] as string; + expect(shellCommand).toContain("hermes sessions export"); + expect(result.agent).toBe("hermes"); + expect(result.format).toBe("jsonl"); + }); + + it("warns about a non-zero in-sandbox cleanup exit so a leftover sensitive JSONL never disappears silently from the sandbox", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + runMock + .mockReturnValueOnce(makeRun(0)) + .mockReturnValueOnce(makeRun(0)) + .mockReturnValueOnce(makeRun(2)); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + try { + const result = await exportSandboxSessions({ sandboxName: "alpha" }); + expect(result.agent).toBe("hermes"); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching( + /failed to remove in-sandbox staging file '\/sandbox\/\.nemoclaw-staging\/sessions-export-hermes-[0-9a-f]+\.jsonl'.*sandbox 'alpha'.*exit 2/, + ), + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it("warns about a non-zero in-sandbox cleanup exit without masking the primary export error", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + runMock.mockReturnValueOnce(makeRun(1)).mockReturnValueOnce(makeRun(3)); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + try { + await expect(exportSandboxSessions({ sandboxName: "alpha" })).rejects.toThrow( + /Failed to export hermes sessions/, + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching(/failed to remove in-sandbox staging file.*exit 3/), + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it("warns with the local staging directory when host cleanup fails after a finalization error so a leftover JSONL with pasted secrets does not vanish silently", async () => { + getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); + chmodSpy.mockImplementation(() => { + throw new Error("EPERM"); + }); + rmSpy.mockImplementation(() => { + throw new Error("EACCES"); + }); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + try { + await expect(exportSandboxSessions({ sandboxName: "alpha" })).rejects.toThrow(/EPERM/); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching( + /failed to remove local staging directory '.*\.sessions-export-hermes-.*'.*EACCES/, + ), + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); +}); diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index afec3990d13..b1ff186d081 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -43,6 +43,7 @@ import fs from "node:fs"; import path from "node:path"; import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime"; import { CLI_NAME } from "../../../cli/branding"; +import * as registry from "../../../state/registry"; import { ensureLiveSandboxOrExit } from "../gateway-state"; import { resolveHostPathFromCwd } from "../host-path"; import { @@ -52,7 +53,7 @@ import { validateSessionKey, } from "./paths"; -export type SessionsExportFormat = "dir" | "tar"; +export type SessionsExportFormat = "dir" | "tar" | "jsonl"; export interface SessionsExportOptions { sandboxName: string; @@ -107,6 +108,9 @@ const STAGING_DIR_IN_SANDBOX = "/sandbox/.nemoclaw-staging"; export async function exportSandboxSessions( opts: SessionsExportOptions, ): Promise { + if (registry.getSandbox(opts.sandboxName)?.agent === "hermes") { + return exportHermesSessions(opts); + } const agent = resolveAgentId(opts); const trimmedKeys = (opts.keys ?? []).map((value) => validateSessionKey(value)); enforceAgentScope(agent, trimmedKeys); @@ -259,6 +263,176 @@ export async function exportSandboxSessions( return result; } +// Scope boundary for `nemoclaw sessions export` on a Hermes sandbox: +// +// - Invalid state addressed: Hermes owns its session store as an in-sandbox +// SQLite database that is not directly reachable from the host. Exporting +// it therefore requires a two-hop orchestration (in-sandbox export, then +// host-side download), the same shape as the OpenClaw path above but with +// a different upstream CLI and on-disk contract. +// - Source boundary: +// * NemoClaw side (this helper): pick a unique in-sandbox staging path +// under `/sandbox/.nemoclaw-staging`, run `hermes sessions export` +// under a `umask 077` + `chmod 600` envelope, download the staged +// JSONL via `openshell sandbox download`, finalise it onto the host +// destination via atomic chmod-then-rename, and best-effort clean up +// the in-sandbox staging file. The cleanup result is captured and +// surfaced as a warning when non-zero so a sensitive session JSONL +// is never left behind in the sandbox without telling the user. +// * Hermes side (upstream `hermes` CLI staged under `agents/hermes/`): +// owns the SQLite session store and the `hermes sessions export +// ` contract that emits a single JSONL stream. NemoClaw never +// reads or rewrites that store and only invokes the upstream CLI; +// changing the store layout or the export shape are upstream +// concerns. +// - Source-fix constraint: `openshell sandbox download` refuses any source +// path outside `/sandbox`, so the staging file must live under +// `/sandbox/.nemoclaw-staging` (the same hidden, NemoClaw-owned prefix +// the OpenClaw path uses). NemoClaw cannot read the Hermes SQLite +// database directly from the host, so the two-hop orchestration is the +// only safe option until Hermes exposes a host-reachable export RPC. +// - Regression-test coverage: +// * Host-side: `export.test.ts > exportSandboxSessions (hermes sandbox)` +// covers the `hermes sessions export` route, the +// `/sandbox/.nemoclaw-staging/sessions-export-hermes-.jsonl` +// path shape, atomic chmod-then-rename finalisation, the +// `--agent hermes` no-op alias, refusal of OpenClaw-only options, +// and the remote cleanup warning on a non-zero `rm -f` exit. +// * E2E (stub openshell): `test/sandbox-sessions-export-cli.test.ts` +// exercises the dispatch through the public CLI with a fake +// openshell binary, proving the `exec hermes sessions export`, +// `download`, and `exec rm` wire calls happen in the expected order. +// - Removal condition: this Hermes branch can be removed when Hermes +// exposes a host-reachable export RPC (or NemoClaw is granted a stable +// contract for the SQLite store layout), making the two-hop in-sandbox +// staging + download orchestration unnecessary. +async function exportHermesSessions(opts: SessionsExportOptions): Promise { + rejectOpenClawOnlyOptions(opts); + await ensureLiveSandboxOrExit(opts.sandboxName, { allowNonReadyPhase: true }); + + const hostDest = resolveHermesHostDestination(opts.out, opts.sandboxName); + const stagingRemote = hermesStagingPath(); + const shellCommand = buildHermesShellInvocation(stagingRemote); + + const absoluteHostDest = path.resolve(hostDest); + const hostStagingDir = fs.mkdtempSync( + path.join(path.dirname(absoluteHostDest), ".sessions-export-hermes-"), + ); + const hostStagingPath = path.join(hostStagingDir, path.basename(absoluteHostDest)); + + try { + const exportResult = runOpenshell( + ["sandbox", "exec", "--name", opts.sandboxName, "--", "sh", "-c", shellCommand], + { ignoreError: true, stdio: "inherit" }, + ); + if (exportResult.status !== 0) { + throw new Error( + `Failed to export hermes sessions in sandbox '${opts.sandboxName}' (exit ${exportResult.status}). Verify the sandbox is live with \`${CLI_NAME} ${opts.sandboxName} status\`.`, + ); + } + + const downloadResult = runOpenshell( + ["sandbox", "download", opts.sandboxName, stagingRemote, hostStagingPath], + { ignoreError: true, stdio: "inherit" }, + ); + if (downloadResult.status !== 0) { + throw new Error( + `Failed to download '${stagingRemote}' from sandbox '${opts.sandboxName}' (exit ${downloadResult.status}).`, + ); + } + + fs.chmodSync(hostStagingPath, 0o600); + fs.renameSync(hostStagingPath, hostDest); + } finally { + // Best-effort cleanup of the in-sandbox staging JSONL. The host throw (if + // any) is already in flight, so a console.warn here cannot mask it — the + // primary error still propagates once the `finally` block returns. + const remoteCleanup = runOpenshell( + ["sandbox", "exec", "--name", opts.sandboxName, "--", "rm", "-f", stagingRemote], + { ignoreError: true, stdio: "ignore" }, + ); + if (remoteCleanup.status !== 0) { + console.warn( + ` Warning: failed to remove in-sandbox staging file '${stagingRemote}' from sandbox '${opts.sandboxName}' (exit ${remoteCleanup.status}). The file may still contain a session JSONL with pasted secrets; remove it manually with \`${CLI_NAME} sandbox exec --name ${opts.sandboxName} -- rm -f ${stagingRemote}\`.`, + ); + } + try { + fs.rmSync(hostStagingDir, { recursive: true, force: true }); + } catch (cleanupErr) { + console.warn( + ` Warning: failed to remove local staging directory '${hostStagingDir}': ${(cleanupErr as Error).message}. The directory may still contain a session JSONL with pasted secrets; remove it manually.`, + ); + } + } + + let bundleBytes: number | null = null; + try { + bundleBytes = fs.statSync(hostDest).size; + } catch { + bundleBytes = null; + } + + const result: SessionsExportResult = { + sandboxName: opts.sandboxName, + agent: "hermes", + format: "jsonl", + selectedKeys: "all", + resolvedSessionIds: [], + resolvedFiles: [path.basename(hostDest)], + hostDest, + bundleBytes, + sessions: [], + }; + + if (opts.json) { + console.log(JSON.stringify(result)); + } else { + const sizeNote = bundleBytes !== null ? ` (${bundleBytes} byte(s))` : ""; + console.error(` Exported hermes sessions to ${hostDest}${sizeNote}`); + } + + return result; +} + +function rejectOpenClawOnlyOptions(opts: SessionsExportOptions): void { + if (opts.agent && opts.agent !== "hermes") { + throw new Error( + `Refusing to export: --agent ${opts.agent} is OpenClaw-specific and is not supported on a Hermes sandbox. Pass --agent hermes or omit the flag.`, + ); + } + if (opts.keys && opts.keys.length > 0) { + throw new Error( + "Refusing to export: positional session keys are OpenClaw-specific. A Hermes sandbox exports the full session store as a single JSONL.", + ); + } + if (opts.includeTrajectory) { + throw new Error( + "Refusing to export: --include-trajectory is OpenClaw-specific. Hermes has no separate trajectory files.", + ); + } + if (opts.format === "tar") { + throw new Error( + "Refusing to export: --format tar is OpenClaw-specific. Hermes export is a single JSONL stream.", + ); + } +} + +function hermesStagingPath(): string { + const suffix = randomBytes(6).toString("hex"); + return `${STAGING_DIR_IN_SANDBOX}/sessions-export-hermes-${suffix}.jsonl`; +} + +function buildHermesShellInvocation(stagingRemote: string): string { + const quotedStaging = shellQuote(stagingRemote); + const quotedStagingDir = shellQuote(STAGING_DIR_IN_SANDBOX); + return `umask 077 && mkdir -p ${quotedStagingDir} && chmod 700 ${quotedStagingDir} && hermes sessions export ${quotedStaging} && chmod 600 ${quotedStaging}`; +} + +function resolveHermesHostDestination(out: string | undefined, sandboxName: string): string { + if (out && out.trim()) return out.trim(); + return `./sessions-${sandboxName}.jsonl`; +} + // Restrict a freshly written host artefact to owner-only (0600). Best-effort: // session JSONL can contain pasted secrets, but a chmod failure (e.g. an exotic // host filesystem) should warn rather than abort an otherwise-successful export. diff --git a/src/lib/cli/public-display-sessions.ts b/src/lib/cli/public-display-sessions.ts index a66251bf091..86ab3110e86 100644 --- a/src/lib/cli/public-display-sessions.ts +++ b/src/lib/cli/public-display-sessions.ts @@ -40,8 +40,9 @@ export const SANDBOX_SESSIONS_DISPLAY_LAYOUT: Record] [--out ] [--include-trajectory] [--json]", - description: "Export OpenClaw session JSONL out of a running sandbox", + flags: + "[keys...] [--agent ] [--format ] [--out ] [--include-trajectory] [--json]", + description: "Export agent session JSONL out of a running sandbox", }, ], }; diff --git a/test/sandbox-sessions-export-cli.test.ts b/test/sandbox-sessions-export-cli.test.ts index 0e289ff5842..04f1cc16b49 100644 --- a/test/sandbox-sessions-export-cli.test.ts +++ b/test/sandbox-sessions-export-cli.test.ts @@ -242,6 +242,60 @@ describe("sandbox sessions export CLI", () => { } }); + it("routes a hermes sandbox to `hermes sessions export` instead of `openclaw sessions list`", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-hermes-")); + try { + writeSandboxRegistry(home, "alpha", { agent: "hermes" }); + const openshellLog = path.join(home, "openshell-calls.log"); + const localBin = buildStubOpenshell(home, openshellLog, "[]"); + + const out = path.join(home, "hermes-sessions.jsonl"); + const result = runWithEnv(`alpha sessions export --out ${out} --json 2>&1`, { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + expect(result.code).toBe(0); + + const calls = fs.readFileSync(openshellLog, "utf8").split("\n"); + const listLine = calls.find((line) => line.includes("openclaw sessions list")); + const hermesExportLine = calls.find( + (line) => line.includes("-- sh -c") && line.includes("hermes sessions export"), + ); + const downloadLine = calls.find((line) => line.startsWith("sandbox download")); + const cleanupLine = calls.find((line) => line.includes("-- rm -f")); + expect(listLine).toBeUndefined(); + expect(hermesExportLine).toBeDefined(); + expect(hermesExportLine).toMatch( + /umask 077 && mkdir -p \/sandbox\/\.nemoclaw-staging && chmod 700 \/sandbox\/\.nemoclaw-staging && hermes sessions export \/sandbox\/\.nemoclaw-staging\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600/, + ); + expect(downloadLine).toContain("alpha"); + expect(downloadLine).toMatch( + /sandbox download alpha \/sandbox\/\.nemoclaw-staging\/sessions-export-hermes-[0-9a-f]+\.jsonl/, + ); + const escapedHome = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + expect(downloadLine).toMatch( + new RegExp(`${escapedHome}/\\.sessions-export-hermes-[^/]+/hermes-sessions\\.jsonl`), + ); + expect(cleanupLine).toBeDefined(); + expect(cleanupLine).toContain("/sandbox/.nemoclaw-staging/sessions-export-hermes-"); + + expect(fs.existsSync(out)).toBe(true); + expect(fs.readFileSync(out, "utf8")).toBe("session-data"); + + const manifest = JSON.parse(result.out.trim().split("\n").at(-1) as string); + expect(manifest).toMatchObject({ + sandboxName: "alpha", + agent: "hermes", + format: "jsonl", + selectedKeys: "all", + hostDest: out, + resolvedFiles: ["hermes-sessions.jsonl"], + }); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it("rejects positional keys that start with '-' instead of silently exporting all sessions", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-export-stray-")); try {