Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions 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 All @@ -25,7 +25,7 @@
"src/lib/messaging/channels/index.ts": 25,
"src/lib/onboard/gateway-binding.ts": 49,
"src/lib/runner.ts": 88,
"src/lib/security/redact.ts": 51,
"src/lib/security/redact.ts": 52,
"src/lib/state/onboard-session.ts": 36,
"src/lib/state/registry.ts": 97,
"src/lib/state/state-root.ts": 21,
Expand Down
18 changes: 17 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1225,8 +1225,24 @@ 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 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`.
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`.
Neither path 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`.
Raw `stderr`, including structured JSON diagnostics, is forwarded unchanged.
NemoClaw appends failed-tool or untrusted-child provenance only from the `stdout` JSON.
The wrapper reads completion markers only from the final matching OpenClaw response envelope: a local `{ payloads, meta }` response or a gateway `{ status, result: { payloads, meta } }` response.
It ignores earlier JSON progress or log records.
It exits with status `1` when that metadata contains `error.kind: "incomplete_turn"`, `livenessState: "abandoned"`, or `replayInvalid: true`, even when the envelope reports success.
Marker-shaped values inside tool results, tool-call arguments, or other descendants do not change the exit status.
A turn can run every tool successfully and still become abandoned before it produces a reply.
The wrapper writes the unchanged JSON trace to `stdout` before it reports the incomplete turn, so the partial tool trace remains available.
The wrapper writes the verdict, the detected markers, and verify-before-retry guidance to `stderr`.
Tool calls in a partial trace may have already applied side effects, so verify what the turn changed before you retry it.
The wrapper passes through an upstream non-zero exit status unchanged.
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.
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;
}
119 changes: 118 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,111 @@ 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("redacts a credential pasted into the turn arguments", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "my-assistant", [
"openclaw",
"agent",
"--agent",
"main",
"-m",
"use sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH",
]);

const output = lines.join("");
expect(output).not.toContain("sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH");
// Pin the surviving command, not just the absent token: a bare negative
// assertion also passes if the recovery command is dropped entirely.
expect(output).toContain(
"nemoclaw 'my-assistant' exec -- 'openclaw' 'agent' '--agent' 'main' '-m' 'use <REDACTED>'",
);
expect(output).toContain("sensitive values were redacted; do not replay this command");
expect(output).not.toContain("run this turn directly inside the sandbox");
});

it("leaves ordinary turn text runnable rather than redacting it", () => {
const { lines, proc } = collectStderr();

writeSilentAgentDispatchFailure(proc, "my-assistant", [
"openclaw",
"agent",
"--agent",
"main",
"-m",
"Summarise README.md",
]);

const output = lines.join("");
expect(output).toContain("'-m' 'Summarise README.md'");
expect(output).toContain("run this turn directly inside the sandbox");
expect(output).not.toContain("do not replay this command");
});

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);
});
});
Loading
Loading