Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
326d0b3
fix(cli): route sessions delete to the native Hermes command
Dongni-Yang Jul 28, 2026
034288e
fix(cli): make the Hermes delete branch a terminal contract
Dongni-Yang Jul 28, 2026
17fe863
test(cli): cover every invalid Hermes session id branch
Dongni-Yang Jul 28, 2026
7909d25
test(cli): cover Hermes rejection of --verbose alongside --json
Dongni-Yang Jul 28, 2026
2873383
test(cli): cover native Hermes delete failure through the public CLI
Dongni-Yang Jul 28, 2026
30c01c5
test(cli): assert the Hermes delete failure path leaks no gateway token
Dongni-Yang Jul 28, 2026
0d4fd93
merge(main): refresh PR #7682 from upstream/main
senthilr-nv Jul 28, 2026
9ae0cfb
refactor(cli): reuse sessions agent routing
senthilr-nv Jul 28, 2026
1cc8f77
merge(main): refresh PR #7682 from upstream/main
senthilr-nv Jul 28, 2026
3c6f4fe
docs(cli): clarify sessions delete routing
senthilr-nv Jul 28, 2026
5024d50
merge(main): refresh PR #7682 from upstream/main
senthilr-nv Jul 28, 2026
4504e33
test(e2e): allow matrix workflow integration startup
senthilr-nv Jul 28, 2026
e2f65c5
merge(main): refresh PR 7682 base to 8bfff4526
senthilr-nv Jul 28, 2026
239173e
test(e2e): allow controller workflow startup
senthilr-nv Jul 28, 2026
c6e3adf
merge(main): refresh PR 7682 base to 7f4b49082
senthilr-nv Jul 28, 2026
117044b
fix(cli): reject agent flag for Hermes session delete
senthilr-nv Jul 28, 2026
3042261
merge: resolve conflicts with main
github-actions[bot] Jul 28, 2026
138a5f6
Merge branch 'main' into fix/7642-hermes-sessions-delete-routing
cv Aug 4, 2026
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
10 changes: 10 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2474,6 +2474,16 @@ $$nemoclaw my-assistant sessions list
$$nemoclaw my-assistant sessions list --source cli --limit 20
```

### `$$nemoclaw <name> sessions delete <id>`

Invoke `hermes sessions delete <id> --yes` inside the sandbox to remove a session from the Hermes store.
Pass a native Hermes session id from `sessions list` (for example `20260727_130357_cb2b61`).
`--agent` accepts only `hermes` as a no-op alias and rejects any other value; the OpenClaw-only `--keep-transcript`, `--json`, and `--verbose` flags are not supported on a Hermes sandbox.

```bash
$$nemoclaw my-assistant sessions delete 20260727_130357_cb2b61
```

</AgentOnly>

<AgentOnly variant="openclaw">
Expand Down
5 changes: 5 additions & 0 deletions src/commands/sandbox/sessions/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export default class SandboxSessionsDeleteCommand extends NemoClawCommand {
"",
"Pass --keep-transcript to retain the on-disk `<sessionId>.jsonl` after the",
"session entry is removed.",
"",
"On a Hermes sandbox this routes to the native `hermes sessions delete <id>",
"--yes` and takes a native Hermes session id from `sessions list`. It accepts",
"--agent hermes as a no-op alias and refuses every other --agent value plus",
"--keep-transcript, --json, and --verbose.",
].join("\n");
static usage = ["<name> <key> [--agent <id>] [--keep-transcript] [--json] [--verbose]"];
static examples = [
Expand Down
116 changes: 115 additions & 1 deletion src/lib/actions/sandbox/sessions/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,24 @@ vi.mock("./gateway-rpc", () => ({
callOpenclawGateway: vi.fn(),
}));

vi.mock("../../../state/registry", () => ({
getSandbox: vi.fn(() => null),
}));

vi.mock("../exec", () => ({
execSandbox: vi.fn(async () => undefined),
}));

import * as registry from "../../../state/registry";
import { execSandbox } from "../exec";
import { ensureLiveSandboxOrExit } from "../gateway-state";
import { callOpenclawGateway } from "./gateway-rpc";
import { deleteSandboxSession } from "./delete";
import { callOpenclawGateway } from "./gateway-rpc";

const ensureMock = ensureLiveSandboxOrExit as unknown as ReturnType<typeof vi.fn>;
const gatewayMock = callOpenclawGateway as unknown as ReturnType<typeof vi.fn>;
const getSandboxMock = registry.getSandbox as unknown as ReturnType<typeof vi.fn>;
const execSandboxMock = execSandbox as unknown as ReturnType<typeof vi.fn>;

function successResult(key: string, extra: { removedTranscript?: boolean; entry?: unknown } = {}) {
const payload = { ok: true as const, key, ...extra };
Expand All @@ -35,6 +47,10 @@ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
ensureMock.mockClear();
gatewayMock.mockReset();
getSandboxMock.mockReset();
getSandboxMock.mockReturnValue(null);
execSandboxMock.mockReset();
execSandboxMock.mockResolvedValue(undefined);
processExitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
Expand Down Expand Up @@ -163,3 +179,101 @@ describe("deleteSandboxSession", () => {
expect(result.removedTranscript).toBe(false);
});
});

describe("deleteSandboxSession (hermes sandbox)", () => {
beforeEach(() => {
getSandboxMock.mockReturnValue({ name: "sb-h", agent: "hermes" });
// execSandbox streams the native output and exits the process with its
// code; model that terminal behavior so the routing never returns a value.
execSandboxMock.mockImplementation(async () => {
process.exit(0);
});
});

it("routes to the native hermes sessions delete without the OpenClaw gateway (#7642)", async () => {
await expect(deleteSandboxSession("sb-h", { key: "20260727_130357_cb2b61" })).rejects.toThrow(
/process\.exit:0/,
);

expect(gatewayMock).not.toHaveBeenCalled();
expect(ensureMock).toHaveBeenCalledWith("sb-h", { allowNonReadyPhase: true });
expect(execSandboxMock).toHaveBeenCalledWith("sb-h", [
"hermes",
"sessions",
"delete",
"20260727_130357_cb2b61",
"--yes",
]);
});

it("passes the native hermes session id through without OpenClaw canonicalization (#7642)", async () => {
await expect(deleteSandboxSession("sb-h", { key: "20260727_121145_238595" })).rejects.toThrow(
/process\.exit:0/,
);

expect(execSandboxMock.mock.calls[0]?.[1]).toContain("20260727_121145_238595");
expect(execSandboxMock.mock.calls[0]?.[1]?.join(" ")).not.toContain("agent:");
});

it("rejects the OpenClaw-only --agent flag on a hermes sandbox (#7642)", async () => {
await expect(
deleteSandboxSession("sb-h", { key: "20260727_130357_cb2b61", agent: "research" }),
).rejects.toThrow(/process\.exit:1/);

expect(execSandboxMock).not.toHaveBeenCalled();
expect(consoleErrorSpy.mock.calls.flat().join("\n")).toMatch(
/--agent.*OpenClaw-only.*not supported on a Hermes sandbox/,
);
});

it("rejects the OpenClaw-only --keep-transcript flag on a hermes sandbox (#7642)", async () => {
await expect(
deleteSandboxSession("sb-h", { key: "20260727_130357_cb2b61", keepTranscript: true }),
).rejects.toThrow(/process\.exit:1/);

expect(execSandboxMock).not.toHaveBeenCalled();
expect(consoleErrorSpy.mock.calls.flat().join("\n")).toMatch(
/--keep-transcript.*OpenClaw-only.*not supported on a Hermes sandbox/,
);
});

it("accepts --agent hermes as a no-op alias and still routes to the native command (#7642)", async () => {
await expect(
deleteSandboxSession("sb-h", { key: "20260727_130357_cb2b61", agent: "hermes" }),
).rejects.toThrow(/process\.exit:0/);

expect(execSandboxMock).toHaveBeenCalledWith("sb-h", [
"hermes",
"sessions",
"delete",
"20260727_130357_cb2b61",
"--yes",
]);
});

it.each([
["json", { json: true }],
["verbose", { verbose: true }],
])("rejects the OpenClaw-only --%s result output on a hermes sandbox (#7642)", async (_flag, extra) => {
await expect(
deleteSandboxSession("sb-h", { key: "20260727_130357_cb2b61", ...extra }),
).rejects.toThrow(/process\.exit:1/);

expect(execSandboxMock).not.toHaveBeenCalled();
expect(consoleErrorSpy.mock.calls.flat().join("\n")).toMatch(
/--json and --verbose.*OpenClaw-only/,
);
});

it.each([
["a leading dash that could parse as a flag", "--yes"],
["an empty string", ""],
["only whitespace", " "],
["embedded whitespace", "2026 0727"],
])("rejects an invalid hermes session id (%s) (#7642)", async (_case, key) => {
await expect(deleteSandboxSession("sb-h", { key })).rejects.toThrow(/process\.exit:1/);

expect(execSandboxMock).not.toHaveBeenCalled();
expect(consoleErrorSpy.mock.calls.flat().join("\n")).toMatch(/session id/i);
});
});
68 changes: 68 additions & 0 deletions src/lib/actions/sandbox/sessions/delete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import * as registry from "../../../state/registry";
import { execSandbox } from "../exec";
import { ensureLiveSandboxOrExit } from "../gateway-state";
import { callOpenclawGateway } from "./gateway-rpc";
import {
Expand Down Expand Up @@ -37,6 +39,20 @@ export async function deleteSandboxSession(
sandboxName: string,
opts: SessionsDeleteOptions,
): Promise<SessionsDeleteResult> {
// Route by the sandbox's registered agent before OpenClaw key validation,
// the same dispatch `sessions export` uses (#5526). Hermes ships a native
// `hermes sessions delete` over its own SQLite store and neither exposes the
// OpenClaw gateway admin RPC nor accepts OpenClaw canonical `agent:<id>:<rest>`
// session keys.
//
// Trust boundary: `registry.getSandbox()` reads the host-side, user-owned
// sandbox registry; a sandbox process cannot change this agent selection. A
// sandbox with no registry entry, or with an `agent` other than `hermes`,
// keeps the OpenClaw path below.
if (registry.getSandbox(sandboxName)?.agent === "hermes") {
return deleteHermesSession(sandboxName, opts);
}

const requestedAgent = opts.agent ? validateAgentId(opts.agent) : null;
const rawKey = validateSessionKey(opts.key);
const keyAgent = parseAgentIdFromSessionKey(rawKey);
Expand Down Expand Up @@ -97,3 +113,55 @@ export async function deleteSandboxSession(

return { key: payload.key, removedTranscript, entry: payload.entry };
}

// The `--agent hermes` no-op alias mirrors `sessions export`; every other
// OpenClaw-only flag is refused rather than silently ignored.
function rejectOpenClawOnlyDeleteOptions(opts: SessionsDeleteOptions): void {
if (opts.agent && opts.agent !== "hermes") {
console.error(
` Refusing to delete: --agent ${opts.agent} is OpenClaw-only and is not supported on a Hermes sandbox. Pass --agent hermes or omit the flag.`,
);
process.exit(1);
}
if (opts.keepTranscript === true) {
console.error(
" Refusing to delete: --keep-transcript is OpenClaw-only and is not supported on a Hermes sandbox. Hermes removes the session entry directly; omit the flag.",
);
process.exit(1);
}
if (opts.json || opts.verbose) {
console.error(
" Refusing to delete: --json and --verbose print the OpenClaw gateway result and are OpenClaw-only; a Hermes sandbox streams the native command output. Omit the flags.",
);
process.exit(1);
}
}

// Accept the native Hermes id as-is but refuse a value that could be parsed as
// a flag by the in-sandbox command; the id is passed as its own argv element,
// so a leading dash or embedded whitespace is the only injection surface.
function validateHermesSessionId(rawKey: string): string {
const sessionId = rawKey.trim();
if (sessionId === "" || sessionId.startsWith("-") || /\s/.test(sessionId)) {
console.error(
` Refusing to delete: '${rawKey}' is not a valid Hermes session id. Pass a native id from \`sessions list\` (for example 20260727_130357_cb2b61).`,
);
process.exit(1);
}
return sessionId;
}

async function deleteHermesSession(
sandboxName: string,
opts: SessionsDeleteOptions,
): Promise<never> {
rejectOpenClawOnlyDeleteOptions(opts);
const sessionId = validateHermesSessionId(opts.key);

await ensureLiveSandboxOrExit(sandboxName, { allowNonReadyPhase: true });
// execSandbox streams the native command output and exits the process with
// its exit code, so control never returns here and there is no NemoClaw-side
// result envelope to build (unlike the OpenClaw gateway path above).
await execSandbox(sandboxName, ["hermes", "sessions", "delete", sessionId, "--yes"]);
throw new Error("unreachable: execSandbox terminates the process");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
1 change: 1 addition & 0 deletions src/lib/actions/sandbox/sessions/gateway-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ function refuseUnsupportedSandboxAgent(
console.error(
` Export a Hermes session with: ${cliName} ${sandboxName} sessions export <keys...>`,
);
console.error(` Delete a Hermes session with: ${cliName} ${sandboxName} sessions delete <id>`);
}
process.exit(1);
}
Expand Down
102 changes: 77 additions & 25 deletions test/sandbox-sessions-admin-agent-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { describe, expect, it } from "vitest";

import { runWithEnv, writeSandboxRegistry } from "./cli/helpers";

function buildStubOpenshell(home: string, logFile: string): string {
function buildStubOpenshell(home: string, logFile: string, nativeDeleteExit = 0): string {
const localBin = path.join(home, "bin");
fs.mkdirSync(localBin, { recursive: true });
fs.writeFileSync(
Expand All @@ -23,6 +23,7 @@ function buildStubOpenshell(home: string, logFile: string): string {
' *"sandbox exec --name alpha -- bash -lc"*)',
` printf '%s\\n' '{"ok":true,"key":"agent:main:main","entry":null}'`,
" exit 0 ;;",
` *"hermes sessions delete"*) exit ${nativeDeleteExit} ;;`,
" *) exit 0 ;;",
"esac",
].join("\n"),
Expand All @@ -39,30 +40,81 @@ function gatewayRpcCalls(logFile: string): string[] {
}

describe("sandbox sessions admin RPCs on a non-OpenClaw agent (#7587)", () => {
for (const verb of ["reset", "delete"] as const) {
it(`refuses \`sessions ${verb}\` on a hermes sandbox instead of dispatching the OpenClaw gateway RPC`, () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), `nemoclaw-cli-sessions-${verb}-hermes-`));
try {
writeSandboxRegistry(home, "alpha", { agent: "hermes" });
const openshellLog = path.join(home, "openshell-calls.log");
const localBin = buildStubOpenshell(home, openshellLog);

const result = runWithEnv(`alpha sessions ${verb} agent:main:main 2>&1`, {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
});

expect(result.code).toBe(1);
expect(result.out).toContain(`Refusing to invoke 'sessions.${verb}' for sandbox 'alpha'`);
expect(result.out).toContain("it uses the 'hermes' agent");
expect(result.out).toContain("alpha sessions list");
expect(result.out).not.toContain("OPENCLAW_GATEWAY_TOKEN");
expect(gatewayRpcCalls(openshellLog)).toEqual([]);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
}
it("refuses `sessions reset` on a hermes sandbox instead of dispatching the OpenClaw gateway RPC", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-hermes-"));
try {
writeSandboxRegistry(home, "alpha", { agent: "hermes" });
const openshellLog = path.join(home, "openshell-calls.log");
const localBin = buildStubOpenshell(home, openshellLog);

const result = runWithEnv("alpha sessions reset agent:main:main 2>&1", {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
});

expect(result.code).toBe(1);
expect(result.out).toContain("Refusing to invoke 'sessions.reset' for sandbox 'alpha'");
expect(result.out).toContain("it uses the 'hermes' agent");
expect(result.out).toContain("alpha sessions list");
expect(result.out).not.toContain("OPENCLAW_GATEWAY_TOKEN");
expect(gatewayRpcCalls(openshellLog)).toEqual([]);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

it("routes `sessions delete` on a hermes sandbox to the native command, not the OpenClaw gateway RPC (#7642)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-delete-hermes-"));
try {
writeSandboxRegistry(home, "alpha", { agent: "hermes" });
const openshellLog = path.join(home, "openshell-calls.log");
const localBin = buildStubOpenshell(home, openshellLog);

const result = runWithEnv("alpha sessions delete 20260727_130357_cb2b61 2>&1", {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
});

expect(result.code).toBe(0);
expect(result.out).not.toContain("Refusing to invoke");
expect(result.out).not.toContain("OPENCLAW_GATEWAY_TOKEN");
expect(gatewayRpcCalls(openshellLog)).toEqual([]);
const nativeDeleteCalls = fs
.readFileSync(openshellLog, "utf8")
.split("\n")
.filter((line) => line.includes("hermes sessions delete 20260727_130357_cb2b61 --yes"));
expect(nativeDeleteCalls.length).toBe(1);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

it("propagates a nonzero native hermes delete exit code and makes no gateway RPC (#7642)", () => {
const home = fs.mkdtempSync(
path.join(os.tmpdir(), "nemoclaw-cli-sessions-delete-hermes-fail-"),
);
try {
writeSandboxRegistry(home, "alpha", { agent: "hermes" });
const openshellLog = path.join(home, "openshell-calls.log");
const localBin = buildStubOpenshell(home, openshellLog, 3);

const result = runWithEnv("alpha sessions delete 20260727_130357_cb2b61 2>&1", {
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
});

expect(result.code).toBe(3);
expect(result.out).not.toContain("Refusing to invoke");
expect(gatewayRpcCalls(openshellLog)).toEqual([]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const nativeDeleteCalls = fs
.readFileSync(openshellLog, "utf8")
.split("\n")
.filter((line) => line.includes("hermes sessions delete 20260727_130357_cb2b61 --yes"));
expect(nativeDeleteCalls.length).toBe(1);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});

it("still dispatches the gateway RPC when the registry records no agent", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-default-"));
Expand Down
Loading