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
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
104 changes: 103 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,115 @@
// 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 { computeExitCode, 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 output exceeded the 4-byte combined capture limit"),
);
expect(computeExitCode(result)).toEqual({
code: 1,
errorMessage: "agent output exceeded the 4-byte combined capture limit",
});
expect(result.stdout).toBe("");
});

it("enforces one capture bound across stdout and stderr", async () => {
const harness = dispatchHarness();
const pending = runAgentDispatch(
"openshell",
["sandbox", "exec", "--name", "alpha", "--", "openclaw", "agent"],
{ maxBufferBytes: 6, stdinIsTty: false },
{ signalSource: harness.signalSource, spawnChild: () => harness.child },
);

harness.stdout.emit("data", "1234");
harness.stderr.emit("data", "567");

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

describe("isSilentAgentDispatch", () => {
it("classifies a zero-exit dispatch with no bytes on either stream as silent", () => {
Expand Down
165 changes: 157 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,174 @@
// - 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;
};

type AgentDispatchCaptureBudget = {
bytes: number;
overflowed: boolean;
};

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,
child: AgentDispatchChild,
chunks: Buffer[],
maxBufferBytes: number,
budget: AgentDispatchCaptureBudget,
setOverflowError: (error: Error) => void,
): void {
stream?.on("data", (chunk) => {
if (budget.overflowed) return;
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const nextSize = budget.bytes + data.byteLength;
if (nextSize > maxBufferBytes) {
budget.overflowed = true;
setOverflowError(
new Error(`agent output exceeded the ${maxBufferBytes}-byte combined capture limit`),
);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
return;
}
budget.bytes = nextSize;
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[] = [];
const captureBudget: AgentDispatchCaptureBudget = { bytes: 0, overflowed: false };
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,
child,
stdoutChunks,
maxBufferBytes,
captureBudget,
setOverflowError,
);
captureAgentDispatchStream(
child.stderr,
child,
stderrChunks,
maxBufferBytes,
captureBudget,
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