diff --git a/src/lib/actions/sandbox/sessions/gateway-rpc-call.test.ts b/src/lib/actions/sandbox/sessions/gateway-rpc-call.test.ts index 4ef4acc0ef6..3653d5c5c1a 100644 --- a/src/lib/actions/sandbox/sessions/gateway-rpc-call.test.ts +++ b/src/lib/actions/sandbox/sessions/gateway-rpc-call.test.ts @@ -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, @@ -26,6 +31,7 @@ import { const captureMock = captureOpenshell as unknown as ReturnType; const autoPairMock = runSandboxAutoPairApprovalPass as unknown as ReturnType; +const getSandboxMock = registry.getSandbox as unknown as ReturnType; function captureResult( status: number, @@ -56,6 +62,8 @@ let consoleErrorSpy: ReturnType; 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}`); }); @@ -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")); diff --git a/src/lib/actions/sandbox/sessions/gateway-rpc.ts b/src/lib/actions/sandbox/sessions/gateway-rpc.ts index 1146ad6ba71..65d689663e9 100644 --- a/src/lib/actions/sandbox/sessions/gateway-rpc.ts +++ b/src/lib/actions/sandbox/sessions/gateway-rpc.ts @@ -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"; @@ -27,6 +28,8 @@ export interface GatewayCallResult(["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: @@ -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 `, + ); + } + process.exit(1); +} + function redactedGatewayOutput(output: string): string { return redactFull(output); } @@ -185,6 +236,11 @@ export function callOpenclawGateway> ${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 }); + } + }); +});