Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1044,7 +1044,7 @@ nemohermes my-assistant agents delete work --force --json

List OpenClaw conversation sessions in the sandbox.
With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent.
This is a thin pass-through to `openclaw sessions` via `openshell sandbox exec`; flags accepted by the in-sandbox CLI are forwarded verbatim.
NemoClaw invokes `openclaw sessions` via `openshell sandbox exec` and forwards OpenClaw flags verbatim, but filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output.

```bash
nemohermes my-assistant sessions
Expand All @@ -1053,8 +1053,8 @@ nemohermes my-assistant sessions --all-agents --json

### `nemohermes <name> sessions list`

Pass-through to `openclaw sessions list` inside the sandbox.
Accepts every flag the in-sandbox CLI does (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`).
Invoke `openclaw sessions list` inside the sandbox.
NemoClaw forwards every flag the in-sandbox CLI accepts (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`) and filters the resulting default table or JSON so internal `nemoclaw-onboard-warmup-*` sessions are hidden.

```bash
nemohermes my-assistant sessions list
Expand Down Expand Up @@ -1110,7 +1110,8 @@ The command routes by the sandbox's agent kind recorded in the registry.

For an OpenClaw sandbox, the command enumerates the session store through `openclaw sessions list --agent <id> --json` and copies only the matching `<sessionId>.jsonl` (plus optional `<sessionId>.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping.
By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload.
With no positional keys, the command exports every session for the agent.
With no positional keys, the command exports every non-internal session for the agent; if only internal warm-up sessions exist, the command reports that there are no sessions to bundle and writes no artifact.
Internal `nemoclaw-onboard-warmup-*` sessions are excluded from export-all output, but passing an explicit warm-up session key still exports that session for debugging.
Pass one or more keys (alias or canonical `agent:<id>:<rest>`) 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.
Expand Down
9 changes: 5 additions & 4 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1342,7 +1342,7 @@ Pass `-f` / `--file <agents.yaml>` to point at the manifest; `--yes` confirms th

List OpenClaw conversation sessions in the sandbox.
With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent.
This is a thin pass-through to `openclaw sessions` via `openshell sandbox exec`; flags accepted by the in-sandbox CLI are forwarded verbatim.
NemoClaw invokes `openclaw sessions` via `openshell sandbox exec` and forwards OpenClaw flags verbatim, but filters default list output so internal `nemoclaw-onboard-warmup-*` sessions created during onboarding are hidden from user-facing output.

```bash
$$nemoclaw my-assistant sessions
Expand All @@ -1351,8 +1351,8 @@ $$nemoclaw my-assistant sessions --all-agents --json

### `$$nemoclaw <name> sessions list`

Pass-through to `openclaw sessions list` inside the sandbox.
Accepts every flag the in-sandbox CLI does (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`).
Invoke `openclaw sessions list` inside the sandbox.
NemoClaw forwards every flag the in-sandbox CLI accepts (`--agent`, `--all-agents`, `--active`, `--limit`, `--json`, `--store`, `--verbose`) and filters the resulting default table or JSON so internal `nemoclaw-onboard-warmup-*` sessions are hidden.

```bash
$$nemoclaw my-assistant sessions list
Expand Down Expand Up @@ -1408,7 +1408,8 @@ The command routes by the sandbox's agent kind recorded in the registry.

For an OpenClaw sandbox, the command enumerates the session store through `openclaw sessions list --agent <id> --json` and copies only the matching `<sessionId>.jsonl` (plus optional `<sessionId>.trajectory.jsonl`) files, so the export never picks up `sessions.json`, stale `.jsonl.lock` files, or other store bookkeeping.
By default it writes a browsable directory of session files (`dir` format); pass `--format tar` for a single `.tgz` bundle suited to sharing or upload.
With no positional keys, the command exports every session for the agent.
With no positional keys, the command exports every non-internal session for the agent; if only internal warm-up sessions exist, the command reports that there are no sessions to bundle and writes no artifact.
Internal `nemoclaw-onboard-warmup-*` sessions are excluded from export-all output, but passing an explicit warm-up session key still exports that session for debugging.
Pass one or more keys (alias or canonical `agent:<id>:<rest>`) 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.
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sandbox/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class SandboxSessionsCommand extends NemoClawCommand {
static strict = false;
static summary = "List OpenClaw conversation sessions in a sandbox";
static description =
"Pass through to `openclaw sessions` in the sandbox. With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent. Additional OpenClaw flags are forwarded verbatim after the sandbox name.";
"Pass through to `openclaw sessions` in the sandbox. With no subcommand the in-sandbox CLI lists stored sessions for the configured default agent, hiding internal NemoClaw onboard warm-up sessions from default user-facing output. Additional OpenClaw flags are forwarded verbatim after the sandbox name.";
static usage = ["<name> [openclaw-sessions-flags...]"];
static examples = [
"<%= config.bin %> sandbox sessions alpha",
Expand Down
5 changes: 3 additions & 2 deletions src/commands/sandbox/sessions/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ export default class SandboxSessionsExportCommand extends NemoClawCommand {
"",
"OpenClaw sandbox: tar the per-session JSONL files inside the sandbox and",
"download the bundle to the host via `openshell sandbox download`. By default",
"every session for the agent is exported; pass one or more positional keys to",
"filter. Keys may be either an alias (e.g. `main`, `telegram:t-1`) or the",
"every non-internal session for the agent is exported; NemoClaw onboard",
"warm-up sessions are hidden from export-all output. Pass one or more",
"positional keys to filter. Keys may be either an alias (e.g. `main`, `telegram:t-1`) or the",
"canonical `agent:<id>:<rest>` 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",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/sandbox/sessions/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class SandboxSessionsListCommand extends NemoClawCommand {
static strict = false;
static summary = "List OpenClaw conversation sessions in a sandbox";
static description =
"Pass through to `openclaw sessions list` in the sandbox. All OpenClaw flags (--agent, --all-agents, --active, --limit, --json, --store, --verbose) are forwarded verbatim.";
"Pass through to `openclaw sessions list` in the sandbox, hiding internal NemoClaw onboard warm-up sessions from default user-facing output. All OpenClaw flags (--agent, --all-agents, --active, --limit, --json, --store, --verbose) are forwarded verbatim.";
static usage = ["<name> [openclaw-sessions-list-flags...]"];
static examples = [
"<%= config.bin %> sandbox sessions list alpha",
Expand Down
25 changes: 22 additions & 3 deletions src/lib/actions/sandbox/auto-pair-warmup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";

import { wrapSandboxShellScript } from "./auto-pair-approval";
import { WARMUP_TIMEOUT_MS } from "./auto-pair-warmup";
import { WARMUP_SCRIPT, WARMUP_TIMEOUT_MS } from "./auto-pair-warmup";
import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session";

// NOTE on coverage shape (#4504-v2): `runSandboxScopeWarmupRun` is not exercised
// in-process here. Like its sibling `runSandboxAutoPairApprovalPass`, the leaf
Expand Down Expand Up @@ -50,7 +51,7 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => {
const warmupShaped = [
"command -v openclaw >/dev/null 2>&1 || exit 0",
'openclaw agent --agent main -m "ping" \\',
' --session-id "nemoclaw-onboard-warmup-$$-$(date +%s)" >/dev/null 2>&1 || true',
` --session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true`,
"exit 0",
"",
].join("\n");
Expand All @@ -60,7 +61,10 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => {
expect(wrapped).toContain("mktemp");
});

it("round-trips a warm-up-shaped payload and preserves its exit-0 status when run", () => {
const shAvailable = spawnSync("sh", ["-c", "exit 0"], { encoding: "utf-8" }).status === 0;
const itWithSh = shAvailable ? it : it.skip;

itWithSh("round-trips a warm-up-shaped payload and preserves its exit-0 status when run", () => {
// Mirror the real warm-up: the provoke command itself may "fail" (the agent
// falls back to embedded mode), but `|| true` + trailing `exit 0` mean the
// wrapped script always exits 0 — so a failed provoke never surfaces as a
Expand All @@ -72,3 +76,18 @@ describe("warm-up payload survives OpenShell exec (#4504-v2)", () => {
expect(result.status).toBe(0);
});
});

describe("warm-up tags its throwaway session for user-facing filters (#5511)", () => {
it("tags the provoke session with the shared warm-up prefix", () => {
expect(WARMUP_SESSION_ID_PREFIX).toBe("nemoclaw-onboard-warmup-");
expect(WARMUP_SCRIPT).toContain(`--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`);
});

it("keeps the #4504-v2 provoke run foreground and within the original budget", () => {
expect(WARMUP_SCRIPT).toContain('openclaw agent --agent main -m "ping" \\');
expect(WARMUP_SCRIPT).toContain(">/dev/null 2>&1 || true");
expect(WARMUP_SCRIPT).not.toContain("setsid");
expect(WARMUP_SCRIPT).not.toContain("WARMUP_AGENT_PID");
expect(WARMUP_SCRIPT).not.toContain("warmup_cleanup_attempt");
});
});
5 changes: 3 additions & 2 deletions src/lib/actions/sandbox/auto-pair-warmup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { spawnSync } from "node:child_process";

import { ROOT } from "../../state/paths";
import { wrapSandboxShellScript } from "./auto-pair-approval";
import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session";

// Outer spawnSync cap (ms) for the throwaway warm-up agent run. The `-m`
// one-shot prompt ("ping") returns fast even when it falls back to embedded
Expand Down Expand Up @@ -65,12 +66,12 @@ export const WARMUP_POLL_LIST_TIMEOUT_S = 2;
// the approval pass that runs immediately after could otherwise list devices
// before the gateway has registered the upgrade. The poll bounds are
// interpolated so the cap is asserted on real values, not source text.
const WARMUP_SCRIPT = `
export const WARMUP_SCRIPT = `
PROXY_ENV=/tmp/nemoclaw-proxy-env.sh
[ -r "$PROXY_ENV" ] && . "$PROXY_ENV"
command -v openclaw >/dev/null 2>&1 || exit 0
openclaw agent --agent main -m "ping" \\
--session-id "nemoclaw-onboard-warmup-$$-$(date +%s)" >/dev/null 2>&1 || true
--session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true
command -v python3 >/dev/null 2>&1 || exit 0
OPENCLAW_BIN="$(command -v openclaw)"
i=0
Expand Down
83 changes: 56 additions & 27 deletions src/lib/actions/sandbox/sessions/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ vi.mock("../../../state/registry", () => ({

import { captureOpenshell, runOpenshell } from "../../../adapters/openshell/runtime";
import * as registry from "../../../state/registry";
import { buildSandboxTarArgv, exportSandboxSessions, parseSessionIndex } from "./export";
import { isWarmupSessionId, WARMUP_SESSION_ID_PREFIX } from "../warmup-session";
import { buildSandboxTarArgv, exportSandboxSessions } from "./export";

const captureMock = captureOpenshell as unknown as ReturnType<typeof vi.fn>;
const runMock = runOpenshell as unknown as ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -74,42 +75,70 @@ describe("buildSandboxTarArgv", () => {
});
});

describe("parseSessionIndex", () => {
it("accepts a plain JSON array of entries", () => {
const output = '[{"key":"agent:main:main","sessionId":"sid-1"}]';
expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]);
describe("isWarmupSessionId", () => {
it("matches the onboard warm-up session id prefix (#5511)", () => {
expect(isWarmupSessionId(`${WARMUP_SESSION_ID_PREFIX}123`)).toBe(true);
expect(isWarmupSessionId("sid-real")).toBe(false);
});
});

it("accepts an object wrapper with a sessions array", () => {
const output = '{"sessions":[{"key":"agent:main:main","sessionId":"sid-1"}]}';
expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]);
});
describe("exportSandboxSessions warm-up filtering", () => {
it("excludes the onboard warm-up session from export-all but keeps real sessions (#5511)", async () => {
captureMock.mockReturnValueOnce(
makeCapture(
JSON.stringify([
{ key: "agent:main:main", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` },
{ key: "agent:main:telegram:t-1", sessionId: "sid-real" },
]),
),
);

it("treats id as an alias for sessionId", () => {
const output = '[{"key":"agent:main:main","id":"sid-1"}]';
expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]);
});
const result = await exportSandboxSessions({
sandboxName: "alpha",
out: "./out.tgz",
format: "tar",
});

it("tolerates log noise preceding a single-line JSON payload", () => {
const output = 'warning: deprecation\n[{"key":"agent:main:main","sessionId":"sid-1"}]';
expect(parseSessionIndex(output)).toEqual([{ key: "agent:main:main", sessionId: "sid-1" }]);
const tarCall = runMock.mock.calls[0]?.[0] as string[];
const shellCommand = tarCall[7] as string;
expect(result.resolvedSessionIds).toEqual(["sid-real"]);
expect(shellCommand).toMatch(/-- \.\/sid-real\.jsonl/);
expect(shellCommand).not.toContain(WARMUP_SESSION_ID_PREFIX);
});

it("returns [] when the upstream emits an empty index (empty array)", () => {
expect(parseSessionIndex("[]")).toEqual([]);
});
it("refuses export-all when only the onboard warm-up session remains (#5511)", async () => {
captureMock.mockReturnValueOnce(
makeCapture(
JSON.stringify([
{ key: "agent:main:explicit:warm", sessionId: `${WARMUP_SESSION_ID_PREFIX}1` },
]),
),
);

it("returns [] when the upstream emits no output at all", () => {
expect(parseSessionIndex("")).toEqual([]);
await expect(
exportSandboxSessions({
sandboxName: "alpha",
out: "./sessions-alpha",
}),
).rejects.toThrow(/agent 'main' has no sessions to bundle/);
expect(runMock).not.toHaveBeenCalled();
});

it("returns null when the output is non-empty but no JSON shape is recognised", () => {
expect(parseSessionIndex("hello world")).toBeNull();
});
it("still exports a warm-up session when the caller names it explicitly", async () => {
const warmupId = `${WARMUP_SESSION_ID_PREFIX}explicit`;
captureMock.mockReturnValueOnce(
makeCapture(JSON.stringify([{ key: "agent:main:main", sessionId: warmupId }])),
);

const result = await exportSandboxSessions({
sandboxName: "alpha",
keys: ["agent:main:main"],
out: "./out.tgz",
format: "tar",
});

it("returns null when the array is non-empty but every entry uses unknown field names (schema drift)", () => {
const output = JSON.stringify([{ alias: "agent:main:main", uuid: "sid-1" }]);
expect(parseSessionIndex(output)).toBeNull();
expect(result.resolvedSessionIds).toEqual([warmupId]);
expect(result.resolvedFiles).toEqual([`${warmupId}.jsonl`]);
});
});

Expand Down
Loading
Loading