diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 8d916fe1265..dc3a0a6544f 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -439,7 +439,7 @@ The exit code is the remote command's exit code. ### `nemohermes agent` -The `agent` wrapper is an OpenClaw passthrough and rejects Hermes sandboxes with guidance for the Hermes HTTP API. +The `agent` wrapper rejects Hermes sandboxes with guidance for the Hermes HTTP API. Hermes sandboxes expose an OpenAI-compatible API on port `8642` inside the sandbox, so non-interactive use does not need a wrapper command. Forward the port and POST chat completions directly: diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 84f4d56b7bc..f3155ccc2bb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -546,34 +546,41 @@ The exit code is the remote command's exit code. -Run one OpenClaw agent turn non-interactively in a running sandbox. -This command forwards every argument verbatim to `openclaw agent ...` inside the sandbox via `openshell sandbox exec`, with `HOME=/sandbox` so the addressed agent profile resolves the same way as `connect`. +Run one agent turn non-interactively in a running sandbox. +For OpenClaw sandboxes, this command forwards arguments to `openclaw agent ...` inside the sandbox via `openshell sandbox exec`, with `HOME=/sandbox` so the addressed agent profile resolves the same way as `connect`. +For terminal-runtime sandboxes, NemoClaw forwards arguments to the manifest-declared interactive command; LangChain Deep Agents Code sandboxes run `dcode ...`. Use this when driving the sandbox programmatically from another process (CI job, multi-agent platform, evaluation harness) rather than from an interactive terminal. -All flags accepted by the in-sandbox OpenClaw CLI are forwarded verbatim, so the upstream surface stays the single source of truth. +All flags accepted by the selected in-sandbox agent CLI are forwarded verbatim, so the upstream surface stays the single source of truth. -Every invocation must include at least one target selector — `--agent`, `--session-id`, `--session-key`, or `--to` — so the wrapper never falls back to the unspecified default-session behaviour. Conflict resolution between multiple selectors is delegated to the in-sandbox `openclaw agent` argv contract; the host-side guard only checks presence. +OpenClaw invocations must include at least one target selector — `--agent`, `--session-id`, `--session-key`, or `--to` — so the wrapper never falls back to the unspecified default-session behaviour. Conflict resolution between multiple selectors is delegated to the in-sandbox `openclaw agent` argv contract; the host-side guard only checks presence. ```bash $$nemoclaw my-assistant agent --agent work -m "Summarise README.md" $$nemoclaw my-assistant agent --agent work -m "Status update?" $$nemoclaw my-assistant agent --session-id review-42 -m "Any new findings?" $$nemoclaw my-assistant agent --session-key intake-42 --json -m 'ping' +$$nemoclaw dcode-sandbox agent --help +$$nemoclaw dcode-sandbox agent -n "Summarize this repository" ``` -The wrapper inherits the remote command's exit code, so host-side pipelines can branch on it. Streaming forwards whatever `openclaw agent` already emits on `stdout`; the wrapper adds no buffering. +The wrapper inherits the remote command's exit code, so host-side pipelines can branch on it. +Streaming forwards whatever the in-sandbox agent command emits on `stdout`; the wrapper adds no buffering. -Common upstream flags include `-m `, `--session-id `, `--agent `, `--model `, `--thinking `, `--json`, `--deliver`, `--reply-channel `, and `--timeout `. Run `$$nemoclaw agent --help` for the wrapper-level summary, or invoke `$$nemoclaw exec -- openclaw agent --help` to view the upstream OpenClaw help text directly. +Common OpenClaw flags include `-m `, `--session-id `, `--agent `, `--model `, `--thinking `, `--json`, `--deliver`, `--reply-channel `, and `--timeout `. +For OpenClaw sandboxes and registry fallbacks, `$$nemoclaw agent --help` prints the wrapper-level summary locally. +Invoke `$$nemoclaw exec -- openclaw agent --help` to view the upstream OpenClaw help text directly. +For registered terminal-runtime sandboxes, bare invocations and `--help` are forwarded to the terminal command, so a LangChain Deep Agents Code sandbox receives `dcode` for `$$nemoclaw agent` and `dcode --help` for `$$nemoclaw agent --help`. Host-side validation runs before the sandbox dispatch: -- At least one target selector flag — `--agent`, `--session-id`, `--session-key`, or `--to` (in either `--flag value` or `--flag=value` form) — must be present. Invocations without a selector exit `2` and print `No target session selected` locally, without paying the in-sandbox dispatch cost. +- OpenClaw sandboxes and registry fallbacks must include at least one target selector flag — `--agent`, `--session-id`, `--session-key`, or `--to` (in either `--flag value` or `--flag=value` form). OpenClaw invocations without a selector exit `2` and print `No target session selected` locally, without paying the in-sandbox dispatch cost. Registered terminal-runtime sandboxes delegate bare invocations and help flags to the manifest command instead. - If the sandbox is registered but not in a `Ready` or `Running` phase, the wrapper exits `1` and prints the documented recovery commands (`$$nemoclaw recover`, `$$nemoclaw rebuild --yes`, `$$nemoclaw onboard --resume`) rather than deferring the readiness rejection to `openshell sandbox exec`. -The `agent` wrapper is an OpenClaw passthrough and rejects Hermes sandboxes with guidance for the Hermes HTTP API. +The `agent` wrapper rejects Hermes sandboxes with guidance for the Hermes HTTP API. Hermes sandboxes expose an OpenAI-compatible API on port `8642` inside the sandbox, so non-interactive use does not need a wrapper command. Forward the port and POST chat completions directly: diff --git a/src/commands/sandbox/agent.test.ts b/src/commands/sandbox/agent.test.ts index 3c62fb32b3f..b56f28c9bfe 100644 --- a/src/commands/sandbox/agent.test.ts +++ b/src/commands/sandbox/agent.test.ts @@ -31,11 +31,11 @@ describe("SandboxAgentCommand oclif parse path", () => { }); }); - it("does not call runAgentPassthrough when --help follows the sandbox name", async () => { + it("passes --help after the sandbox name to agent-aware dispatch (#5790)", async () => { await SandboxAgentCommand.run(["alpha", "--help"], rootDir); - expect(runAgentPassthroughMock).not.toHaveBeenCalled(); - const help = logSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); - expect(help).toMatch(/openclaw agent/); + expect(runAgentPassthroughMock).toHaveBeenCalledWith("alpha", { + extraArgs: ["--help"], + }); }); it("does not call runAgentPassthrough when no sandbox name is supplied", async () => { @@ -50,13 +50,8 @@ describe("SandboxAgentCommand oclif parse path", () => { expect(runAgentPassthroughMock).not.toHaveBeenCalled(); }); - it("prints wrapper help and does not dispatch on a bare no-args invocation (#5658)", async () => { - // `nemoclaw agent` with no further args cannot succeed in-sandbox - // (openclaw agent requires -m), so short-circuit to wrapper help locally - // instead of paying sandbox-exec latency to surface an upstream error. + it("passes a bare sandbox invocation to agent-aware dispatch (#5790)", async () => { await SandboxAgentCommand.run(["alpha"], rootDir); - expect(runAgentPassthroughMock).not.toHaveBeenCalled(); - const help = logSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n"); - expect(help).toMatch(/openclaw agent/); + expect(runAgentPassthroughMock).toHaveBeenCalledWith("alpha", { extraArgs: [] }); }); }); diff --git a/src/commands/sandbox/agent.ts b/src/commands/sandbox/agent.ts index c55d5c2e4ea..09ae40d296d 100644 --- a/src/commands/sandbox/agent.ts +++ b/src/commands/sandbox/agent.ts @@ -1,20 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - hasAgentPassthroughHelpToken, - printAgentPassthroughHelp, -} from "../../lib/actions/sandbox/agent/passthrough-help"; +import { printAgentPassthroughHelp } from "../../lib/actions/sandbox/agent/passthrough-help"; import { runAgentPassthrough } from "../../lib/actions/sandbox/agent/passthrough"; import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; export default class SandboxAgentCommand extends NemoClawCommand { static id = "sandbox:agent"; static strict = false; - static summary = "Run one OpenClaw agent turn non-interactively in a sandbox"; + static summary = "Run one agent turn non-interactively in a sandbox"; static description = - "Pass through to `openclaw agent` inside the sandbox via `openshell sandbox exec`. Stream the agent's response back to stdout without owning a TTY; useful for driving the sandbox from another process (CI job, multi-agent platform, evaluation harness). All flags accepted by the in-sandbox OpenClaw CLI are forwarded verbatim, including `-m `, `--session-id `, `--agent `, `--json`, `--thinking `, `--deliver`, and `--reply-channel`. Currently supported on OpenClaw sandboxes only; Hermes sandboxes exit non-zero with a redirect to the OpenAI-compatible API on port 8642 inside the sandbox."; - static usage = [" [openclaw-agent-flags...]"]; + "Pass through to the sandbox's registered agent command via `openshell sandbox exec`. OpenClaw sandboxes run `openclaw agent`; terminal-runtime sandboxes run their manifest-declared interactive command, such as `dcode` for LangChain Deep Agents Code. Stream the agent's response back to stdout without owning a TTY; useful for driving the sandbox from another process (CI job, multi-agent platform, evaluation harness). Hermes sandboxes exit non-zero with a redirect to the OpenAI-compatible API on port 8642 inside the sandbox."; + static usage = [" [agent-flags...]"]; static examples = [ '<%= config.bin %> sandbox agent alpha --agent work -m "Summarise README.md"', '<%= config.bin %> sandbox agent alpha --agent work -m "Status update?"', @@ -34,14 +31,6 @@ export default class SandboxAgentCommand extends NemoClawCommand { printAgentPassthroughHelp(); return; } - // A bare ` agent` with no further args cannot succeed in-sandbox - // (`openclaw agent` requires `-m`), so treat it like a help request and - // print the wrapper summary locally instead of paying sandbox-exec latency - // only to surface an upstream "Missing required option -m" error (#5658). - if (extraArgs.length === 0 || hasAgentPassthroughHelpToken(extraArgs)) { - printAgentPassthroughHelp(); - return; - } await runAgentPassthrough(sandboxName, { extraArgs }); } } diff --git a/src/lib/actions/sandbox/agent/passthrough-help.test.ts b/src/lib/actions/sandbox/agent/passthrough-help.test.ts index 80b26719ddd..a0bda1771ff 100644 --- a/src/lib/actions/sandbox/agent/passthrough-help.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough-help.test.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { hasAgentPassthroughHelpToken } from "./passthrough-help"; +import { hasAgentPassthroughHelpToken, printAgentPassthroughHelp } from "./passthrough-help"; describe("hasAgentPassthroughHelpToken", () => { it("returns true for --help before the OpenClaw argv separator", () => { @@ -20,3 +20,23 @@ describe("hasAgentPassthroughHelpToken", () => { expect(hasAgentPassthroughHelpToken([])).toBe(false); }); }); + +describe("printAgentPassthroughHelp", () => { + it("describes both OpenClaw and terminal-runtime passthroughs (#5790)", () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + let output = ""; + try { + printAgentPassthroughHelp(); + output = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + } finally { + logSpy.mockRestore(); + } + + expect(output).toContain("[agent-flags...]"); + expect(output).toContain("registered agent command"); + expect(output).toContain("OpenClaw sandboxes run `openclaw agent ...`"); + expect(output).toContain("terminal-runtime sandboxes run"); + expect(output).toContain("`dcode ...`"); + expect(output).not.toContain("OpenClaw sandboxes only"); + }); +}); diff --git a/src/lib/actions/sandbox/agent/passthrough-help.ts b/src/lib/actions/sandbox/agent/passthrough-help.ts index 9e1326a3abe..967c77ded90 100644 --- a/src/lib/actions/sandbox/agent/passthrough-help.ts +++ b/src/lib/actions/sandbox/agent/passthrough-help.ts @@ -13,32 +13,31 @@ export function hasAgentPassthroughHelpToken(args: readonly string[]): boolean { export function printAgentPassthroughHelp(): void { console.log(""); - console.log(` Usage: ${CLI_NAME} agent [openclaw-agent-flags...]`); + console.log(` Usage: ${CLI_NAME} agent [agent-flags...]`); console.log(""); console.log( - " Pass-through to `openclaw agent ...` inside the sandbox via `openshell sandbox exec`.", + " Pass-through to the sandbox's registered agent command via `openshell sandbox exec`.", ); - console.log(" All flags accepted by the in-sandbox OpenClaw CLI are forwarded verbatim."); + console.log(" OpenClaw sandboxes run `openclaw agent ...`; terminal-runtime sandboxes run"); console.log( - " Common flags: -m , --session-id , --agent , --json, --thinking .", - ); - console.log(""); - console.log( - " Every invocation must include at least one target selector — --agent, --session-id,", - ); - console.log( - " --session-key, or --to. On Ready/Running sandboxes, invocations without a selector", + " their manifest-declared interactive command, such as `dcode ...` for Deep Agents Code.", ); + console.log(" All flags accepted by the selected in-sandbox agent CLI are forwarded verbatim."); console.log( - " exit 2 with `No target session selected` before any in-sandbox dispatch runs; on a", + " Common OpenClaw flags: -m , --session-id , --agent , --json, --thinking .", ); + console.log(""); + console.log(" OpenClaw invocations must include at least one target selector — --agent,"); + console.log(" --session-id, --session-key, or --to. On Ready/Running OpenClaw sandboxes,"); + console.log(" invocations without a selector exit 2 with `No target session selected`; on a"); console.log( " non-Ready sandbox the phase guard fires first and exits 1 with recovery commands.", ); console.log(""); - console.log( - " Currently supported on OpenClaw sandboxes only; Hermes sandboxes are rejected with a", - ); + console.log(` For terminal-runtime help, run \`${CLI_NAME} agent --help\` to view the`); + console.log(" upstream command help from inside the sandbox."); + console.log(""); + console.log(" Hermes sandboxes are rejected with a"); console.log(" redirect to the OpenAI-compatible API on port 8642 inside the sandbox."); console.log(""); } diff --git a/src/lib/actions/sandbox/agent/passthrough.test.ts b/src/lib/actions/sandbox/agent/passthrough.test.ts index 58455796168..7ea02e7f7a7 100644 --- a/src/lib/actions/sandbox/agent/passthrough.test.ts +++ b/src/lib/actions/sandbox/agent/passthrough.test.ts @@ -1,21 +1,45 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const execMock = vi.hoisted(() => vi.fn(async () => {})); const ensureLiveMock = vi.hoisted(() => vi.fn(async () => ({ state: "present", output: "Phase: Ready" }) as { output?: string }), ); -const getSandboxMock = vi.hoisted(() => vi.fn(() => null as { agent?: string } | null)); +const getSandboxMock = vi.hoisted(() => vi.fn(() => null as { agent?: string | null } | null)); +const listAgentsMock = vi.hoisted(() => + vi.fn(() => ["custom-terminal", "hermes", "langchain-deepagents-code", "openclaw"]), +); +const loadAgentMock = vi.hoisted(() => + vi.fn((name: string) => ({ + name, + runtime: + name === "langchain-deepagents-code" + ? { kind: "terminal", interactive_command: "dcode", headless_command: "dcode -n" } + : undefined, + })), +); +const isTerminalAgentMock = vi.hoisted(() => + vi.fn((agent: { runtime?: { kind?: string } }) => agent.runtime?.kind === "terminal"), +); vi.mock("../exec", () => ({ execSandbox: execMock })); vi.mock("../gateway-state", () => ({ ensureLiveSandboxOrExit: ensureLiveMock })); vi.mock("../../../state/registry", () => ({ getSandbox: getSandboxMock })); +vi.mock("../../../agent/defs", () => ({ + isTerminalAgent: isTerminalAgentMock, + listAgents: listAgentsMock, + loadAgent: loadAgentMock, +})); import { runAgentPassthrough } from "./passthrough"; describe("runAgentPassthrough", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + function makeProcMock() { const writes: string[] = []; const exit = vi.fn((code: number) => { @@ -32,8 +56,6 @@ describe("runAgentPassthrough", () => { } it("rejects Hermes sandboxes with a redirect to the OpenAI-compatible API", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); getSandboxMock.mockReturnValueOnce({ agent: "hermes" }); const { writes, exit, proc } = makeProcMock(); await expect( @@ -42,15 +64,11 @@ describe("runAgentPassthrough", () => { expect(execMock).not.toHaveBeenCalled(); expect(ensureLiveMock).not.toHaveBeenCalled(); expect(exit).toHaveBeenCalledWith(2); - expect(writes.join("")).toMatch( - /Only OpenClaw sandboxes support the `sandbox agent` wrapper today \(sandbox 'alpha' runs 'hermes'\)/, - ); + expect(writes.join("")).toMatch(/cannot dispatch to sandbox 'alpha' because it runs 'hermes'/); expect(writes.join("")).toMatch(/port 8642/); }); it("forwards extraArgs verbatim to `openclaw agent` for OpenClaw sandboxes with --no-tty enforced", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); await runAgentPassthrough("alpha", { extraArgs: ["--agent", "work", "--session-id", "s-1", "-m", "ping", "--json"], @@ -63,8 +81,42 @@ describe("runAgentPassthrough", () => { ); }); + it("keeps OpenClaw --help local so wrapper docs parity stays offline", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await runAgentPassthrough("alpha", { extraArgs: ["--help"] }); + } finally { + logSpy.mockRestore(); + } + expect(ensureLiveMock).not.toHaveBeenCalled(); + expect(execMock).not.toHaveBeenCalled(); + }); + + it("dispatches Deep Agents Code help to dcode instead of local wrapper help (#5790)", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "langchain-deepagents-code" }); + await runAgentPassthrough("dcode-help", { extraArgs: ["--help"] }); + expect(ensureLiveMock).toHaveBeenCalledWith("dcode-help", { allowNonReadyPhase: true }); + expect(execMock).toHaveBeenCalledWith("dcode-help", ["dcode", "--help"], { tty: false }); + }); + + it("dispatches bare Deep Agents Code invocations to dcode so upstream owns exit code (#5790)", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "langchain-deepagents-code" }); + await runAgentPassthrough("dcode-help"); + expect(execMock).toHaveBeenCalledWith("dcode-help", ["dcode"], { tty: false }); + }); + + it("propagates bare Deep Agents Code non-zero exits from the sandbox exec path (#5790)", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "langchain-deepagents-code" }); + execMock.mockRejectedValueOnce(new Error("__exit:42")); + + await expect(runAgentPassthrough("dcode-fail")).rejects.toThrow("__exit:42"); + + expect(ensureLiveMock).toHaveBeenCalledWith("dcode-fail", { allowNonReadyPhase: true }); + expect(execMock).toHaveBeenCalledWith("dcode-fail", ["dcode"], { tty: false }); + }); + it("treats a clean registry miss as OpenClaw (preserves bootstrap and recovery paths)", async () => { - execMock.mockClear(); getSandboxMock.mockReturnValueOnce(null); await runAgentPassthrough("ghost", { extraArgs: ["--agent", "main", "-m", "hi"] }); expect(execMock).toHaveBeenCalledWith( @@ -74,9 +126,19 @@ describe("runAgentPassthrough", () => { ); }); + it("keeps registry-miss --help local for offline docs parity", async () => { + getSandboxMock.mockReturnValueOnce(null); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await runAgentPassthrough("placeholder-sandbox", { extraArgs: ["--help"] }); + } finally { + logSpy.mockRestore(); + } + expect(ensureLiveMock).not.toHaveBeenCalled(); + expect(execMock).not.toHaveBeenCalled(); + }); + it("fails closed when the registry read throws and never spawns OpenShell exec", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); getSandboxMock.mockImplementationOnce(() => { throw new Error("EACCES: permission denied, open '~/.config/nemoclaw/sandboxes.json'"); }); @@ -93,9 +155,65 @@ describe("runAgentPassthrough", () => { expect(all).toMatch(/EACCES/); }); - it("rejects with exit 2 when no target selector flag is present on a Ready sandbox", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); + it("fails closed when a registered agent is not in the manifest allowlist", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "../missing-agent" }); + const { writes, exit, proc } = makeProcMock(); + await expect( + runAgentPassthrough("../missing-agent", { extraArgs: ["--help"] }, { process: proc }), + ).rejects.toThrow("__exit:2"); + expect(execMock).not.toHaveBeenCalled(); + expect(ensureLiveMock).not.toHaveBeenCalled(); + expect(loadAgentMock).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(2); + const all = writes.join(""); + expect(all).toMatch(/registered agent '\.\.\/missing-agent'/); + expect(all).toMatch(/not present in the local agent manifest allowlist/); + expect(all).toMatch(/Refusing to dispatch/); + }); + + it("fails closed when a known registered agent cannot be resolved before OpenShell exec", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "missing-agent" }); + listAgentsMock.mockReturnValueOnce(["missing-agent"]); + loadAgentMock.mockImplementationOnce(() => { + throw new Error("Agent manifest not found: agents/missing-agent/manifest.yaml"); + }); + const { writes, exit, proc } = makeProcMock(); + await expect( + runAgentPassthrough("alpha", { extraArgs: ["-m", "hi"] }, { process: proc }), + ).rejects.toThrow("__exit:2"); + expect(execMock).not.toHaveBeenCalled(); + expect(ensureLiveMock).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(2); + const all = writes.join(""); + expect(all).toMatch(/registered agent 'missing-agent'/); + expect(all).toMatch(/Agent manifest not found/); + expect(all).toMatch(/Refusing to dispatch/); + }); + + it("fails closed for quoted terminal manifest commands instead of splitting them incorrectly", async () => { + getSandboxMock.mockReturnValueOnce({ agent: "custom-terminal" }); + loadAgentMock.mockReturnValueOnce({ + name: "custom-terminal", + runtime: { + kind: "terminal", + interactive_command: 'tool --profile "Deep Agents"', + headless_command: "tool -n", + }, + }); + const { writes, exit, proc } = makeProcMock(); + await expect( + runAgentPassthrough("quoted-terminal", { extraArgs: ["--help"] }, { process: proc }), + ).rejects.toThrow("__exit:2"); + expect(execMock).not.toHaveBeenCalled(); + expect(ensureLiveMock).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(2); + const all = writes.join(""); + expect(all).toMatch(/registered agent 'custom-terminal'/); + expect(all).toMatch(/simple whitespace-delimited argv tokens/); + expect(all).toMatch(/quoted or escaped shell syntax is not supported/); + }); + + it("rejects with exit 2 when no target selector flag is present on a Ready OpenClaw sandbox", async () => { getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); await expect( @@ -110,9 +228,7 @@ describe("runAgentPassthrough", () => { expect(all).toMatch(/openclaw agents list/); }); - it("rejects with exit 2 when extraArgs is empty on a Ready sandbox", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); + it("rejects with exit 2 when extraArgs is empty on a Ready OpenClaw sandbox", async () => { getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { exit, proc } = makeProcMock(); await expect(runAgentPassthrough("alpha", {}, { process: proc })).rejects.toThrow("__exit:2"); @@ -122,8 +238,6 @@ describe("runAgentPassthrough", () => { }); it("prints recovery hints with exit 1 before selector rejection when the sandbox phase is non-Ready (covers the literal #5655 stopped-sandbox repro `agent -m ping`)", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); ensureLiveMock.mockResolvedValueOnce({ output: "Phase: Error" }); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); @@ -142,8 +256,6 @@ describe("runAgentPassthrough", () => { }); it("rejects with exit 2 when the selector token appears after the `--` argv separator", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); await expect( @@ -159,7 +271,6 @@ describe("runAgentPassthrough", () => { }); it("accepts selector in --flag=value form and forwards verbatim", async () => { - execMock.mockClear(); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); await runAgentPassthrough("alpha", { extraArgs: ["--session-key=abc-123", "-m", "ping"], @@ -172,8 +283,6 @@ describe("runAgentPassthrough", () => { }); it("rejects with exit 1 + recovery hints when sandbox phase is non-Ready", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); ensureLiveMock.mockResolvedValueOnce({ output: "Phase: Error" }); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); @@ -196,8 +305,6 @@ describe("runAgentPassthrough", () => { }); it("fails closed with exit 2 when ensureLive returns output without a parseable Phase line, never invoking exec", async () => { - execMock.mockClear(); - ensureLiveMock.mockClear(); ensureLiveMock.mockResolvedValueOnce({ output: "Name: alpha\n(no phase line here)\n" }); getSandboxMock.mockReturnValueOnce({ agent: "openclaw" }); const { writes, exit, proc } = makeProcMock(); @@ -212,6 +319,6 @@ describe("runAgentPassthrough", () => { expect(exit).toHaveBeenCalledWith(2); const all = writes.join(""); expect(all).toMatch(/Could not parse a 'Phase:' line/); - expect(all).toMatch(/Refusing to forward/); + expect(all).toMatch(/Refusing to dispatch/); }); }); diff --git a/src/lib/actions/sandbox/agent/passthrough.ts b/src/lib/actions/sandbox/agent/passthrough.ts index f6533bb0115..6dd40a7c286 100644 --- a/src/lib/actions/sandbox/agent/passthrough.ts +++ b/src/lib/actions/sandbox/agent/passthrough.ts @@ -13,10 +13,13 @@ // an in-sandbox binary that does not exist (or exists with incompatible // flags), and would silently bypass the host-side guard intended to // redirect Hermes callers to the OpenAI-compatible API on port 8642. -// - Source boundary: the registry is NemoClaw-owned; the in-sandbox agent -// invocation, its argv contract, and its streaming behaviour are owned -// by upstream OpenClaw. NemoClaw does not rewrite OpenClaw flags here; -// it forwards them verbatim. +// - Source boundary: the registry and agent manifest allowlist are +// NemoClaw-owned. The in-sandbox invocation, its argv contract, and its +// streaming behaviour are owned by the selected upstream agent command. +// NemoClaw does not rewrite agent flags here; it forwards them verbatim. +// Terminal-runtime dispatch uses the manifest command only when it can +// be represented as simple whitespace-delimited argv tokens; shell +// quoting/escaping fails closed until manifests expose argv natively. // - Source-fix constraint: NemoClaw cannot prove agent type from anywhere // except the registry, because the OpenShell exec transport has no // pre-execution probe that reveals the sandbox's configured agent. A @@ -65,10 +68,11 @@ // the in-sandbox binary. // // Regression tests: `passthrough.test.ts` covers the Hermes redirect, the -// registry-miss fallback to OpenClaw, the registry-error fail-closed path, -// the enforced `--no-tty` argv shape, the non-Ready phase recovery path, -// the unparseable phase fail-closed path, the no-selector and empty-args -// rejection branches, and the `--flag=value` selector-acceptance branch. +// forwarded argv, the registry-miss fallback to OpenClaw, registry and +// manifest-resolution fail-closed paths, quoted manifest command rejection, +// the enforced `--no-tty` argv shape, the non-Ready phase recovery path, the +// unparseable phase fail-closed path, the OpenClaw no-selector rejection, and +// the `--flag=value` selector-acceptance branch. // // Removal conditions: // @@ -78,12 +82,16 @@ // readiness or recovery guidance itself. // - Drop the selector mirror when upstream `openclaw agent` rejects a // missing selector with a clean exit 2 and an actionable message. +// - Drop the simple-token parser when terminal runtime manifests expose +// argv arrays natively. +import { type AgentDefinition, isTerminalAgent, listAgents, loadAgent } from "../../../agent/defs"; import { CLI_NAME } from "../../../cli/branding"; -import * as registry from "../../../state/registry"; import { parseSandboxPhase } from "../../../state/gateway"; +import * as registry from "../../../state/registry"; import { execSandbox } from "../exec"; import { ensureLiveSandboxOrExit } from "../gateway-state"; +import { hasAgentPassthroughHelpToken, printAgentPassthroughHelp } from "./passthrough-help"; export { hasAgentPassthroughHelpToken, @@ -108,6 +116,10 @@ type RegistryReadResult = | { kind: "missing" } | { kind: "agent"; agent: string | null } | { kind: "error"; message: string }; +type ResolvedRegistryReadResult = Exclude; +type TerminalCommandResult = + | { kind: "command"; argv: string[] } + | { kind: "unsupported"; message: string }; function readSandboxAgentFromRegistry( sandboxName: string, @@ -128,7 +140,7 @@ function rejectNonOpenclawAgent( proc: NonNullable, ): never { proc.stderr.write( - ` Only OpenClaw sandboxes support the \`sandbox agent\` wrapper today (sandbox '${sandboxName}' runs '${agent}').\n`, + ` The \`sandbox agent\` wrapper cannot dispatch to sandbox '${sandboxName}' because it runs '${agent}'.\n`, ); proc.stderr.write(" Hermes exposes an OpenAI-compatible API on port 8642 inside the sandbox;\n"); proc.stderr.write( @@ -138,6 +150,112 @@ function rejectNonOpenclawAgent( return proc.exit(2); } +function rejectAgentResolutionError( + sandboxName: string, + agent: string, + message: string, + proc: NonNullable, +): never { + proc.stderr.write( + ` Could not resolve a passthrough command for registered agent '${agent}' in sandbox '${sandboxName}'.\n`, + ); + proc.stderr.write(` Agent resolution error: ${message}\n`); + proc.stderr.write(" Refusing to dispatch because the sandbox agent guard cannot fail closed.\n"); + return proc.exit(2); +} + +function ensureRegisteredAgentIsKnown( + sandboxName: string, + agent: string, + proc: NonNullable, +): void { + let knownAgents: string[]; + try { + knownAgents = listAgents(); + } catch (error) { + rejectAgentResolutionError( + sandboxName, + agent, + `Could not read local agent manifest allowlist: ${(error as Error).message}`, + proc, + ); + } + if (!knownAgents.includes(agent)) { + rejectAgentResolutionError( + sandboxName, + agent, + "Registered agent is not present in the local agent manifest allowlist", + proc, + ); + } +} + +function splitManifestCommand(command: string): TerminalCommandResult { + const trimmed = command.trim(); + if (!trimmed) return { kind: "command", argv: [] }; + if (/["'\\]/.test(trimmed)) { + return { + kind: "unsupported", + message: + "terminal runtime commands must be simple whitespace-delimited argv tokens; quoted or escaped shell syntax is not supported", + }; + } + return { kind: "command", argv: trimmed.split(/\s+/).filter(Boolean) }; +} + +function getTerminalInteractiveCommand(agent: AgentDefinition): TerminalCommandResult { + const command = agent.runtime?.interactive_command ?? agent.runtime?.headless_command ?? ""; + return splitManifestCommand(command); +} + +function getPassthroughCommand( + sandboxName: string, + lookup: ResolvedRegistryReadResult, + extraArgs: readonly string[], + proc: NonNullable, +): string[] | null { + if (lookup.kind === "missing") { + if (hasAgentPassthroughHelpToken(extraArgs)) { + printAgentPassthroughHelp(); + return null; + } + return ["openclaw", "agent", ...extraArgs]; + } + + const agentName = lookup.agent; + if (agentName === null || agentName === "openclaw") { + if (hasAgentPassthroughHelpToken(extraArgs)) { + printAgentPassthroughHelp(); + return null; + } + return ["openclaw", "agent", ...extraArgs]; + } + + ensureRegisteredAgentIsKnown(sandboxName, agentName, proc); + let agent: AgentDefinition; + try { + agent = loadAgent(agentName); + } catch (error) { + rejectAgentResolutionError(sandboxName, agentName, (error as Error).message, proc); + } + if (!isTerminalAgent(agent)) { + rejectNonOpenclawAgent(sandboxName, agentName, proc); + } + + const terminalCommand = getTerminalInteractiveCommand(agent); + if (terminalCommand.kind === "unsupported") { + rejectAgentResolutionError(sandboxName, agentName, terminalCommand.message, proc); + } + if (terminalCommand.argv.length === 0) { + rejectNonOpenclawAgent(sandboxName, agentName, proc); + } + return [...terminalCommand.argv, ...extraArgs]; +} + +function isOpenClawPassthroughCommand(command: readonly string[]): boolean { + return command[0] === "openclaw" && command[1] === "agent"; +} + function rejectRegistryReadError( sandboxName: string, message: string, @@ -181,7 +299,7 @@ function rejectUnparseablePhase( ` Could not parse a 'Phase:' line from the live state of sandbox '${sandboxName}'.\n`, ); proc.stderr.write( - " Refusing to forward to `openclaw agent` because the readiness guard cannot fail closed.\n", + " Refusing to dispatch the agent command because the readiness guard cannot fail closed.\n", ); proc.stderr.write( ` Run \`${CLI_NAME} ${sandboxName} status\` to inspect the gateway-state output.\n`, @@ -220,9 +338,8 @@ export async function runAgentPassthrough( if (lookup.kind === "error") { rejectRegistryReadError(sandboxName, lookup.message, proc); } - if (lookup.kind === "agent" && lookup.agent && lookup.agent !== "openclaw") { - rejectNonOpenclawAgent(sandboxName, lookup.agent, proc); - } + const command = getPassthroughCommand(sandboxName, lookup, extraArgs, proc); + if (!command) return; const ensureLive = deps.ensureLive ?? ensureLiveSandboxOrExit; const state = await ensureLive(sandboxName, { allowNonReadyPhase: true }); const phase = parseSandboxPhase(state?.output ?? ""); @@ -232,10 +349,9 @@ export async function runAgentPassthrough( if (phase !== "Ready" && phase !== "Running") { rejectNotReadyForAgent(sandboxName, phase, proc); } - if (!hasTargetSelector(extraArgs)) { + if (isOpenClawPassthroughCommand(command) && !hasTargetSelector(extraArgs)) { rejectNoTargetSelector(proc); } - const command = ["openclaw", "agent", ...extraArgs]; const exec = deps.exec ?? execSandbox; await exec(sandboxName, command, { tty: false }); }