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
7 changes: 6 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,12 @@ $$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 the in-sandbox agent command emits on `stdout`; the wrapper adds no buffering.
For normal turns, streaming forwards whatever the in-sandbox agent command emits on `stdout`; the wrapper adds no buffering.
When the top-level OpenClaw `--json` output flag is present, the wrapper uses a captured no-TTY path with a `64 MiB` buffer so `stdout` stays parseable JSON.
Raw `stderr` is forwarded, and failed-tool or untrusted-child provenance found in the stdout JSON is appended to `stderr`.
Literal `--json` values consumed by flags such as `-m` or `--reply-channel`, or arguments after `--`, stay on the normal passthrough path.
Documented value flags written as `--flag=value`, such as `--session-id=s1`, are recognized the same way as separated value flags.
If an unrecognized OpenClaw option appears before `--json`, NemoClaw also keeps the command on the normal passthrough path so OpenClaw remains the argv source of truth.

Common OpenClaw flags include `-m <text>`, `--session-id <id>`, `--agent <id>`, `--model <id>`, `--thinking <level>`, `--json`, `--deliver`, `--reply-channel <channel>`, and `--timeout <seconds>`.
For OpenClaw sandboxes and registry fallbacks, `$$nemoclaw <name> agent --help` prints the wrapper-level summary locally.
Expand Down
4 changes: 2 additions & 2 deletions src/commands/sandbox/agent.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { printAgentPassthroughHelp } from "../../lib/actions/sandbox/agent/passthrough-help";
import { runAgentPassthrough } from "../../lib/actions/sandbox/agent/passthrough";
import { printAgentPassthroughHelp } from "../../lib/actions/sandbox/agent/passthrough-help";
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 agent turn non-interactively in a sandbox";
static description =
"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.";
"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. Normal turns stream the agent's response without owning a TTY; top-level OpenClaw `--json` uses a captured path that preserves JSON stdout and appends provenance to stderr. 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 = ["<name> [agent-flags...]"];
static examples = [
'<%= config.bin %> sandbox agent alpha --agent work -m "Summarise README.md"',
Expand Down
165 changes: 165 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// 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 { runAgentJsonPassthrough } from "./passthrough-json";

describe("runAgentJsonPassthrough", () => {
function makeProc() {
const stdout: string[] = [];
const stderr: string[] = [];
const exit = vi.fn((code: number) => {
throw new Error(`__exit:${code}`);
});
return {
exit,
proc: {
exit: exit as unknown as (code: number) => never,
stdout: { write: (value: string) => stdout.push(value) },
stderr: { write: (value: string) => stderr.push(value) },
},
stderr,
stdout,
};
}

it("preserves OpenClaw JSON stdout and appends failed-tool provenance to stderr", () => {
const payload = JSON.stringify({
result: {
messages: [
{
role: "toolResult",
type: "toolResult",
toolName: "exec",
toolCallId: "call_missing",
isError: true,
text: "exec failed: node-not-real: not found",
},
],
payloads: [{ text: "Saved successfully." }],
},
});
const spawnSync = vi.fn(() => ({
status: 0,
signal: null,
stdout: payload,
stderr: "openclaw warning\n",
pid: 123,
output: [null, payload, "openclaw warning\n"],
}));
const { exit, proc, stderr, stdout } = makeProc();

expect(() =>
runAgentJsonPassthrough("alpha", ["openclaw", "agent", "--json"], proc, {
getOpenshellBinary: () => "/usr/local/bin/openshell",
spawnSync,
}),
).toThrow("__exit:0");

expect(spawnSync).toHaveBeenCalledWith(
"/usr/local/bin/openshell",
["sandbox", "exec", "--name", "alpha", "--no-tty", "--", "openclaw", "agent", "--json"],
expect.objectContaining({
encoding: "utf-8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["inherit", "pipe", "pipe"],
}),
);
expect(stdout.join("")).toBe(payload);
expect(() => JSON.parse(stdout.join(""))).not.toThrow();
expect(stderr.join("")).toContain("openclaw warning");
expect(stderr.join("")).toContain("[openclaw provenance] failed tool result");
expect(stderr.join("")).toContain("node-not-real");
expect(exit).toHaveBeenCalledWith(0);
});

it("surfaces spawn errors and exits with the computed transport failure code", () => {
const spawnSync = vi.fn(() => ({
status: null,
signal: null,
stdout: "",
stderr: "",
error: new Error("spawnSync openshell ENOENT"),
pid: 0,
output: [null, "", ""],
}));
const { exit, proc, stderr } = makeProc();

expect(() =>
runAgentJsonPassthrough("alpha", ["openclaw", "agent", "--json"], proc, {
getOpenshellBinary: () => "openshell",
spawnSync,
}),
).toThrow("__exit:1");

expect(stderr.join("")).toContain("Failed to invoke openshell");
expect(stderr.join("")).toContain("spawnSync openshell ENOENT");
expect(exit).toHaveBeenCalledWith(1);
});

it("does not treat stderr JSON diagnostics as agent provenance", () => {
const stdoutPayload = JSON.stringify({ result: { payloads: [{ text: "OK" }] } });
const stderrPayload = JSON.stringify({
messages: [
{
role: "toolResult",
type: "toolResult",
toolName: "stderr-diagnostic",
toolCallId: "call_stderr",
isError: true,
text: "this was not part of stdout JSON",
},
],
});
const spawnSync = vi.fn(() => ({
status: 0,
signal: null,
stdout: stdoutPayload,
stderr: stderrPayload,
pid: 123,
output: [null, stdoutPayload, stderrPayload],
}));
const { proc, stderr } = makeProc();

expect(() =>
runAgentJsonPassthrough("alpha", ["openclaw", "agent", "--json"], proc, {
getOpenshellBinary: () => "/usr/local/bin/openshell",
spawnSync,
}),
).toThrow("__exit:0");

expect(stderr.join("")).toContain("stderr-diagnostic");
expect(stderr.join("")).not.toContain("[openclaw provenance]");
});

it("preserves forwarded output and remote exit code when provenance parsing fails", () => {
const stdoutPayload = JSON.stringify({ result: { payloads: [{ text: "OK" }] } });
const spawnSync = vi.fn(() => ({
status: 7,
signal: null,
stdout: stdoutPayload,
stderr: "openclaw warning",
pid: 123,
output: [null, stdoutPayload, "openclaw warning"],
}));
const { exit, proc, stderr, stdout } = makeProc();

expect(() =>
runAgentJsonPassthrough("alpha", ["openclaw", "agent", "--json"], proc, {
getOpenshellBinary: () => "/usr/local/bin/openshell",
provenanceLines: () => {
throw new RangeError("Maximum call stack size exceeded");
},
spawnSync,
}),
).toThrow("__exit:7");

expect(stdout.join("")).toBe(stdoutPayload);
expect(stderr.join("")).toContain("openclaw warning");
expect(stderr.join("")).toContain(
"[openclaw provenance] skipped provenance extraction after parser failure.",
);
expect(exit).toHaveBeenCalledWith(7);
});
});
90 changes: 90 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync, type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process";

import { openClawAgentJsonProvenanceLines } from "../../../openclaw/agent-json-provenance";
import { buildOpenshellExecArgs, computeExitCode } from "../exec";

const AGENT_JSON_MAX_BUFFER_BYTES = 64 * 1024 * 1024;

export type AgentJsonPassthroughProcess = {
exit(code: number): never;
stdout: { write(s: string): unknown };
stderr: { write(s: string): unknown };
};

export type AgentJsonPassthroughDeps = {
getOpenshellBinary?: () => string;
provenanceLines?: (raw: string) => string[];
spawnSync?: (
command: string,
args: readonly string[],
options: SpawnSyncOptions,
) => SpawnSyncReturns<string | Buffer>;
};

function text(value: string | Buffer | null | undefined): string {
if (Buffer.isBuffer(value)) return value.toString("utf-8");
return typeof value === "string" ? value : "";
}

function defaultGetOpenshellBinary(): string {
// Lazy require keeps this module unit-testable under Vitest's TS loader; the
// OpenShell runtime imports runner/platform modules that only exist in built
// CLI layouts.
const runtime =
require("../../../adapters/openshell/runtime") as typeof import("../../../adapters/openshell/runtime");
return runtime.getOpenshellBinary();
}

function writeProvenanceBlock(
proc: AgentJsonPassthroughProcess,
stderr: string,
lines: readonly string[],
): void {
if (lines.length === 0) return;
proc.stderr.write(`${stderr && !stderr.endsWith("\n") ? "\n" : ""}${lines.join("\n")}\n`);
}

export function runAgentJsonPassthrough(
sandboxName: string,
command: readonly string[],
proc: AgentJsonPassthroughProcess = process,
deps: AgentJsonPassthroughDeps = {},
): never {
const binary = (deps.getOpenshellBinary ?? defaultGetOpenshellBinary)();
const spawnSyncImpl = deps.spawnSync ?? spawnSync;
const result = spawnSyncImpl(
binary,
buildOpenshellExecArgs(sandboxName, command, { tty: false }),
{
encoding: "utf-8",
maxBuffer: AGENT_JSON_MAX_BUFFER_BYTES,
stdio: ["inherit", "pipe", "pipe"],
},
);
const stdout = text(result.stdout);
const stderr = text(result.stderr);
if (stdout) proc.stdout.write(stdout);
if (stderr) proc.stderr.write(stderr);

try {
writeProvenanceBlock(
proc,
stderr,
(deps.provenanceLines ?? openClawAgentJsonProvenanceLines)(stdout),
);
} catch {
writeProvenanceBlock(proc, stderr, [
"[openclaw provenance] skipped provenance extraction after parser failure.",
]);
}

const { code, errorMessage } = computeExitCode(result);
if (errorMessage) {
proc.stderr.write(` Failed to invoke openshell: ${errorMessage}\n`);
proc.stderr.write(" Ensure 'openshell' is installed and on PATH.\n");
}
return proc.exit(code);
}
Loading
Loading