Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,8 @@ Otherwise, it writes the captured output to the corresponding host streams and r
The in-sandbox NemoClaw plugin writes its registration banner to `stderr`, so the banner does not prefix the agent reply on `stdout` in non-JSON mode.
Because a delivered turn always writes to one of the two streams, the wrapper reports a dispatch with status `0` and no output as a failure.
The wrapper prints recovery guidance to `stderr` and exits with status `1`.
Pressing `Ctrl+C` interrupts the OpenShell child, and sending `SIGTERM` to the host wrapper forwards `SIGTERM` to that child.
NemoClaw waits for OpenShell to stop the in-sandbox turn, replays captured output, and returns status `130` for `SIGINT` or `143` for `SIGTERM`.
The diagnostic shell-quotes the sandbox name and forwarded arguments, then redacts detected credential values before writing the recovery command to `stderr`.
If redaction changes the recovery command, the diagnostic tells you not to replay it; otherwise, it labels the command as runnable inside the sandbox.
For a registered sandbox, both captured paths pin the sandbox's recorded gateway with an explicit `-g`.
Expand Down Expand Up @@ -4516,7 +4518,7 @@ OpenClaw-specific onboarding configuration:
| `NEMOCLAW_WEB_SEARCH_PROVIDER` | `brave`, `tavily`, or `none` | Selects Brave Search or Tavily Search in non-interactive onboarding, or disables web search explicitly. When unset, `BRAVE_API_KEY` implicitly selects Brave before `TAVILY_API_KEY` can implicitly select Tavily. |
| `BRAVE_API_KEY` | Brave Search API key | Supplies and implicitly selects Brave Search when no web search provider is set. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. |
| `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no provider is set and no Brave key is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. |
| `NEMOCLAW_AGENT_TIMEOUT` | positive integer (seconds) | Overrides `agents.defaults.timeoutSeconds` and `models.providers.<provider-id>.timeoutSeconds` in the built OpenClaw config. Raise for slow inference. |
| `NEMOCLAW_AGENT_TIMEOUT` | positive integer (seconds) | Build-time setting that overrides `agents.defaults.timeoutSeconds` and `models.providers.<provider-id>.timeoutSeconds` in the built OpenClaw config. Set it before onboarding builds the sandbox image. Setting it only for a later `$$nemoclaw <name> agent` invocation does not change the existing image. Raise for slow inference. |
| `NEMOCLAW_MCP_SHADOW_DIAGNOSTICS` | literal `1` to enable | Forwards opt-in successful Streamable HTTP MCP timing diagnostics to a newly created or rebuilt OpenClaw sandbox. It does not change timeouts, retries, requests, or responses. Unset it and rebuild after evidence collection to restore failure-only logging. Other values are ignored. |
| `NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS` | positive number of seconds | Sets the post-pairing poll cadence for the in-sandbox OpenClaw auto-pair watcher. Defaults to `5` so late allowlisted CLI and browser scope upgrades are approved before clients time out. Raise only on load-sensitive gateways. |
| `NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS` | positive integer | Sets how many fast polls run after the watcher observes a fresh allowlisted scope-upgrade request. Defaults to `5`; set lower only when you need to reduce gateway polling. |
Expand Down
77 changes: 76 additions & 1 deletion src/lib/actions/sandbox/agent/passthrough-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,88 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { EventEmitter } from "node:events";

import { describe, expect, it, vi } from "vitest";

import {
type AgentDispatchChild,
agentDispatchStdio,
isSilentAgentDispatch,
runAgentDispatch,
SILENT_AGENT_DISPATCH_EXIT_CODE,
} from "./passthrough-dispatch";
import type { SandboxExecSignalSource } from "../exec";

function dispatchHarness() {
const childEvents = new EventEmitter();
const signalEvents = new EventEmitter();
const stderr = new EventEmitter();
const stdout = new EventEmitter();
const child: AgentDispatchChild = {
exitCode: null,
signalCode: null,
kill: vi.fn((signal) => {
child.signalCode = signal;
queueMicrotask(() => childEvents.emit("close", null, signal));
return true;
}),
once: ((event: string, listener: (...args: unknown[]) => void) =>
childEvents.once(event, listener)) as AgentDispatchChild["once"],
stderr,
stdout,
};
const signalSource: SandboxExecSignalSource = {
add: (signal, listener) => signalEvents.on(signal, listener),
remove: (signal, listener) => signalEvents.off(signal, listener),
};
return { child, signalEvents, signalSource, stderr, stdout };
}

describe("runAgentDispatch", () => {
it("forwards host SIGTERM to OpenShell and captures output before signal exit (#8723)", async () => {
const harness = dispatchHarness();
const pending = runAgentDispatch(
"openshell",
["sandbox", "exec", "--name", "alpha", "--", "openclaw", "agent"],
{ stdinIsTty: true },
{ signalSource: harness.signalSource, spawnChild: () => harness.child },
);

harness.stdout.emit("data", "partial response\n");
harness.stderr.emit("data", Buffer.from("gateway timeout pending\n"));
harness.signalEvents.emit("SIGTERM");

const result = await pending;
expect(harness.child.kill).toHaveBeenCalledOnce();
expect(harness.child.kill).toHaveBeenCalledWith("SIGTERM");
expect(result).toMatchObject({
status: null,
signal: "SIGTERM",
stdout: "partial response\n",
stderr: "gateway timeout pending\n",
});
expect(harness.signalEvents.listenerCount("SIGTERM")).toBe(0);
expect(harness.signalEvents.listenerCount("SIGINT")).toBe(0);
});

it("terminates the OpenShell child when captured output exceeds its bound", async () => {
const harness = dispatchHarness();
const pending = runAgentDispatch(
"openshell",
["sandbox", "exec", "--name", "alpha", "--", "openclaw", "agent"],
{ maxBufferBytes: 4, stdinIsTty: false },
{ signalSource: harness.signalSource, spawnChild: () => harness.child },
);

harness.stdout.emit("data", "12345");

const result = await pending;
expect(harness.child.kill).toHaveBeenCalledWith("SIGTERM");
expect(result.error).toEqual(new Error("agent stdout exceeded the 4-byte capture limit"));
expect(result.stdout).toBe("");
});
});

describe("isSilentAgentDispatch", () => {
it("classifies a zero-exit dispatch with no bytes on either stream as silent", () => {
Expand Down
160 changes: 152 additions & 8 deletions src/lib/actions/sandbox/agent/passthrough-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

// Source-of-truth boundary for the agent dispatch contract (#8796).
//
// Both `nemoclaw <name> agent` transports capture the child's streams and
// forward its exit code. That makes "the child exited 0" the only success
// signal, so a dispatch that never ran the turn is indistinguishable from a
// turn that ran and answered.
// Both `nemoclaw <name> agent` transports capture the child's streams, forward
// host termination signals, and return the child's exit status. OpenClaw can
// still report status 0 when a dispatch produces no result, so the wrapper
// must classify that ambiguous result before it reports success.
//
// 6. Empty-dispatch guard (delivery contract).
//
Expand Down Expand Up @@ -41,25 +41,169 @@
// - Removal condition: drop the TTY carve-out if `openclaw agent` gains a
// documented interactive stdin mode reachable through this wrapper.
//
// 8. Host interruption propagation.
//
// - Invalid state: the former synchronous transports blocked the Node.js
// event loop. A host SIGTERM stopped NemoClaw without notifying the
// OpenShell child, so the in-sandbox agent turn continued until its own
// deadline.
// - Source boundary: OpenShell owns remote command cancellation. NemoClaw
// owns its direct child and uses the shared sandbox exec supervisor to
// forward SIGTERM, wait for OpenShell to exit, and return exit 143.
// - Removal condition: none while NemoClaw owns the host-side OpenShell
// child lifecycle.
//
// Regression tests: `passthrough-dispatch.test.ts` owns the classifier and the
// stdio shape; `passthrough-help.test.ts` owns the diagnostic text.
// supervised process lifecycle; `passthrough-help.test.ts` owns the diagnostic
// text.

import type { StdioOptions } from "node:child_process";
import { spawn, type StdioOptions } from "node:child_process";

import { isStdinTty } from "../../../core/stdin";
import { runSandboxExecChild, type SandboxExecChild, type SandboxExecSignalSource } from "../exec";

/**
* Exit code for a dispatch that reported success without delivering a turn.
* Matches the wrapper's other non-recoverable dispatch failures.
*/
export const SILENT_AGENT_DISPATCH_EXIT_CODE = 1;

/** The subset of a `spawnSync` return the delivery classifier reads. */
/** The subset of a child-process result the delivery classifier reads. */
export type AgentDispatchOutcome = {
error?: unknown;
error?: Error;
status: number | null;
signal?: NodeJS.Signals | null;
};

export type AgentDispatchResult = AgentDispatchOutcome & {
stderr: string;
stdout: string;
};

type AgentDispatchReadable = {
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
};

export type AgentDispatchChild = SandboxExecChild & {
stderr: AgentDispatchReadable | null;
stdout: AgentDispatchReadable | null;
};

export type AgentDispatchSpawner = (
binary: string,
args: readonly string[],
stdio: StdioOptions,
) => AgentDispatchChild;

export type AgentDispatchRunner = (
binary: string,
args: readonly string[],
options?: {
maxBufferBytes?: number;
stdinIsTty?: boolean;
},
) => Promise<AgentDispatchResult>;

export type AgentDispatchRunDeps = {
signalSource?: SandboxExecSignalSource;
spawnChild?: AgentDispatchSpawner;
};

const DEFAULT_AGENT_DISPATCH_MAX_BUFFER_BYTES = 64 * 1024 * 1024;

const defaultAgentDispatchSpawner: AgentDispatchSpawner = (binary, args, stdio) =>
spawn(binary, [...args], { stdio }) as unknown as AgentDispatchChild;

function captureAgentDispatchStream(
stream: AgentDispatchReadable | null,
streamName: "stderr" | "stdout",
child: AgentDispatchChild,
chunks: Buffer[],
maxBufferBytes: number,
setOverflowError: (error: Error) => void,
): void {
let size = 0;
let overflowed = false;
stream?.on("data", (chunk) => {
if (overflowed) return;
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += data.byteLength;
if (size > maxBufferBytes) {
overflowed = true;
setOverflowError(
new Error(`agent ${streamName} exceeded the ${maxBufferBytes}-byte capture limit`),
);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
return;
}
chunks.push(data);
});
}

/**
* Capture one agent dispatch while the shared sandbox exec supervisor forwards
* host termination signals to OpenShell and waits for the child to exit.
*/
export async function runAgentDispatch(
binary: string,
args: readonly string[],
options: {
maxBufferBytes?: number;
stdinIsTty?: boolean;
} = {},
deps: AgentDispatchRunDeps = {},
): Promise<AgentDispatchResult> {
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
let overflowError: Error | undefined;
const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_AGENT_DISPATCH_MAX_BUFFER_BYTES;
const spawnChild = deps.spawnChild ?? defaultAgentDispatchSpawner;
const result = await runSandboxExecChild(
binary,
args,
{ tty: false },
(runBinary, runArgs) => {
const child = spawnChild(
runBinary,
runArgs,
agentDispatchStdio(options.stdinIsTty ?? isStdinTty()),
);
const setOverflowError = (error: Error) => {
overflowError ??= error;
};
captureAgentDispatchStream(
child.stdout,
"stdout",
child,
stdoutChunks,
maxBufferBytes,
setOverflowError,
);
captureAgentDispatchStream(
child.stderr,
"stderr",
child,
stderrChunks,
maxBufferBytes,
setOverflowError,
);
return child;
},
deps.signalSource,
);
try {
return {
status: result.status,
signal: result.signal,
...(result.error || overflowError ? { error: result.error ?? overflowError } : {}),
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
};
} finally {
result.releaseSignals?.();
}
}

/**
* Stdio for a non-interactive agent dispatch. An interactive terminal is
* withheld from fd 0; a genuine pipe or redirect is still forwarded so
Expand Down
Loading
Loading