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
106 changes: 106 additions & 0 deletions src/lib/actions/sandbox/sessions/gateway-rpc-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ vi.mock("../auto-pair-approval", () => ({
runSandboxAutoPairApprovalPass: vi.fn(),
}));

vi.mock("../../../state/registry", () => ({
getSandbox: vi.fn(() => ({ name: "alpha", agent: "openclaw" })),
}));

import { captureOpenshell } from "../../../adapters/openshell/runtime";
import * as registry from "../../../state/registry";
import { runSandboxAutoPairApprovalPass } from "../auto-pair-approval";
import {
buildGatewayAdminRpcShell,
Expand All @@ -26,6 +31,7 @@ import {

const captureMock = captureOpenshell as unknown as ReturnType<typeof vi.fn>;
const autoPairMock = runSandboxAutoPairApprovalPass as unknown as ReturnType<typeof vi.fn>;
const getSandboxMock = registry.getSandbox as unknown as ReturnType<typeof vi.fn>;

function captureResult(
status: number,
Expand Down Expand Up @@ -56,6 +62,8 @@ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
captureMock.mockReset();
autoPairMock.mockReset();
getSandboxMock.mockReset();
getSandboxMock.mockReturnValue({ name: "alpha", agent: "openclaw" });
processExitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
Expand Down Expand Up @@ -257,6 +265,104 @@ describe("callOpenclawGateway", () => {
);
});

it("refuses a sandbox whose agent has no OpenClaw gateway admin RPCs", () => {
getSandboxMock.mockReturnValue({ name: "alpha", agent: "hermes" });

expect(() =>
callOpenclawGateway({
sandboxName: "alpha",
method: "sessions.reset",
params: { key: "agent:main:main", reason: "reset" },
}),
).toThrow(/process\.exit:1/);

expect(autoPairMock).not.toHaveBeenCalled();
expect(captureMock).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
" Refusing to invoke 'sessions.reset' for sandbox 'alpha': it uses the 'hermes' agent, which does not expose the OpenClaw gateway admin RPCs. These commands only support the OpenClaw agent.",
);
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("alpha sessions list"));
});

it("dispatches when the registry records the OpenClaw agent", () => {
getSandboxMock.mockReturnValue({ name: "alpha", agent: "openclaw" });
captureMock.mockReturnValue(captureResult(0, '{"ok":true,"key":"agent:main:main"}'));

const result = callOpenclawGateway({
sandboxName: "alpha",
method: "sessions.delete",
params: { key: "agent:main:main" },
});

expect(result.payload).toMatchObject({ ok: true });
expect(captureMock).toHaveBeenCalledTimes(1);
});

it.each([
undefined,
null,
])("dispatches for an existing legacy registry entry whose agent is %s", (agent) => {
getSandboxMock.mockReturnValue({ name: "alpha", agent });
captureMock.mockReturnValue(captureResult(0, '{"ok":true,"key":"agent:main:main"}'));

const result = callOpenclawGateway({
sandboxName: "alpha",
method: "sessions.reset",
params: { key: "agent:main:main", reason: "reset" },
});

expect(result.payload).toMatchObject({ ok: true });
expect(captureMock).toHaveBeenCalledTimes(1);
});

it("refuses when the registry has no sandbox entry", () => {
getSandboxMock.mockReturnValue(null);

expect(() =>
callOpenclawGateway({
sandboxName: "alpha",
method: "sessions.delete",
params: { key: "agent:main:main" },
}),
).toThrow(/process\.exit:1/);

expect(autoPairMock).not.toHaveBeenCalled();
expect(captureMock).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("no registry entry"));
});

it("refuses an existing registry entry whose agent is empty", () => {
getSandboxMock.mockReturnValue({ name: "alpha", agent: "" });

expect(() =>
callOpenclawGateway({
sandboxName: "alpha",
method: "sessions.delete",
params: { key: "agent:main:main" },
}),
).toThrow(/process\.exit:1/);

expect(autoPairMock).not.toHaveBeenCalled();
expect(captureMock).not.toHaveBeenCalled();
});

it("does not dispatch when the registry lookup throws", () => {
getSandboxMock.mockImplementation(() => {
throw new Error("registry unreadable");
});

expect(() =>
callOpenclawGateway({
sandboxName: "alpha",
method: "sessions.reset",
params: { key: "agent:main:main", reason: "reset" },
}),
).toThrow("registry unreadable");

expect(autoPairMock).not.toHaveBeenCalled();
expect(captureMock).not.toHaveBeenCalled();
});

it("does not retry unrelated gateway failures", () => {
captureMock.mockReturnValue(captureResult(1, "openclaw gateway crashed"));

Expand Down
58 changes: 57 additions & 1 deletion src/lib/actions/sandbox/sessions/gateway-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

import { Buffer } from "node:buffer";
import { captureOpenshell } from "../../../adapters/openshell/runtime";
import { CLI_NAME } from "../../../cli/branding";
import { CLI_NAME, getAgentBranding } from "../../../cli/branding";
import { redactFull } from "../../../security/redact";
import * as registry from "../../../state/registry";
import { runSandboxAutoPairApprovalPass } from "../auto-pair-approval";
import { buildTrustedProxyEnvSourceShell } from "../trusted-proxy-env";
import { type GatewayCallPayload, parseGatewayCallPayload } from "./gateway-rpc-envelope";
Expand All @@ -27,6 +28,8 @@ export interface GatewayCallResult<T extends GatewayCallPayload = GatewayCallPay

const SUPPORTED_GATEWAY_ADMIN_METHODS = new Set<string>(["sessions.reset", "sessions.delete"]);

const OPENCLAW_AGENT_ID = "openclaw";

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

// Source-boundary note for this SDK-backed admin RPC wrapper:
Expand Down Expand Up @@ -139,6 +142,54 @@ function isSupportedGatewayAdminMethod(method: string): method is GatewayAdminMe
return SUPPORTED_GATEWAY_ADMIN_METHODS.has(method);
}

/**
* Resolve the agent registered for the sandbox.
*
* Trust boundary: `registry.getSandbox()` reads the host-side, user-owned
* `~/.nemoclaw/sandboxes.json` registry. Sandbox processes cannot reach the
* host filesystem to change this selection. Only an existing legacy entry
* whose agent field is absent or null keeps the historical OpenClaw default.
* A missing entry is rejected, and registry read errors propagate, because an
* unknown agent identity cannot authorize an OpenClaw admin RPC.
*/
function resolveSandboxAgent(sandboxName: string, method: GatewayAdminMethod): string {
const sandbox = registry.getSandbox(sandboxName);
if (!sandbox) {
console.error(
` Refusing to invoke '${method}' for sandbox '${sandboxName}': it has no registry entry, so NemoClaw cannot confirm that it uses the OpenClaw agent.`,
);
process.exit(1);
}
return sandbox.agent === undefined || sandbox.agent === null ? OPENCLAW_AGENT_ID : sandbox.agent;
}

/**
* Report that the sandbox agent has no gateway admin RPCs and stop.
*
* These RPCs run an OpenClaw plugin-SDK script inside the sandbox against the
* OpenClaw gateway token. Other agents ship neither the OpenClaw binary nor
* that token, so the call used to surface an in-sandbox "token is required"
* stack trace that reads as a NemoClaw wiring gap. State the agent mismatch
* instead, and point Hermes users at the session commands they do have.
*/
function refuseUnsupportedSandboxAgent(
sandboxName: string,
agent: string,
method: GatewayAdminMethod,
): never {
console.error(
` Refusing to invoke '${method}' for sandbox '${sandboxName}': it uses the '${agent}' agent, which does not expose the OpenClaw gateway admin RPCs. These commands only support the OpenClaw agent.`,
);
if (agent === "hermes") {
const cliName = getAgentBranding().cli;
console.error(` List Hermes sessions with: ${cliName} ${sandboxName} sessions list`);
console.error(
` Export a Hermes session with: ${cliName} ${sandboxName} sessions export <keys...>`,
);
}
process.exit(1);
}

function redactedGatewayOutput(output: string): string {
return redactFull(output);
}
Expand Down Expand Up @@ -185,6 +236,11 @@ export function callOpenclawGateway<T extends GatewayCallPayload = GatewayCallPa
process.exit(1);
}

const agent = resolveSandboxAgent(opts.sandboxName, opts.method);
if (agent !== OPENCLAW_AGENT_ID) {
refuseUnsupportedSandboxAgent(opts.sandboxName, agent, opts.method);
}

// Drain allowlisted CLI/webchat pairing or scope-upgrade requests before
// host-side gateway RPCs. The RPC itself uses OpenClaw's SDK in backend mode
// with loopback + the shared gateway token, so sessions reset/delete do not
Expand Down
86 changes: 86 additions & 0 deletions test/sandbox-sessions-admin-agent-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";

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

function buildStubOpenshell(home: string, logFile: string): string {
const localBin = path.join(home, "bin");
fs.mkdirSync(localBin, { recursive: true });
fs.writeFileSync(
path.join(localBin, "openshell"),
[
"#!/usr/bin/env bash",
`printf '%s\\n' "$*" >> ${JSON.stringify(logFile)}`,
'case "$*" in',
' "sandbox list"*) printf "alpha Ready\\n"; exit 0 ;;',
' "sandbox get alpha"*) printf "Name: alpha\\nPhase: Ready\\nPolicy:\\n"; exit 0 ;;',
' "gateway info -g nemoclaw"*) printf "Gateway: nemoclaw\\n"; exit 0 ;;',
' *"sandbox exec --name alpha -- bash -lc"*)',
` printf '%s\\n' '{"ok":true,"key":"agent:main:main","entry":null}'`,
" exit 0 ;;",
" *) exit 0 ;;",
"esac",
].join("\n"),
{ mode: 0o755 },
);
return localBin;
}

function gatewayRpcCalls(logFile: string): string[] {
return fs
.readFileSync(logFile, "utf8")
.split("\n")
.filter((line) => line.includes("sandbox exec --name alpha -- bash -lc"));
}

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("still dispatches the gateway RPC when the registry records no agent", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-sessions-reset-default-"));
try {
writeSandboxRegistry(home);
const openshellLog = path.join(home, "openshell-calls.log");
const localBin = buildStubOpenshell(home, openshellLog);

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

expect(result.code).toBe(0);
expect(result.out).not.toContain("Refusing to invoke");
expect(gatewayRpcCalls(openshellLog).length).toBeGreaterThan(0);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
Loading