Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -2601,6 +2601,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`).
The OpenClaw-only `--agent`, `--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
6 changes: 5 additions & 1 deletion src/commands/sandbox/sessions/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { sandboxNameArg } from "../../../lib/sandbox/command-support";
export default class SandboxSessionsDeleteCommand extends NemoClawCommand {
static id = "sandbox:sessions:delete";
static strict = true;
static summary = "Delete an OpenClaw conversation session via the gateway";
static summary = "Delete a conversation session from the sandbox";
static description = [
"Remove a session entry (and, by default, its transcript) by invoking the",
"OpenClaw gateway `sessions.delete` RPC from inside the sandbox. The gateway",
Expand All @@ -26,6 +26,10 @@ 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 refuses",
"the OpenClaw-only --agent, --keep-transcript, --json, and --verbose flags.",
].join("\n");
static usage = ["<name> <key> [--agent <id>] [--keep-transcript] [--json] [--verbose]"];
static examples = [
Expand Down
109 changes: 108 additions & 1 deletion src/lib/actions/sandbox/sessions/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ vi.mock("../gateway-state", () => ({

vi.mock("./gateway-rpc", () => ({
callOpenclawGateway: vi.fn(),
sandboxUsesHermesAgent: vi.fn(() => false),
}));

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

import { execSandbox } from "../exec";
import { ensureLiveSandboxOrExit } from "../gateway-state";
import { callOpenclawGateway } from "./gateway-rpc";
import { deleteSandboxSession } from "./delete";
import { callOpenclawGateway, sandboxUsesHermesAgent } from "./gateway-rpc";

const ensureMock = ensureLiveSandboxOrExit as unknown as ReturnType<typeof vi.fn>;
const gatewayMock = callOpenclawGateway as unknown as ReturnType<typeof vi.fn>;
const hermesAgentMock = sandboxUsesHermesAgent 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 +43,10 @@ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
ensureMock.mockClear();
gatewayMock.mockReset();
hermesAgentMock.mockReset();
hermesAgentMock.mockReturnValue(false);
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 +175,98 @@ describe("deleteSandboxSession", () => {
expect(result.removedTranscript).toBe(false);
});
});

describe("deleteSandboxSession (hermes sandbox)", () => {
beforeEach(() => {
hermesAgentMock.mockReturnValue(true);
// 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("rejects --agent hermes instead of silently ignoring the OpenClaw-only flag (#7642)", async () => {
await expect(
deleteSandboxSession("sb-h", { key: "20260727_130357_cb2b61", agent: "hermes" }),
).rejects.toThrow(/process\.exit:1/);

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

it.each([
["json", { json: true }],
["verbose", { verbose: true }],
])("rejects the OpenClaw-only --%s flag 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);
});
});
67 changes: 66 additions & 1 deletion src/lib/actions/sandbox/sessions/delete.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { execSandbox } from "../exec";
import { ensureLiveSandboxOrExit } from "../gateway-state";
import { callOpenclawGateway } from "./gateway-rpc";
import { callOpenclawGateway, sandboxUsesHermesAgent } from "./gateway-rpc";
import {
buildCanonicalSessionKey,
DEFAULT_AGENT_ID,
Expand Down Expand Up @@ -37,6 +38,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: the routing helper 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 (sandboxUsesHermesAgent(sandboxName)) {
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 +112,53 @@ export async function deleteSandboxSession(

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

// OpenClaw-only flags are refused rather than silently ignored.
function rejectOpenClawOnlyDeleteOptions(opts: SessionsDeleteOptions): void {
if (opts.agent) {
console.error(
` Refusing to delete: --agent ${opts.agent} is OpenClaw-only and is not supported on a Hermes sandbox. 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);
}
}

// Reject a leading dash so Hermes cannot parse the id as a flag. Reject
// whitespace because native Hermes ids contain none.
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.
12 changes: 12 additions & 0 deletions src/lib/actions/sandbox/sessions/gateway-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ const OPENCLAW_AGENT_ID = "openclaw";

const RETRYABLE_PAIRING_FAILURE = /scope upgrade pending|pairing required|device is not approved/i;

/**
* Return whether the host registry assigns the sandbox to Hermes.
*
* A missing entry or agent keeps the historical OpenClaw command path. The
* gateway call validates that default more strictly before using OpenClaw
* credentials.
*/
export function sandboxUsesHermesAgent(sandboxName: string): boolean {
return registry.getSandbox(sandboxName)?.agent === "hermes";
}

// Source-boundary note for this SDK-backed admin RPC wrapper:
// - Invalid state: `openclaw gateway call` currently acts like a sandbox-origin
// CLI client and can create/pending a new device pairing request while
Expand Down Expand Up @@ -186,6 +197,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
4 changes: 2 additions & 2 deletions test/e2e/support/e2e-report-to-pr-workflow-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,7 @@ it("builds controller target matrices only from trusted runner mappings (#7031)"
"::error::PR E2E target is not approved by the trusted controller",
);
expect(rejected.workflowOutput).toBe("");
});
}, 30_000);

it("binds controller matrix IDs and runners to the trusted target selector (#7031)", () => {
const target = "ubuntu-repo-cloud-langchain-deepagents-code";
Expand Down Expand Up @@ -978,7 +978,7 @@ it("binds controller matrix IDs and runners to the trusted target selector (#703
"::error::E2E planner matrix does not match controller-selected targets",
);
expect(runnerInjected.workflowOutput).toBe("");
});
}, 30_000);

it("requires the report-to-pr job to check out the trusted workflow revision", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-workflow-"));
Expand Down
Loading
Loading