Skip to content

Commit f400489

Browse files
cvWilliamK112
andauthored
fix(plugin): route registration banner to stderr (NVIDIA#6480)
<!-- markdownlint-disable MD041 --> ## Summary Routes the in-sandbox NemoClaw registration banner to `stderr` so non-JSON agent output on `stdout` remains machine-readable. This is a current-main, GitHub-verified replacement for NVIDIA#5674 that preserves the contributor's original authorship. ## Related Issue Fixes NVIDIA#5654. ## Changes - Write the plugin registration banner directly to `stderr` instead of plugin info logs. - Cover banner routing, live model rendering, mock restoration, and the non-JSON passthrough stream boundary. - Document the agent command's stdout/stderr contract. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates <!-- Check exactly one tests line and one docs line. Check other lines when applicable. Add every requested justification or approval reference. --> - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Docs updated for user-facing behavior changes - [ ] Docs not applicable — justification: - [ ] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [ ] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## Verification <!-- Check each applicable item only when supported by the requested evidence. Run targeted tests once per relevant change set and rerun after later edits or hook autofixes that can affect the tested behavior. Do not rerun hook-covered checks. --> - [x] PR description includes the DCO sign-off declaration and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run check:diff` passed when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — command/result or justification: `npm --prefix nemoclaw test -- src/register.test.ts` passed 24/24; `npx vitest run --project cli src/lib/actions/sandbox/agent/passthrough.test.ts` passed 38/38; plugin and CLI builds passed. - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) `npm run docs` passed with zero errors and two pre-existing Fern warnings. --- <!-- DCO sign-off is required in this PR description, and every commit must appear as Verified in GitHub. Run: git config user.name && git config user.email --> Signed-off-by: Carlos Villela <cvillela@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Updated agent startup so the NemoClaw registration banner is emitted to standard error, avoiding interference with non-JSON agent replies on standard output. * **Documentation** * Refreshed the command reference to clarify where the banner appears during non-JSON runs. * **Tests** * Revised banner-related tests to capture and assert standard error output, and added coverage to ensure standard output remains clean for non-JSON replies. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: WilliamK112 <164879897+WilliamK112@users.noreply.github.com> Signed-off-by: Carlos Villela <cvillela@nvidia.com> Co-authored-by: WilliamK112 <164879897+WilliamK112@users.noreply.github.com>
1 parent 7bf427e commit f400489

4 files changed

Lines changed: 100 additions & 24 deletions

File tree

docs/reference/commands.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,7 @@ $$nemoclaw dcode-sandbox agent -n "Summarize this repository"
869869
When post-command permission cleanup succeeds, the wrapper inherits the remote command's exit code so host-side pipelines can branch on it.
870870
If cleanup fails closed, the wrapper prints the command and cleanup statuses to `stderr` and returns the cleanup failure.
871871
For normal turns, streaming forwards whatever the in-sandbox agent command emits on `stdout`; the wrapper adds no buffering.
872+
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.
872873
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.
873874
Raw `stderr` is forwarded, and failed-tool or untrusted-child provenance found in the stdout JSON is appended to `stderr`.
874875
Literal `--json` values consumed by flags such as `-m` or `--reply-channel`, or arguments after `--`, stay on the normal passthrough path.

nemoclaw/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -437,9 +437,9 @@ export default function register(api: OpenClawPluginApi): void {
437437
" Slash: /nemoclaw",
438438
];
439439

440-
api.logger.info("");
440+
process.stderr.write("\n");
441441
for (const line of renderBox(bannerLines)) {
442-
api.logger.info(line);
442+
process.stderr.write(`${line}\n`);
443443
}
444-
api.logger.info("");
444+
process.stderr.write("\n");
445445
}

nemoclaw/src/register.test.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import type { OpenClawPluginApi } from "./index.js";
66

77
vi.mock("node:fs", async (importOriginal) => {
@@ -32,6 +32,17 @@ const mockedReadFileSync = vi.mocked(readFileSync);
3232
const mockedLoadOnboardConfig = vi.mocked(loadOnboardConfig);
3333
const originalReadFileSync = (await vi.importActual<typeof import("node:fs")>("node:fs"))
3434
.readFileSync;
35+
let stderrWrite: ReturnType<typeof vi.spyOn>;
36+
37+
function mockStderrWrite(): void {
38+
stderrWrite = vi
39+
.spyOn(process.stderr, "write")
40+
.mockImplementation((() => true) as typeof process.stderr.write);
41+
}
42+
43+
function stderrOutput(): string {
44+
return stderrWrite.mock.calls.map(([chunk]) => String(chunk)).join("");
45+
}
3546

3647
function mockMissingOpenClawConfig(): void {
3748
mockedReadFileSync.mockReset();
@@ -64,13 +75,18 @@ function createMockApi(): OpenClawPluginApi {
6475
};
6576
}
6677

67-
describe("plugin registration", () => {
68-
beforeEach(() => {
69-
vi.clearAllMocks();
70-
mockMissingOpenClawConfig();
71-
mockedLoadOnboardConfig.mockReturnValue(null);
72-
});
78+
beforeEach(() => {
79+
vi.clearAllMocks();
80+
mockStderrWrite();
81+
mockMissingOpenClawConfig();
82+
mockedLoadOnboardConfig.mockReturnValue(null);
83+
});
84+
85+
afterEach(() => {
86+
vi.restoreAllMocks();
87+
});
7388

89+
describe("plugin registration", () => {
7490
it("registers a slash command", () => {
7591
const api = createMockApi();
7692
register(api);
@@ -140,8 +156,15 @@ describe("plugin registration", () => {
140156
expect(providerArg.models?.chat).toEqual([
141157
expect.objectContaining({ id: "inference/nvidia/live-model", label: "nvidia/live-model" }),
142158
]);
143-
const logLines = vi.mocked(api.logger.info).mock.calls.map(([message]) => message);
144-
expect(logLines.some((line) => line.includes("Model: nvidia/live-model"))).toBe(true);
159+
expect(stderrOutput()).toContain("Model: nvidia/live-model");
160+
});
161+
162+
it("writes the registration banner to stderr instead of plugin info logs", () => {
163+
const api = createMockApi();
164+
register(api);
165+
166+
expect(stderrOutput()).toContain("NemoClaw registered");
167+
expect(api.logger.info).not.toHaveBeenCalled();
145168
});
146169

147170
it("falls back to onboard config when openclaw.json has no primary model", () => {
@@ -178,22 +201,14 @@ describe("plugin registration", () => {
178201
expect.objectContaining({ id: "nvidia/nemotron-3-nano-30b-a3b" }),
179202
]);
180203

181-
const logLines = vi.mocked(api.logger.info).mock.calls.map(([message]) => message);
182-
expect(logLines.some((line) => line.includes("Endpoint: build.nvidia.com"))).toBe(true);
183-
expect(logLines.some((line) => line.includes("Provider: NVIDIA Endpoints"))).toBe(true);
184-
expect(
185-
logLines.some((line) => line.includes("Model: nvidia/nemotron-3-super-120b-a12b")),
186-
).toBe(true);
204+
const stderr = stderrOutput();
205+
expect(stderr).toContain("Endpoint: build.nvidia.com");
206+
expect(stderr).toContain("Provider: NVIDIA Endpoints");
207+
expect(stderr).toContain("Model: nvidia/nemotron-3-super-120b-a12b");
187208
});
188209
});
189210

190211
describe("before_tool_call secret scanner hook (#1233)", () => {
191-
beforeEach(() => {
192-
vi.clearAllMocks();
193-
mockMissingOpenClawConfig();
194-
mockedLoadOnboardConfig.mockReturnValue(null);
195-
});
196-
197212
function getHookHandler(api: OpenClawPluginApi) {
198213
register(api);
199214
const onCalls = vi.mocked(api.on).mock.calls;

src/lib/actions/sandbox/agent/passthrough.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,36 @@ vi.mock("../../../agent/defs", () => ({
3737
vi.mock("../../../shields/audit", () => ({
3838
readRecentShieldsAutoRestore: vi.fn(() => ({ kind: "none" })),
3939
}));
40+
vi.mock("../../../../../nemoclaw/src/onboard/config.js", () => ({
41+
loadOnboardConfig: vi.fn(() => null),
42+
describeOnboardEndpoint: vi.fn(() => "build.nvidia.com"),
43+
describeOnboardProvider: vi.fn(() => "NVIDIA Endpoint API"),
44+
}));
4045

46+
import registerPlugin, { type OpenClawPluginApi } from "../../../../../nemoclaw/src/index";
4147
import { type AgentPassthroughDeps, runAgentPassthrough } from "./passthrough";
4248

49+
function createPluginApi(): OpenClawPluginApi {
50+
return {
51+
id: "nemoclaw",
52+
name: "NemoClaw",
53+
version: "0.1.0",
54+
config: {},
55+
pluginConfig: {},
56+
logger: {
57+
info: vi.fn(),
58+
warn: vi.fn(),
59+
error: vi.fn(),
60+
debug: vi.fn(),
61+
},
62+
registerCommand: vi.fn(),
63+
registerProvider: vi.fn(),
64+
registerService: vi.fn(),
65+
resolvePath: vi.fn((value: string) => value),
66+
on: vi.fn(),
67+
};
68+
}
69+
4370
describe("runAgentPassthrough", () => {
4471
beforeEach(() => {
4572
vi.clearAllMocks();
@@ -86,6 +113,39 @@ describe("runAgentPassthrough", () => {
86113
);
87114
});
88115

116+
it("keeps a non-JSON agent reply isolated from the plugin banner (#5654)", async () => {
117+
const stdout: string[] = [];
118+
const stderr: string[] = [];
119+
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(((chunk) => {
120+
stdout.push(String(chunk));
121+
return true;
122+
}) as typeof process.stdout.write);
123+
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(((chunk) => {
124+
stderr.push(String(chunk));
125+
return true;
126+
}) as typeof process.stderr.write);
127+
const exec = vi.fn(async () => {
128+
registerPlugin(createPluginApi());
129+
process.stdout.write("ack\n");
130+
});
131+
getSandboxMock.mockReturnValueOnce({ agent: "openclaw" });
132+
133+
try {
134+
await runAgentPassthrough(
135+
"alpha",
136+
{ extraArgs: ["--agent", "main", "-m", "ping"] },
137+
{ exec, getRecentShieldsAutoRestore: () => ({ kind: "none" }) },
138+
);
139+
} finally {
140+
stdoutWrite.mockRestore();
141+
stderrWrite.mockRestore();
142+
}
143+
144+
expect(stdout.join("")).toBe("ack\n");
145+
expect(stdout.join("")).not.toContain("NemoClaw registered");
146+
expect(stderr.join("")).toContain("NemoClaw registered");
147+
});
148+
89149
it("uses the captured JSON path for `openclaw agent --json` so provenance can be emitted on stderr", async () => {
90150
const execJson = vi.fn(() => {
91151
throw new Error("__exit:0");

0 commit comments

Comments
 (0)