Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9753b66
fix(agent): fail loud when an agent dispatch delivers nothing
Dongni-Yang Aug 12, 2026
0c2ede8
fix(agent): make the empty-dispatch recovery command runnable
Dongni-Yang Aug 12, 2026
5243690
fix(agent): redact the empty-dispatch recovery command before logging
Dongni-Yang Aug 12, 2026
6c92cdb
test(agent): pin the surviving command in the redaction case
Dongni-Yang Aug 12, 2026
b95df07
Merge branch 'main' into fix/8796-agent-gateway-pin
cv Aug 12, 2026
1b49897
fix(agent): mark redacted recovery commands non-replayable
apurvvkumaria Aug 12, 2026
a0befe2
docs(agent): explain redacted dispatch recovery
apurvvkumaria Aug 12, 2026
3049322
Merge remote-tracking branch 'origin/main' into codex/pr8846-readiness
apurvvkumaria Aug 12, 2026
7f95c1b
test(sandbox): include shared gateway launcher in fixtures
apurvvkumaria Aug 12, 2026
912a767
test(cli): include shared gateway launcher in startup fixture
apurvvkumaria Aug 12, 2026
1ed28e0
merge(main): refresh agent dispatch readiness
apurvvkumaria Aug 12, 2026
89fbfbe
docs(cli): clarify JSON diagnostic streams
apurvvkumaria Aug 12, 2026
ea18c20
fix(agent): exit non-zero for an incomplete agent turn (#8857)
Dongni-Yang Aug 12, 2026
ee44661
fix(agent): select authoritative response metadata
prekshivyas Aug 12, 2026
1214167
docs(agent): identify authoritative response envelope
prekshivyas Aug 12, 2026
9b227e7
docs(agent): qualify gateway pinning scope
apurvvkumaria Aug 12, 2026
13b178d
Merge remote-tracking branch 'origin/main' into codex/pr8846-readiness
apurvvkumaria Aug 12, 2026
625fec1
docs(agent): align command prose format
apurvvkumaria Aug 12, 2026
ff1d173
fix(agent): detect incomplete envelopes without payloads
prekshivyas Aug 12, 2026
a83aaa1
fix(agent): ignore JSON progress records
prekshivyas Aug 12, 2026
d86efba
Merge branch 'main' into fix/8796-agent-gateway-pin
cv Aug 12, 2026
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
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"src/lib/cli/terminal-style.ts": 43,
"src/lib/core/json-types.ts": 37,
"src/lib/core/ports.ts": 87,
"src/lib/core/shell-quote.ts": 26,
"src/lib/core/shell-quote.ts": 27,
"src/lib/core/url-utils.ts": 28,
"src/lib/core/wait.ts": 35,
"src/lib/credentials/store.ts": 46,
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,8 @@ The combined capture limit is `64 MiB`; exceeding it reports an OpenShell invoca
If the captured output contains an embedded-fallback marker, the wrapper suppresses both streams, prints `recover`, `rebuild --yes`, and `onboard --resume` guidance to `stderr`, and exits with status `1`.
Otherwise, it writes the captured output to the corresponding host streams and returns the OpenShell command's exit status.
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 therefore always writes to one of the two streams, a dispatch that exits `0` with no bytes on either stream is reported as a failure: the wrapper prints the recovery guidance to `stderr` and exits with status `1` instead of reporting a successful turn.
Both captured paths pin the sandbox's owning gateway with an explicit `-g`, and neither forwards an interactive terminal on `stdin`; a genuine pipe or redirect is still passed through, so `printf 'ping' | $$nemoclaw my-assistant agent --agent main` keeps working.
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.
Expand Down
59 changes: 59 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

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

import {
agentDispatchStdio,
isSilentAgentDispatch,
SILENT_AGENT_DISPATCH_EXIT_CODE,
} from "./passthrough-dispatch";

describe("isSilentAgentDispatch", () => {
it("classifies a zero-exit dispatch with no bytes on either stream as silent", () => {
expect(isSilentAgentDispatch({ status: 0 }, "", "")).toBe(true);
});

it("does not classify a dispatch that wrote to stdout", () => {
expect(isSilentAgentDispatch({ status: 0 }, "PONG\n", "")).toBe(false);
});

it("does not classify a dispatch that wrote only to stderr", () => {
expect(isSilentAgentDispatch({ status: 0 }, "", "openclaw warning\n")).toBe(false);
});

it("does not classify a non-zero dispatch, which already fails on its own", () => {
expect(isSilentAgentDispatch({ status: 7 }, "", "")).toBe(false);
});

it("does not classify a transport error, which reports its own diagnosis", () => {
expect(isSilentAgentDispatch({ status: null, error: new Error("ENOENT") }, "", "")).toBe(false);
});

it("does not classify a signal-killed dispatch with a null status", () => {
expect(isSilentAgentDispatch({ status: null }, "", "")).toBe(false);
});
});

describe("agentDispatchStdio", () => {
it("withholds an interactive terminal from fd 0", () => {
expect(agentDispatchStdio(true)).toEqual(["ignore", "pipe", "pipe"]);
});

it("forwards a non-terminal stdin so scripted input keeps working", () => {
expect(agentDispatchStdio(false)).toEqual(["inherit", "pipe", "pipe"]);
});

it("captures both output streams in either stdin posture", () => {
expect([agentDispatchStdio(true).slice(1), agentDispatchStdio(false).slice(1)]).toEqual([
["pipe", "pipe"],
["pipe", "pipe"],
]);
});
});

describe("SILENT_AGENT_DISPATCH_EXIT_CODE", () => {
it("reports a dispatch failure rather than success", () => {
expect(SILENT_AGENT_DISPATCH_EXIT_CODE).toBe(1);
});
});
83 changes: 83 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// 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.
//
// 6. Empty-dispatch guard (delivery contract).
//
// - Invalid state: `openshell sandbox exec` returns status 0 with zero
// bytes on both captured streams. A delivered OpenClaw turn cannot look
// like this — the in-sandbox NemoClaw plugin writes its registration
// banner to stderr on every invocation (docs/reference/commands.mdx),
// so a healthy turn is never byte-empty on both streams. Reporting exit
// 0 here tells CI jobs and evaluation harnesses that a turn happened
// when the agent never received the message.
// - Source boundary: OpenShell owns the exec transport and OpenClaw owns
// the turn. NemoClaw cannot repair either from the host, but it does own
// what it reports to its own caller, so it fails loud instead of
// laundering an empty dispatch into a success.
// - Removal condition: drop this guard when the exec transport reports a
// non-zero status (or a structured error) for a command it did not
// actually run.
//
// 7. Non-interactive stdin posture.
//
// - Invalid state: `nemoclaw <name> agent` is documented as a
// non-interactive one-shot, yet PR #8191 moved the non-JSON transport
// off `execSandbox` onto a raw `spawnSync` with a hard-coded
// `stdio[0] = "inherit"`, dropping the TTY-aware stdin guard that
// `buildSandboxExecStdio` applies to every other sandbox exec. The JSON
// transport has carried the same hard-coded inherit since #5683. The
// result is a live terminal on fd 0 handed to a dispatch whose stdout
// and stderr are pipes and whose argv says `--no-tty`.
// - Source boundary: NemoClaw owns which fds it hands to OpenShell.
// Forwarding a real pipe stays supported so `printf ... | nemoclaw
// <name> agent` keeps working; only an interactive terminal is withheld.
// - Removal condition: drop the TTY carve-out if `openclaw agent` gains a
// documented interactive stdin mode reachable through this wrapper.
//
// Regression tests: `passthrough-dispatch.test.ts` owns the classifier and the
// stdio shape; `passthrough-help.test.ts` owns the diagnostic text.

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

import { isStdinTty } from "../../../core/stdin";

/**
* 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. */
export type AgentDispatchOutcome = {
error?: unknown;
status: number | null;
};

/**
* Stdio for a non-interactive agent dispatch. An interactive terminal is
* withheld from fd 0; a genuine pipe or redirect is still forwarded so
* scripted stdin keeps working.
*/
export function agentDispatchStdio(stdinIsTty: boolean = isStdinTty()): StdioOptions {
return [stdinIsTty ? "ignore" : "inherit", "pipe", "pipe"];
}

/**
* True when the exec transport reported success but produced no bytes at all.
* Requires both streams to be empty so a quiet-but-real turn (any banner,
* warning, or reply) is never misread as an empty dispatch.
*/
export function isSilentAgentDispatch(
result: AgentDispatchOutcome,
stdout: string,
stderr: string,
): boolean {
return !result.error && result.status === 0 && stdout.length === 0 && stderr.length === 0;
}
78 changes: 77 additions & 1 deletion src/lib/actions/sandbox/agent/passthrough-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@

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

import { hasAgentPassthroughHelpToken, printAgentPassthroughHelp } from "./passthrough-help";
import {
hasAgentPassthroughHelpToken,
printAgentPassthroughHelp,
writeSilentAgentDispatchFailure,
} from "./passthrough-help";

function collectStderr() {
const lines: string[] = [];
return { lines, proc: { stderr: { write: (value: string) => lines.push(value) } } };
}

describe("hasAgentPassthroughHelpToken", () => {
it("returns true for --help before the OpenClaw argv separator", () => {
Expand Down Expand Up @@ -40,3 +49,70 @@ describe("printAgentPassthroughHelp", () => {
expect(output).not.toContain("OpenClaw sandboxes only");
});
});

describe("writeSilentAgentDispatchFailure", () => {
const turn = ["openclaw", "agent", "--agent", "main", "-m", "Say PONG"];

it("names the sandbox and states that the turn was not delivered", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "my-assistant", turn);

expect(lines.join("")).toContain(
"The agent dispatch for sandbox 'my-assistant' exited 0 without producing any output, so the turn was not delivered.",
);
});

it("prints a directly runnable recovery command carrying the whole turn", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "my-assistant", turn);

expect(lines.join("")).toContain(
"nemoclaw 'my-assistant' exec -- 'openclaw' 'agent' '--agent' 'main' '-m' 'Say PONG'",
);
});

it("keeps the target selector in the recovery command so it does not exit on the selector guard", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "my-assistant", [
"openclaw",
"agent",
"--session-key",
"agent:main:main",
"-m",
"ping",
]);

expect(lines.join("")).toContain("'--session-key' 'agent:main:main'");
});

it("shell-quotes a sandbox name and turn arguments that carry shell metacharacters", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "sb; rm -rf /", ["openclaw", "agent", "-m", "a'b $(x)"]);

expect(lines.join("")).toContain("'sb; rm -rf /'");
expect(lines.join("")).toContain(String.raw`'a'\''b $(x)'`);
});

it("offers the documented recovery paths", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "my-assistant", turn);

const written = lines.join("");
expect(written).toContain("exec -- 'openclaw' 'agent'");
expect(written).toContain("'my-assistant' status");
expect(written).toContain("'my-assistant' recover");
});

it("terminates every emitted line", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "my-assistant", turn);

expect(lines.every((line) => line.endsWith("\n"))).toBe(true);
});
});
40 changes: 40 additions & 0 deletions src/lib/actions/sandbox/agent/passthrough-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,46 @@
// SPDX-License-Identifier: Apache-2.0

import { CLI_NAME } from "../../../cli/branding";
import { shellQuote } from "../../../core/shell-quote";

/** Stderr sink for the passthrough's operator-facing failure text. */
export type AgentPassthroughDiagnosticProcess = {
stderr: { write(s: string): unknown };
};

/**
* Report a dispatch that exited 0 without delivering a turn (#8796). Lives
* beside the help text because both are operator-facing copy for this command;
* the classifier that decides when to call it lives in `passthrough-dispatch`.
*/
export function writeSilentAgentDispatchFailure(
proc: AgentPassthroughDiagnosticProcess,
sandboxName: string,
command: readonly string[],
): void {
// The recovery command reproduces THIS turn, not a bare `openclaw agent`:
// an invocation without a target selector and the original arguments exits 2
// on the selector guard instead of running anything. Sandbox name and
// forwarded argv are user-controlled command text and stay shell-quoted.
const directRun = [CLI_NAME, shellQuote(sandboxName), "exec", "--", ...command.map(shellQuote)]
.join(" ")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.trimEnd();
proc.stderr.write(
` The agent dispatch for sandbox '${sandboxName}' exited 0 without producing any output, so the turn was not delivered.\n`,
);
proc.stderr.write(
" Reporting this as a failure: a delivered turn always writes to stdout or stderr.\n",
);
proc.stderr.write(" Documented recovery paths:\n");
proc.stderr.write(` ${directRun}\n`);
proc.stderr.write(" — run this exact turn directly inside the sandbox\n");
proc.stderr.write(
` ${CLI_NAME} ${shellQuote(sandboxName)} status — confirm gateway and inference health\n`,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
proc.stderr.write(
` ${CLI_NAME} ${shellQuote(sandboxName)} recover — re-pair the gateway without recreating the sandbox\n`,
);
}

export function hasAgentPassthroughHelpToken(args: readonly string[]): boolean {
for (const arg of args) {
Expand Down
Loading
Loading