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
11 changes: 9 additions & 2 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -551,17 +551,24 @@ Use this when driving the sandbox programmatically from another process (CI job,

All flags accepted by the in-sandbox OpenClaw 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.

```bash
$$nemoclaw my-assistant agent -m "Summarise README.md"
$$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 --json -m 'ping'
$$nemoclaw my-assistant agent --session-key intake-42 --json -m 'ping'
```

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.

Common upstream flags include `-m <text>`, `--session-id <id>`, `--agent <id>`, `--model <id>`, `--thinking <level>`, `--json`, `--deliver`, `--reply-channel <channel>`, and `--timeout <seconds>`. Run `$$nemoclaw <name> agent --help` for the wrapper-level summary, or invoke `$$nemoclaw <name> exec -- openclaw agent --help` to view the upstream OpenClaw help text directly.

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.
- If the sandbox is registered but not in a `Ready` or `Running` phase, the wrapper exits `1` and prints the documented recovery commands (`$$nemoclaw <name> recover`, `$$nemoclaw <name> rebuild --yes`, `$$nemoclaw onboard --resume`) rather than deferring the readiness rejection to `openshell sandbox exec`.

</AgentOnly>
<AgentOnly variant="hermes">

Expand Down
4 changes: 2 additions & 2 deletions src/commands/sandbox/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export default class SandboxAgentCommand extends NemoClawCommand {
"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 <text>`, `--session-id <id>`, `--agent <id>`, `--json`, `--thinking <level>`, `--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 = ["<name> [openclaw-agent-flags...]"];
static examples = [
'<%= config.bin %> sandbox agent alpha -m "Summarise README.md"',
'<%= config.bin %> sandbox agent alpha --agent work -m "Summarise README.md"',
'<%= config.bin %> sandbox agent alpha --agent work -m "Status update?"',
'<%= config.bin %> sandbox agent alpha --session-id review-42 -m "Any new findings?"',
"<%= config.bin %> sandbox agent alpha --json -m 'ping'",
"<%= config.bin %> sandbox agent alpha --session-key intake-42 --json -m 'ping'",
];

public async run(): Promise<void> {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ export function printAgentPassthroughHelp(): void {
" Common flags: -m <text>, --session-id <id>, --agent <id>, --json, --thinking <level>.",
);
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",
);
console.log(
" exit 2 with `No target session selected` before any in-sandbox dispatch runs; 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",
);
Expand Down
135 changes: 127 additions & 8 deletions src/lib/actions/sandbox/agent/passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import { describe, expect, it, vi } from "vitest";

const execMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureLiveMock = 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));

vi.mock("../exec", () => ({ execSandbox: execMock }));
Expand Down Expand Up @@ -64,10 +66,12 @@ describe("runAgentPassthrough", () => {
it("treats a clean registry miss as OpenClaw (preserves bootstrap and recovery paths)", async () => {
execMock.mockClear();
getSandboxMock.mockReturnValueOnce(null);
await runAgentPassthrough("ghost", { extraArgs: ["-m", "hi"] });
expect(execMock).toHaveBeenCalledWith("ghost", ["openclaw", "agent", "-m", "hi"], {
tty: false,
});
await runAgentPassthrough("ghost", { extraArgs: ["--agent", "main", "-m", "hi"] });
expect(execMock).toHaveBeenCalledWith(
"ghost",
["openclaw", "agent", "--agent", "main", "-m", "hi"],
{ tty: false },
);
});

it("fails closed when the registry read throws and never spawns OpenShell exec", async () => {
Expand All @@ -89,10 +93,125 @@ describe("runAgentPassthrough", () => {
expect(all).toMatch(/EACCES/);
});

it("works with no extraArgs and still enforces --no-tty", async () => {
it("rejects with exit 2 when no target selector flag is present on a Ready sandbox", async () => {
execMock.mockClear();
ensureLiveMock.mockClear();
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });
await runAgentPassthrough("alpha");
expect(execMock).toHaveBeenCalledWith("alpha", ["openclaw", "agent"], { tty: false });
const { writes, exit, proc } = makeProcMock();
await expect(
runAgentPassthrough("alpha", { extraArgs: ["-m", "hi"] }, { process: proc }),
).rejects.toThrow("__exit:2");
expect(execMock).not.toHaveBeenCalled();
expect(ensureLiveMock).toHaveBeenCalledWith("alpha", { allowNonReadyPhase: true });
expect(exit).toHaveBeenCalledWith(2);
const all = writes.join("");
expect(all).toMatch(/No target session selected/);
expect(all).toMatch(/--agent <id>/);
expect(all).toMatch(/openclaw agents list/);
});

it("rejects with exit 2 when extraArgs is empty on a Ready sandbox", async () => {
execMock.mockClear();
ensureLiveMock.mockClear();
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });
const { exit, proc } = makeProcMock();
await expect(runAgentPassthrough("alpha", {}, { process: proc })).rejects.toThrow("__exit:2");
expect(execMock).not.toHaveBeenCalled();
expect(ensureLiveMock).toHaveBeenCalledWith("alpha", { allowNonReadyPhase: true });
expect(exit).toHaveBeenCalledWith(2);
});

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();
await expect(
runAgentPassthrough("my-assistant", { extraArgs: ["-m", "ping"] }, { process: proc }),
).rejects.toThrow("__exit:1");
expect(execMock).not.toHaveBeenCalled();
expect(ensureLiveMock).toHaveBeenCalledWith("my-assistant", { allowNonReadyPhase: true });
expect(exit).toHaveBeenCalledWith(1);
const all = writes.join("");
expect(all).toMatch(
/Sandbox 'my-assistant' is not ready for the agent wrapper \(phase: Error\)/,
);
expect(all).toMatch(/my-assistant recover/);
expect(all).not.toMatch(/No target session selected/);
});

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(
runAgentPassthrough(
"alpha",
{ extraArgs: ["--", "--agent", "work", "-m", "hi"] },
{ process: proc },
),
).rejects.toThrow("__exit:2");
expect(execMock).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(2);
expect(writes.join("")).toMatch(/No target session selected/);
});

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"],
});
expect(execMock).toHaveBeenCalledWith(
"alpha",
["openclaw", "agent", "--session-key=abc-123", "-m", "ping"],
{ tty: false },
);
});

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();
await expect(
runAgentPassthrough(
"my-assistant",
{ extraArgs: ["--agent", "main", "-m", "hi"] },
{ process: proc },
),
).rejects.toThrow("__exit:1");
expect(execMock).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(1);
const all = writes.join("");
expect(all).toMatch(
/Sandbox 'my-assistant' is not ready for the agent wrapper \(phase: Error\)/,
);
expect(all).toMatch(/my-assistant recover/);
expect(all).toMatch(/my-assistant rebuild --yes/);
expect(all).toMatch(/onboard --resume/);
});

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();
await expect(
runAgentPassthrough(
"alpha",
{ extraArgs: ["--agent", "main", "-m", "hi"] },
{ process: proc },
),
).rejects.toThrow("__exit:2");
expect(execMock).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(2);
const all = writes.join("");
expect(all).toMatch(/Could not parse a 'Phase:' line/);
expect(all).toMatch(/Refusing to forward/);
});
});
Loading
Loading