From b859a615910e25f39ba7239589786dae504e71cd Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 17 Jun 2026 02:58:06 +0000 Subject: [PATCH 1/5] fix(sandbox/sessions): route export by agent kind Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 19 +-- docs/reference/commands.mdx | 19 +-- src/commands/sandbox/sessions/export.ts | 25 ++-- .../actions/sandbox/sessions/export.test.ts | 93 +++++++++++++++ src/lib/actions/sandbox/sessions/export.ts | 112 +++++++++++++++++- test/sandbox-sessions-export-cli.test.ts | 45 +++++++ 6 files changed, 289 insertions(+), 24 deletions(-) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 0f60d3464f9..89cc8708505 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1093,13 +1093,18 @@ 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. +**OpenClaw sandbox.** 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. +**Hermes sandbox.** Invokes the in-sandbox `hermes sessions export` against a staging path, then downloads the resulting single JSONL stream to the host. +Hermes stores session history in a SQLite database, so positional keys, `--agent`, `--format tar`, and `--include-trajectory` are OpenClaw-only and are refused with a clear error when the sandbox is Hermes. +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 @@ -1109,15 +1114,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 only. Agent id when `` are aliases rather than the canonical `agent::` form. | +| `--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 268a2d55714..055bd06f6dd 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1349,13 +1349,18 @@ $$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. +**OpenClaw sandbox.** 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. +**Hermes sandbox.** Invokes the in-sandbox `hermes sessions export` against a staging path, then downloads the resulting single JSONL stream to the host. +Hermes stores session history in a SQLite database, so positional keys, `--agent`, `--format tar`, and `--include-trajectory` are OpenClaw-only and are refused with a clear error when the sandbox is Hermes. +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 @@ -1365,15 +1370,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 only. Agent id when `` are aliases rather than the canonical `agent::` form. | +| `--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..7ae9c43afc4 100644 --- a/src/commands/sandbox/sessions/export.ts +++ b/src/commands/sandbox/sessions/export.ts @@ -9,19 +9,26 @@ 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, then download the resulting single JSONL stream to the host.", + "Hermes stores the session history in a SQLite database, so positional keys,", + "--agent, --format tar, and --include-trajectory are OpenClaw-only and are", + "rejected with a clear error when the sandbox is Hermes. 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 27ee2d182ce..d7b6c284393 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -14,11 +14,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; @@ -27,6 +33,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); }); @@ -419,3 +427,88 @@ describe("exportSandboxSessions", () => { expect(parsed).toHaveProperty("bundleBytes"); }); }); + +describe("exportSandboxSessions (hermes sandbox)", () => { + 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 chmodSpy = vi.spyOn(fs, "chmodSync").mockImplementation(() => {}); + + 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 && hermes sessions export \/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600 \/tmp\/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(/^\/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl$/); + expect(downloadCall.at(-1)).toBe("./sessions-alpha.jsonl"); + + const cleanupCall = runMock.mock.calls.at(-1); + expect(cleanupCall?.[0]).toContain("rm"); + expect(cleanupCall?.[0]).toContain("-f"); + + expect(chmodSpy).toHaveBeenCalledWith("./sessions-alpha.jsonl", 0o600); + chmodSpy.mockRestore(); + + 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" }); + vi.spyOn(fs, "chmodSync").mockImplementation(() => {}); + + const result = await exportSandboxSessions({ + sandboxName: "alpha", + out: "./hermes-bundle.jsonl", + }); + + const downloadCall = runMock.mock.calls[1]?.[0] as string[]; + expect(downloadCall.at(-1)).toBe("./hermes-bundle.jsonl"); + expect(result.hostDest).toBe("./hermes-bundle.jsonl"); + expect(result.resolvedFiles).toEqual(["hermes-bundle.jsonl"]); + }); + + 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"); + }); + + it("refuses OpenClaw-only flags 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 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(); + }); +}); diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index c0efabe2781..b7ae8a74719 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 { DEFAULT_AGENT_ID, @@ -51,7 +52,7 @@ import { validateSessionKey, } from "./paths"; -export type SessionsExportFormat = "dir" | "tar"; +export type SessionsExportFormat = "dir" | "tar" | "jsonl"; export interface SessionsExportOptions { sandboxName: string; @@ -98,6 +99,9 @@ const SAFE_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; 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); @@ -250,6 +254,112 @@ export async function exportSandboxSessions( return result; } +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); + + 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, hostDest], + { ignoreError: true, stdio: "inherit" }, + ); + if (downloadResult.status !== 0) { + throw new Error( + `Failed to download '${stagingRemote}' from sandbox '${opts.sandboxName}' (exit ${downloadResult.status}).`, + ); + } + } finally { + runOpenshell( + ["sandbox", "exec", "--name", opts.sandboxName, "--", "rm", "-f", stagingRemote], + { ignoreError: true, stdio: "ignore" }, + ); + } + + hardenPermissions(hostDest); + + 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) { + throw new Error( + "Refusing to export: --agent is OpenClaw-specific and is not supported on a Hermes sandbox.", + ); + } + 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 `/tmp/sessions-export-hermes-${suffix}.jsonl`; +} + +function buildHermesShellInvocation(stagingRemote: string): string { + const quotedStaging = shellQuote(stagingRemote); + return `umask 077 && 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/test/sandbox-sessions-export-cli.test.ts b/test/sandbox-sessions-export-cli.test.ts index fef7b85a7c6..960d5394755 100644 --- a/test/sandbox-sessions-export-cli.test.ts +++ b/test/sandbox-sessions-export-cli.test.ts @@ -242,6 +242,51 @@ 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 && hermes sessions export \/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600/, + ); + expect(downloadLine).toContain("alpha"); + expect(downloadLine).toContain(out); + expect(cleanupLine).toBeDefined(); + expect(cleanupLine).toContain("/tmp/sessions-export-hermes-"); + + 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 { From c42b88d2899c9c875354a19b57f3919625d58436 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 17 Jun 2026 06:58:23 +0000 Subject: [PATCH 2/5] fix(sandbox/sessions): atomic 0600 hermes export + agent-neutral display Signed-off-by: Tinson Lai --- .../actions/sandbox/sessions/export.test.ts | 86 +++++++++++++++---- src/lib/actions/sandbox/sessions/export.ts | 28 +++--- src/lib/cli/public-display-sessions.ts | 4 +- test/sandbox-sessions-export-cli.test.ts | 7 +- 4 files changed, 97 insertions(+), 28 deletions(-) diff --git a/src/lib/actions/sandbox/sessions/export.test.ts b/src/lib/actions/sandbox/sessions/export.test.ts index d7b6c284393..dfa7885312e 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -429,9 +429,29 @@ describe("exportSandboxSessions", () => { }); 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 chmodSpy = vi.spyOn(fs, "chmodSync").mockImplementation(() => {}); const result = await exportSandboxSessions({ sandboxName: "alpha" }); @@ -447,14 +467,17 @@ describe("exportSandboxSessions (hermes sandbox)", () => { const downloadCall = runMock.mock.calls[1]?.[0] as string[]; expect(downloadCall.slice(0, 3)).toEqual(["sandbox", "download", "alpha"]); expect(downloadCall[3]).toMatch(/^\/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl$/); - expect(downloadCall.at(-1)).toBe("./sessions-alpha.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("./sessions-alpha.jsonl", 0o600); - chmodSpy.mockRestore(); + expect(chmodSpy).toHaveBeenCalledWith(hostStagingPath, 0o600); + expect(renameSpy).toHaveBeenCalledWith(hostStagingPath, "./sessions-alpha.jsonl"); expect(result).toMatchObject({ sandboxName: "alpha", @@ -470,7 +493,6 @@ describe("exportSandboxSessions (hermes sandbox)", () => { it("honours --out for the host destination on a hermes sandbox", async () => { getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" }); - vi.spyOn(fs, "chmodSync").mockImplementation(() => {}); const result = await exportSandboxSessions({ sandboxName: "alpha", @@ -478,11 +500,31 @@ describe("exportSandboxSessions (hermes sandbox)", () => { }); const downloadCall = runMock.mock.calls[1]?.[0] as string[]; - expect(downloadCall.at(-1)).toBe("./hermes-bundle.jsonl"); + 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)); @@ -493,22 +535,36 @@ describe("exportSandboxSessions (hermes sandbox)", () => { 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 OpenClaw-only flags 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 is OpenClaw-specific/); - await expect( - exportSandboxSessions({ sandboxName: "alpha", keys: ["main"] }), - ).rejects.toThrow(/positional session keys are OpenClaw-specific/); + await expect(exportSandboxSessions({ sandboxName: "alpha", agent: "main" })).rejects.toThrow( + /--agent 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/); + await expect(exportSandboxSessions({ sandboxName: "alpha", format: "tar" })).rejects.toThrow( + /--format tar is OpenClaw-specific/, + ); expect(runMock).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index b7ae8a74719..403c082dfae 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -254,9 +254,7 @@ export async function exportSandboxSessions( return result; } -async function exportHermesSessions( - opts: SessionsExportOptions, -): Promise { +async function exportHermesSessions(opts: SessionsExportOptions): Promise { rejectOpenClawOnlyOptions(opts); await ensureLiveSandboxOrExit(opts.sandboxName, { allowNonReadyPhase: true }); @@ -264,6 +262,12 @@ async function exportHermesSessions( 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], @@ -276,7 +280,7 @@ async function exportHermesSessions( } const downloadResult = runOpenshell( - ["sandbox", "download", opts.sandboxName, stagingRemote, hostDest], + ["sandbox", "download", opts.sandboxName, stagingRemote, hostStagingPath], { ignoreError: true, stdio: "inherit" }, ); if (downloadResult.status !== 0) { @@ -284,15 +288,19 @@ async function exportHermesSessions( `Failed to download '${stagingRemote}' from sandbox '${opts.sandboxName}' (exit ${downloadResult.status}).`, ); } + + fs.chmodSync(hostStagingPath, 0o600); + fs.renameSync(hostStagingPath, hostDest); } finally { - runOpenshell( - ["sandbox", "exec", "--name", opts.sandboxName, "--", "rm", "-f", stagingRemote], - { ignoreError: true, stdio: "ignore" }, - ); + runOpenshell(["sandbox", "exec", "--name", opts.sandboxName, "--", "rm", "-f", stagingRemote], { + ignoreError: true, + stdio: "ignore", + }); + try { + fs.rmSync(hostStagingDir, { recursive: true, force: true }); + } catch {} } - hardenPermissions(hostDest); - let bundleBytes: number | null = null; try { bundleBytes = fs.statSync(hostDest).size; diff --git a/src/lib/cli/public-display-sessions.ts b/src/lib/cli/public-display-sessions.ts index a66251bf091..c4869181bf5 100644 --- a/src/lib/cli/public-display-sessions.ts +++ b/src/lib/cli/public-display-sessions.ts @@ -40,8 +40,8 @@ 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 960d5394755..53c8e7d5636 100644 --- a/test/sandbox-sessions-export-cli.test.ts +++ b/test/sandbox-sessions-export-cli.test.ts @@ -269,10 +269,15 @@ describe("sandbox sessions export CLI", () => { /umask 077 && hermes sessions export \/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600/, ); expect(downloadLine).toContain("alpha"); - expect(downloadLine).toContain(out); + expect(downloadLine).toMatch( + new RegExp(`${home}/\\.sessions-export-hermes-[^/]+/hermes-sessions\\.jsonl`), + ); expect(cleanupLine).toBeDefined(); expect(cleanupLine).toContain("/tmp/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", From 698df35ea3bf09f4857d8b09930b1ffa7fbf8718 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 17 Jun 2026 07:44:40 +0000 Subject: [PATCH 3/5] fix(sandbox/sessions): biome wrap + de-bold export docs + safe regex Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 6 +++--- docs/reference/commands.mdx | 6 +++--- src/lib/cli/public-display-sessions.ts | 3 ++- test/sandbox-sessions-export-cli.test.ts | 3 ++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 89cc8708505..e9237b3757b 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1096,13 +1096,13 @@ nemohermes my-assistant sessions delete agent:main:slack:c-9 --json 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. -**OpenClaw sandbox.** 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. -**Hermes sandbox.** Invokes the in-sandbox `hermes sessions export` against a staging path, then downloads the resulting single JSONL stream to the host. -Hermes stores session history in a SQLite database, so positional keys, `--agent`, `--format tar`, and `--include-trajectory` are OpenClaw-only and are refused with a clear error when the sandbox is Hermes. +For a Hermes sandbox, the command invokes the in-sandbox `hermes sessions export` against a staging path, then downloads the resulting single JSONL stream to the host. +Hermes stores session history in a SQLite database, so the command refuses positional keys, `--agent`, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. The host destination defaults to `./sessions-.jsonl`; `--out` picks a different path. ```bash diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 055bd06f6dd..90d60f778c2 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1352,13 +1352,13 @@ $$nemoclaw my-assistant sessions delete agent:main:slack:c-9 --json 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. -**OpenClaw sandbox.** 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. -**Hermes sandbox.** Invokes the in-sandbox `hermes sessions export` against a staging path, then downloads the resulting single JSONL stream to the host. -Hermes stores session history in a SQLite database, so positional keys, `--agent`, `--format tar`, and `--include-trajectory` are OpenClaw-only and are refused with a clear error when the sandbox is Hermes. +For a Hermes sandbox, the command invokes the in-sandbox `hermes sessions export` against a staging path, then downloads the resulting single JSONL stream to the host. +Hermes stores session history in a SQLite database, so the command refuses positional keys, `--agent`, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. The host destination defaults to `./sessions-.jsonl`; `--out` picks a different path. ```bash diff --git a/src/lib/cli/public-display-sessions.ts b/src/lib/cli/public-display-sessions.ts index c4869181bf5..86ab3110e86 100644 --- a/src/lib/cli/public-display-sessions.ts +++ b/src/lib/cli/public-display-sessions.ts @@ -40,7 +40,8 @@ export const SANDBOX_SESSIONS_DISPLAY_LAYOUT: Record] [--format ] [--out ] [--include-trajectory] [--json]", + 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 53c8e7d5636..215298fb6cf 100644 --- a/test/sandbox-sessions-export-cli.test.ts +++ b/test/sandbox-sessions-export-cli.test.ts @@ -269,8 +269,9 @@ describe("sandbox sessions export CLI", () => { /umask 077 && hermes sessions export \/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600/, ); expect(downloadLine).toContain("alpha"); + const escapedHome = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); expect(downloadLine).toMatch( - new RegExp(`${home}/\\.sessions-export-hermes-[^/]+/hermes-sessions\\.jsonl`), + new RegExp(`${escapedHome}/\\.sessions-export-hermes-[^/]+/hermes-sessions\\.jsonl`), ); expect(cleanupLine).toBeDefined(); expect(cleanupLine).toContain("/tmp/sessions-export-hermes-"); From 7f38d0c250b8b6a98ebcd12f9a268e7062547a0a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 23 Jun 2026 09:25:42 +0000 Subject: [PATCH 4/5] fix(sandbox/sessions): hermes export stages under /sandbox/.nemoclaw-staging and accepts --agent hermes Signed-off-by: Tinson Lai --- docs/reference/commands-nemohermes.mdx | 7 +-- docs/reference/commands.mdx | 7 +-- src/commands/sandbox/sessions/export.ts | 12 ++--- .../actions/sandbox/sessions/export.test.ts | 45 +++++++++++++++++-- src/lib/actions/sandbox/sessions/export.ts | 15 ++++--- test/sandbox-sessions-export-cli.test.ts | 7 ++- 6 files changed, 71 insertions(+), 22 deletions(-) diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 29195f32ca7..6b7c49670e2 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -1109,8 +1109,9 @@ By default it writes a browsable directory of session files (`dir` format); pass 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, then downloads the resulting single JSONL stream to the host. -Hermes stores session history in a SQLite database, so the command refuses positional keys, `--agent`, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. +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 @@ -1122,7 +1123,7 @@ nemohermes my-assistant sessions export --format tar --out ./bundles/alpha.tgz - | Flag | Description | |------|-------------| -| `--agent ` | OpenClaw only. Agent id when `` are aliases rather than the canonical `agent::` form. | +| `--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. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index dd3e0f08159..aa19c339672 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1398,8 +1398,9 @@ By default it writes a browsable directory of session files (`dir` format); pass 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, then downloads the resulting single JSONL stream to the host. -Hermes stores session history in a SQLite database, so the command refuses positional keys, `--agent`, `--format tar`, and `--include-trajectory` with a clear error when the sandbox is Hermes. +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 @@ -1411,7 +1412,7 @@ $$nemoclaw my-assistant sessions export --format tar --out ./bundles/alpha.tgz - | Flag | Description | |------|-------------| -| `--agent ` | OpenClaw only. Agent id when `` are aliases rather than the canonical `agent::` form. | +| `--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. | diff --git a/src/commands/sandbox/sessions/export.ts b/src/commands/sandbox/sessions/export.ts index 7ae9c43afc4..c6a4863c928 100644 --- a/src/commands/sandbox/sessions/export.ts +++ b/src/commands/sandbox/sessions/export.ts @@ -23,11 +23,13 @@ export default class SandboxSessionsExportCommand extends NemoClawCommand { "--include-trajectory.", "", "Hermes sandbox: invoke the in-sandbox `hermes sessions export` against a", - "staging path, then download the resulting single JSONL stream to the host.", - "Hermes stores the session history in a SQLite database, so positional keys,", - "--agent, --format tar, and --include-trajectory are OpenClaw-only and are", - "rejected with a clear error when the sandbox is Hermes. The host destination", - "defaults to ./sessions-.jsonl; --out picks a different path.", + "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", diff --git a/src/lib/actions/sandbox/sessions/export.test.ts b/src/lib/actions/sandbox/sessions/export.test.ts index 2c7b5b3d7e3..1100928b892 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -488,12 +488,14 @@ describe("exportSandboxSessions (hermes sandbox)", () => { expect(execCall.slice(0, 7)).toEqual(["sandbox", "exec", "--name", "alpha", "--", "sh", "-c"]); const shellCommand = execCall[7] as string; expect(shellCommand).toMatch( - /^umask 077 && hermes sessions export \/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600 \/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl$/, + /^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(/^\/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl$/); + 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); @@ -578,10 +580,10 @@ describe("exportSandboxSessions (hermes sandbox)", () => { expect(cleanupCall?.[0]).toContain("-f"); }); - it("refuses OpenClaw-only flags on a hermes sandbox so users see a clear error rather than a silent half-export", async () => { + 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 is OpenClaw-specific/, + /--agent main is OpenClaw-specific/, ); await expect(exportSandboxSessions({ sandboxName: "alpha", keys: ["main"] })).rejects.toThrow( /positional session keys are OpenClaw-specific/, @@ -594,4 +596,39 @@ describe("exportSandboxSessions (hermes sandbox)", () => { ); 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 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 28f7898d919..68fefa7f373 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -307,7 +307,11 @@ async function exportHermesSessions(opts: SessionsExportOptions): Promise 0) { @@ -364,12 +368,13 @@ function rejectOpenClawOnlyOptions(opts: SessionsExportOptions): void { function hermesStagingPath(): string { const suffix = randomBytes(6).toString("hex"); - return `/tmp/sessions-export-hermes-${suffix}.jsonl`; + return `${STAGING_DIR_IN_SANDBOX}/sessions-export-hermes-${suffix}.jsonl`; } function buildHermesShellInvocation(stagingRemote: string): string { const quotedStaging = shellQuote(stagingRemote); - return `umask 077 && hermes sessions export ${quotedStaging} && chmod 600 ${quotedStaging}`; + 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 { diff --git a/test/sandbox-sessions-export-cli.test.ts b/test/sandbox-sessions-export-cli.test.ts index 87bc111ca29..04f1cc16b49 100644 --- a/test/sandbox-sessions-export-cli.test.ts +++ b/test/sandbox-sessions-export-cli.test.ts @@ -266,15 +266,18 @@ describe("sandbox sessions export CLI", () => { expect(listLine).toBeUndefined(); expect(hermesExportLine).toBeDefined(); expect(hermesExportLine).toMatch( - /umask 077 && hermes sessions export \/tmp\/sessions-export-hermes-[0-9a-f]+\.jsonl && chmod 600/, + /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("/tmp/sessions-export-hermes-"); + expect(cleanupLine).toContain("/sandbox/.nemoclaw-staging/sessions-export-hermes-"); expect(fs.existsSync(out)).toBe(true); expect(fs.readFileSync(out, "utf8")).toBe("session-data"); From 26915c5a687ac702f7eb2cbefcab0b1188bafa8c Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 23 Jun 2026 13:16:02 +0000 Subject: [PATCH 5/5] fix(sandbox/sessions): warn on non-zero hermes remote staging cleanup + document hermes source boundary Signed-off-by: Tinson Lai --- .../actions/sandbox/sessions/export.test.ts | 38 ++++++++++++ src/lib/actions/sandbox/sessions/export.ts | 59 +++++++++++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/lib/actions/sandbox/sessions/export.test.ts b/src/lib/actions/sandbox/sessions/export.test.ts index 1100928b892..f2dc47d7101 100644 --- a/src/lib/actions/sandbox/sessions/export.test.ts +++ b/src/lib/actions/sandbox/sessions/export.test.ts @@ -610,6 +610,44 @@ describe("exportSandboxSessions (hermes sandbox)", () => { 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(() => { diff --git a/src/lib/actions/sandbox/sessions/export.ts b/src/lib/actions/sandbox/sessions/export.ts index 68fefa7f373..b1ff186d081 100644 --- a/src/lib/actions/sandbox/sessions/export.ts +++ b/src/lib/actions/sandbox/sessions/export.ts @@ -263,6 +263,49 @@ 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 }); @@ -301,10 +344,18 @@ async function exportHermesSessions(opts: SessionsExportOptions): Promise