From bb53acb0edc6dc26e966a28a0fa99f5352908afa Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Tue, 30 Jun 2026 09:10:16 +0800 Subject: [PATCH 01/15] fix(onboard): color preflight WARN/ERROR check lines (#6004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preflight check results printed plain ✓/⚠/✗ lines in the default terminal color, so warnings and failures were visually indistinguishable from passing checks in the lengthy onboard preflight output. Add warnLine/failLine helpers in terminal-style.ts (⚠ yellow, ✗ red) — color auto-suppressed under NO_COLOR or non-TTY via the existing useColor gate, so CI stays plain text. Apply them to the dedicated preflight-check modules (bridge-dns-preflight, sandbox-gpu-preflight). ✓/INFO lines are left in default color per the issue. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Jason Ma --- src/lib/cli/terminal-style.test.ts | 43 +++++++++++++++++++++++- src/lib/cli/terminal-style.ts | 15 +++++++++ src/lib/onboard/bridge-dns-preflight.ts | 26 +++++++++----- src/lib/onboard/sandbox-gpu-preflight.ts | 17 ++++++---- 4 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/lib/cli/terminal-style.test.ts b/src/lib/cli/terminal-style.test.ts index 21194202011..8a278acf57d 100644 --- a/src/lib/cli/terminal-style.test.ts +++ b/src/lib/cli/terminal-style.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { B, D, G, R, RD, YW } from "./terminal-style"; @@ -12,3 +12,44 @@ describe("terminal-style", () => { } }); }); + +const ORIGINAL_TTY = process.stdout.isTTY; + +function setTTY(value: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value, configurable: true }); +} + +// #6004: warnLine/failLine read NO_COLOR + stdout.isTTY at module load, so each +// case reloads the module under a stubbed environment. +async function loadStyle(opts: { tty: boolean; noColor?: string }) { + vi.resetModules(); + setTTY(opts.tty); + vi.stubEnv("NO_COLOR", opts.noColor ?? ""); + return import("./terminal-style"); +} + +describe("preflight line helpers (#6004)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + setTTY(ORIGINAL_TTY); + }); + + it("renders warn ⚠ in yellow and fail ✗ in red on a color-capable TTY", async () => { + const { warnLine, failLine } = await loadStyle({ tty: true, noColor: "" }); + expect(warnLine("disk low")).toBe(" \x1b[1;33m⚠ disk low\x1b[0m"); + expect(failLine("docker down")).toBe(" \x1b[1;31m✗ docker down\x1b[0m"); + }); + + it("emits plain text (no ANSI) under NO_COLOR=1", async () => { + const { warnLine, failLine } = await loadStyle({ tty: true, noColor: "1" }); + expect(warnLine("disk low")).toBe(" ⚠ disk low"); + expect(failLine("docker down")).toBe(" ✗ docker down"); + }); + + it("emits plain text (no ANSI) when stdout is not a TTY", async () => { + const { warnLine, failLine } = await loadStyle({ tty: false }); + expect(warnLine("x")).toBe(" ⚠ x"); + expect(failLine("y")).toBe(" ✗ y"); + }); +}); diff --git a/src/lib/cli/terminal-style.ts b/src/lib/cli/terminal-style.ts index 82a1e1824a7..ef01beae491 100644 --- a/src/lib/cli/terminal-style.ts +++ b/src/lib/cli/terminal-style.ts @@ -11,3 +11,18 @@ export const D = useColor ? "\x1b[2m" : ""; export const R = useColor ? "\x1b[0m" : ""; export const RD = useColor ? "\x1b[1;31m" : ""; export const YW = useColor ? "\x1b[1;33m" : ""; + +/** + * Preflight result line helpers (#6004). Render a warning (`⚠`) line in yellow + * and a failure (`✗`) line in red so they stand out from the default-colored + * `✓`/INFO lines in the lengthy onboard preflight output. Color is suppressed + * automatically when `NO_COLOR` is set or stdout is not a TTY (via `YW`/`RD`/`R` + * being empty strings), so CI output stays plain text. + */ +export function warnLine(message: string): string { + return ` ${YW}⚠ ${message}${R}`; +} + +export function failLine(message: string): string { + return ` ${RD}✗ ${message}${R}`; +} diff --git a/src/lib/onboard/bridge-dns-preflight.ts b/src/lib/onboard/bridge-dns-preflight.ts index e37cce3f779..ec14cb5faa8 100644 --- a/src/lib/onboard/bridge-dns-preflight.ts +++ b/src/lib/onboard/bridge-dns-preflight.ts @@ -12,6 +12,7 @@ * on the fatal reasons described in `[[isFatalContainerDnsProbeFailure]]`. */ +import { failLine, warnLine } from "../cli/terminal-style"; import { cliDisplayName, cliName } from "./branding"; interface DaemonJsonDnsPatchOpts { @@ -75,6 +76,7 @@ function printDaemonJsonDnsPatch(opts: DaemonJsonDnsPatchOpts): void { ].join(" "); console.error(`${indent}${sudoPrefix}sh -c '${shBody.replace(/'/g, "'\"'\"'")}'`); } + import { BUSYBOX_PROBE_IMAGE, DEFAULT_HOST_DNS_PROBE_HOSTNAME, @@ -95,7 +97,7 @@ export function printDockerBridgeContainerStartFailure( result: DockerBridgeContainerStartProbeResult, host?: Pick, ): void { - console.error(" ✗ Docker could not start a bridge-network test container."); + console.error(failLine("Docker could not start a bridge-network test container.")); if (result.details) { for (const line of String(result.details).split("\n").slice(-4)) { if (line.trim()) console.error(` ${line.trim()}`); @@ -209,7 +211,9 @@ export function assertDockerBridgeAndContainerDnsHealthy(host: Host, nonInteract " but doesn't prove container DNS is broken — the sandbox build may still succeed.", ); } else { - console.warn(` ⚠ Container DNS probe inconclusive (reason: ${dns.reason ?? "unknown"}).`); + console.warn( + warnLine(`Container DNS probe inconclusive (reason: ${dns.reason ?? "unknown"}).`), + ); } if (dns.details) { for (const line of String(dns.details).split("\n").slice(-3)) { @@ -249,11 +253,11 @@ export function assertDockerBridgeAndContainerDnsHealthy(host: Host, nonInteract process.exit(1); } if (dns.reason === "timeout" || dns.reason === "killed") { - console.error(" ✗ Container DNS probe did not complete."); + console.error(failLine("Container DNS probe did not complete.")); } else if (dns.reason === "image_pull_failed") { - console.error(" ✗ Docker could not resolve or pull the DNS probe image."); + console.error(failLine("Docker could not resolve or pull the DNS probe image.")); } else { - console.error(" ✗ DNS resolution from inside a docker container failed."); + console.error(failLine("DNS resolution from inside a docker container failed.")); } if (dns.details) { for (const line of String(dns.details).split("\n").slice(-4)) { @@ -383,7 +387,7 @@ export function assertHostDnsHealthy(host: Host, opts: AssertHostDnsHealthyOpts return; } if (!isFatalHostDnsProbeFailure(result)) { - console.warn(` ⚠ Host DNS probe inconclusive (reason: ${result.reason ?? "unknown"}).`); + console.warn(warnLine(`Host DNS probe inconclusive (reason: ${result.reason ?? "unknown"}).`)); if (result.details) { console.warn(` ${String(result.details).trim()}`); } @@ -394,11 +398,15 @@ export function assertHostDnsHealthy(host: Host, opts: AssertHostDnsHealthyOpts } if (result.reason === "timeout" || result.reason === "killed") { - console.error(` ✗ Host DNS probe did not complete (could not resolve ${result.hostname}).`); + console.error( + failLine(`Host DNS probe did not complete (could not resolve ${result.hostname}).`), + ); } else if (result.reason === "resolution_failed") { - console.error(` ✗ Host could not resolve ${result.hostname} (resolver answered, no record).`); + console.error( + failLine(`Host could not resolve ${result.hostname} (resolver answered, no record).`), + ); } else { - console.error(` ✗ Host DNS resolution failed (could not resolve ${result.hostname}).`); + console.error(failLine(`Host DNS resolution failed (could not resolve ${result.hostname}).`)); } if (result.details) { console.error(` ${String(result.details).trim()}`); diff --git a/src/lib/onboard/sandbox-gpu-preflight.ts b/src/lib/onboard/sandbox-gpu-preflight.ts index 9778b9ab3cc..c119b14b697 100644 --- a/src/lib/onboard/sandbox-gpu-preflight.ts +++ b/src/lib/onboard/sandbox-gpu-preflight.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { dockerInfoFormat } from "../adapters/docker"; +import { failLine, warnLine } from "../cli/terminal-style"; import type { GpuDetection } from "../inference/nim"; import type { SandboxGpuProofResult } from "../state/registry"; import { findReadableNvidiaCdiSpecFiles, getDockerCdiSpecDirs } from "./docker-cdi"; @@ -83,7 +84,7 @@ export function sandboxGpuRemediationLines( export function exitOnSandboxGpuConfigErrors(config: SandboxGpuConfig): void { if (config.errors.length > 0) { console.error(""); - for (const error of config.errors) console.error(` ✗ ${error}`); + for (const error of config.errors) console.error(failLine(error)); process.exit(1); } } @@ -126,7 +127,7 @@ export function dockerNvidiaRuntimeAvailable(deps: SandboxGpuPreflightDeps = {}) function validateJetsonSandboxGpuPreflight(deps: SandboxGpuPreflightDeps): void { if (!dockerNvidiaRuntimeAvailable(deps)) { console.error(""); - console.error(" ✗ Docker NVIDIA runtime was not detected for Jetson/Tegra sandbox GPU."); + console.error(failLine("Docker NVIDIA runtime was not detected for Jetson/Tegra sandbox GPU.")); console.error(" Jetson sandbox GPU uses NVIDIA Container Runtime semantics, not CDI."); console.error( " Install/configure NVIDIA Container Toolkit for Docker, then restart Docker:", @@ -228,7 +229,7 @@ export function createDirectSandboxGpuVerifier( if (proof.optional !== true) { // Required proof (e.g. the sandbox-exec wrapper itself): keep the // historical hard-fail so onboarding aborts and rolls back. - console.error(` ✗ GPU proof failed: ${proof.label}`); + console.error(failLine(`GPU proof failed: ${proof.label}`)); if (diagnostic) console.error(` ${diagnostic}`); for (const line of sandboxGpuRemediationLines({ wslDockerDesktopStatus: detectWslDockerDesktopStatus(deps), @@ -248,7 +249,7 @@ export function createDirectSandboxGpuVerifier( if (proof.id === CUDA_USABILITY_PROOF_ID && cudaInitRan) { cudaFailure = { label: proof.label, detail: diagnostic }; } - console.warn(` ⚠ GPU proof inconclusive: ${proof.label}`); + console.warn(warnLine(`GPU proof inconclusive: ${proof.label}`)); if (diagnostic) console.warn(` ${diagnostic}`); } const status: SandboxGpuProofResult["status"] = cudaVerified @@ -259,7 +260,7 @@ export function createDirectSandboxGpuVerifier( if (status === "verified") { console.log(" ✓ Sandbox CUDA usability proven (cuInit succeeded)."); } else if (status === "failed") { - console.warn(` ⚠ Sandbox CUDA proof failed: ${cudaFailure?.label}`); + console.warn(warnLine(`Sandbox CUDA proof failed: ${cudaFailure?.label}`)); const lines = resolvedPlatform === "jetson" ? jetsonGpuProofRemediationLines() @@ -268,7 +269,9 @@ export function createDirectSandboxGpuVerifier( }); for (const line of lines) console.warn(` ${line}`); } else { - console.warn(" ⚠ Sandbox GPU enabled but CUDA usability is unverified (no CUDA proof ran)."); + console.warn( + warnLine("Sandbox GPU enabled but CUDA usability is unverified (no CUDA proof ran)."), + ); } return { status, @@ -308,7 +311,7 @@ export function validateSandboxGpuPreflight( ); if (cdiSpecFiles.length === 0) { console.error(""); - console.error(" ✗ Docker CDI GPU support was not detected."); + console.error(failLine("Docker CDI GPU support was not detected.")); for (const line of sandboxGpuRemediationLines({ wslDockerDesktopStatus, })) { From b1096dd7a1d704dfef3500bc7fc02380245b3933 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 1 Jul 2026 15:27:43 -0700 Subject: [PATCH 02/15] fix(onboard): apply warnLine to bridge container start probe inconclusive message PRA-1: console.warn at bridge-dns-preflight.ts:173 was rendering in default color instead of using warnLine() like the other warn messages added in this PR. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/onboard/bridge-dns-preflight.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/bridge-dns-preflight.ts b/src/lib/onboard/bridge-dns-preflight.ts index ec14cb5faa8..6452fedc909 100644 --- a/src/lib/onboard/bridge-dns-preflight.ts +++ b/src/lib/onboard/bridge-dns-preflight.ts @@ -171,7 +171,9 @@ export function assertDockerBridgeAndContainerDnsHealthy(host: Host, nonInteract process.exit(1); } else { console.warn( - ` ⚠ Bridge container start probe inconclusive (reason: ${bridgeStart.reason ?? "unknown"}).`, + warnLine( + `Bridge container start probe inconclusive (reason: ${bridgeStart.reason ?? "unknown"}).`, + ), ); if (bridgeStart.details) { for (const line of String(bridgeStart.details).split("\n").slice(-3)) { From 6f58de26612fa87c99ee5ea2b4a69ad93a459499 Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Thu, 2 Jul 2026 10:11:00 +0800 Subject: [PATCH 03/15] fix(onboard): route image-pull DNS warning through warnLine (#6004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the warnLine cutover for inconclusive container-DNS warnings: the image_pull_failed branch still emitted a hardcoded '⚠' line, bypassing the helper's TTY/NO_COLOR handling. Route it through warnLine so all inconclusive DNS warnings share one authoritative formatting path. Addresses CodeRabbit review comment on #6017. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Jason Ma --- src/lib/onboard/bridge-dns-preflight.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/bridge-dns-preflight.ts b/src/lib/onboard/bridge-dns-preflight.ts index 6452fedc909..ba5369dc496 100644 --- a/src/lib/onboard/bridge-dns-preflight.ts +++ b/src/lib/onboard/bridge-dns-preflight.ts @@ -206,7 +206,7 @@ export function assertDockerBridgeAndContainerDnsHealthy(host: Host, nonInteract if (!dnsIsFatal) { if (dns.reason === "image_pull_failed") { console.warn( - " ⚠ Container DNS probe inconclusive: docker couldn't pull the busybox test image.", + warnLine("Container DNS probe inconclusive: docker couldn't pull the busybox test image."), ); console.warn(" This usually means the docker daemon itself can't reach Docker Hub,"); console.warn( From 3c9cb0c24cad4107689a93ccd7de10a93b0d8443 Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Fri, 3 Jul 2026 16:06:01 +0800 Subject: [PATCH 04/15] fix(onboard): color preflight WARN/ERROR by target stream via styleText (#6004) Replace the module-load YW/RD-string preflight helpers with one severity renderer backed by node:util.styleText. warnLine/failLine now decide color from process.stderr (where console.warn/error write) and okLine/infoLine from process.stdout, so styleText's per-stream capability and NO_COLOR / NODE_DISABLE_COLORS / FORCE_COLOR handling apply to the stream the line actually lands on. Previously color was computed from process.stdout.isTTY while WARN/ERROR printed to stderr, so 'onboard >log' dropped their color on a color-capable terminal and 'onboard 2>log' leaked raw ANSI into the file. Apply the renderer across the onboard environment-preflight surface that emits through console.warn/error: unsupported/under-provisioned runtime, low-memory swap, and missing messaging providers in onboard.ts; Docker bridge / DNS in bridge-dns-preflight.ts; sandbox GPU in sandbox-gpu-preflight.ts; and sandbox->gateway bridge reachability in gateway-sandbox-reachability.ts. The two low-memory warnings move from console.log to console.warn so their stream matches their severity. The single-sink log(msg) surfaces (wsl-docker-desktop-gpu and the interactive provider/messaging verifiers) route all levels to stdout and are left for a follow-up sink refactor, since stderr-keyed color there would recreate the mismatch this change removes. Signed-off-by: Jason Ma --- src/lib/cli/terminal-style.test.ts | 105 +++++++++++++----- src/lib/cli/terminal-style.ts | 53 +++++++-- src/lib/onboard.ts | 25 +++-- .../onboard/gateway-sandbox-reachability.ts | 13 ++- 4 files changed, 145 insertions(+), 51 deletions(-) diff --git a/src/lib/cli/terminal-style.test.ts b/src/lib/cli/terminal-style.test.ts index 8a278acf57d..0f414ef8208 100644 --- a/src/lib/cli/terminal-style.test.ts +++ b/src/lib/cli/terminal-style.test.ts @@ -1,9 +1,21 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { B, D, G, R, RD, YW } from "./terminal-style"; +import { + B, + D, + failLine, + G, + infoLine, + okLine, + R, + RD, + severityLine, + warnLine, + YW, +} from "./terminal-style"; describe("terminal-style", () => { it("exports terminal style strings", () => { @@ -13,43 +25,86 @@ describe("terminal-style", () => { }); }); -const ORIGINAL_TTY = process.stdout.isTTY; +const ORIGINAL_STDOUT = { + isTTY: process.stdout.isTTY, + getColorDepth: process.stdout.getColorDepth, +}; +const ORIGINAL_STDERR = { + isTTY: process.stderr.isTTY, + getColorDepth: process.stderr.getColorDepth, +}; -function setTTY(value: boolean): void { - Object.defineProperty(process.stdout, "isTTY", { value, configurable: true }); +// styleText decides color from the target stream's reported color depth +// (`getColorDepth()`), which is where a real terminal folds in isTTY, NO_COLOR, +// NODE_DISABLE_COLORS and FORCE_COLOR. Depth 1 = no color (what NO_COLOR / a +// redirected pipe / CI report); depth 24 = truecolor. Model both directly so +// each case is deterministic regardless of the worker's own TTY/env. +function stubStream(stream: NodeJS.WriteStream, isTTY: boolean, colorDepth: number): void { + Object.defineProperty(stream, "isTTY", { value: isTTY, configurable: true }); + Object.defineProperty(stream, "getColorDepth", { value: () => colorDepth, configurable: true }); } -// #6004: warnLine/failLine read NO_COLOR + stdout.isTTY at module load, so each -// case reloads the module under a stubbed environment. -async function loadStyle(opts: { tty: boolean; noColor?: string }) { - vi.resetModules(); - setTTY(opts.tty); - vi.stubEnv("NO_COLOR", opts.noColor ?? ""); - return import("./terminal-style"); +function restoreStream( + stream: NodeJS.WriteStream, + original: { isTTY: boolean | undefined; getColorDepth: unknown }, +): void { + Object.defineProperty(stream, "isTTY", { value: original.isTTY, configurable: true }); + Object.defineProperty(stream, "getColorDepth", { + value: original.getColorDepth, + configurable: true, + }); } -describe("preflight line helpers (#6004)", () => { +// styleText's `yellow`/`red`/`green` formats (as of Node 22.16) wrap text in +// SGR color codes with a `39` (default-foreground) reset. +const YELLOW = (s: string) => `\x1b[33m${s}\x1b[39m`; +const RED = (s: string) => `\x1b[31m${s}\x1b[39m`; +const GREEN = (s: string) => `\x1b[32m${s}\x1b[39m`; + +describe("preflight severity lines (#6004)", () => { afterEach(() => { vi.unstubAllEnvs(); - vi.resetModules(); - setTTY(ORIGINAL_TTY); + restoreStream(process.stdout, ORIGINAL_STDOUT); + restoreStream(process.stderr, ORIGINAL_STDERR); }); - it("renders warn ⚠ in yellow and fail ✗ in red on a color-capable TTY", async () => { - const { warnLine, failLine } = await loadStyle({ tty: true, noColor: "" }); - expect(warnLine("disk low")).toBe(" \x1b[1;33m⚠ disk low\x1b[0m"); - expect(failLine("docker down")).toBe(" \x1b[1;31m✗ docker down\x1b[0m"); + it("colors warn/error from stderr — their real stream — not stdout (#6004)", () => { + // stdout redirected to a file, terminal still on stderr: warn/error must + // stay colored (they land on the color-capable stderr) while stdout-bound + // ok lines go plain. The old helpers colored from stdout and dropped these. + stubStream(process.stderr, true, 24); + stubStream(process.stdout, false, 1); + expect(warnLine("disk low")).toBe(` ${YELLOW("⚠ disk low")}`); + expect(failLine("docker down")).toBe(` ${RED("✗ docker down")}`); + expect(okLine("ready")).toBe(" ✓ ready"); }); - it("emits plain text (no ANSI) under NO_COLOR=1", async () => { - const { warnLine, failLine } = await loadStyle({ tty: true, noColor: "1" }); + it("drops warn/error color when stderr is redirected but stdout is a TTY (#6004)", () => { + // The inverse leak: stderr redirected to a log, stdout still a terminal. + // warn/error must go plain so no raw ANSI lands in the log; stdout-bound + // ok lines stay colored. The old helpers colored from stdout and leaked. + stubStream(process.stdout, true, 24); + stubStream(process.stderr, false, 1); expect(warnLine("disk low")).toBe(" ⚠ disk low"); expect(failLine("docker down")).toBe(" ✗ docker down"); + expect(okLine("ready")).toBe(` ${GREEN("✓ ready")}`); + }); + + it("emits plain text with no ANSI when the stream reports no color (NO_COLOR / CI)", () => { + // A real terminal under NO_COLOR reports color depth 1; redirected pipes + // and CI do the same. Assert the observable #6004 guarantee: no escapes. + vi.stubEnv("NO_COLOR", "1"); + stubStream(process.stdout, true, 1); + stubStream(process.stderr, true, 1); + expect(warnLine("a")).toBe(" ⚠ a"); + expect(failLine("b")).toBe(" ✗ b"); + expect(okLine("c")).toBe(" ✓ c"); }); - it("emits plain text (no ANSI) when stdout is not a TTY", async () => { - const { warnLine, failLine } = await loadStyle({ tty: false }); - expect(warnLine("x")).toBe(" ⚠ x"); - expect(failLine("y")).toBe(" ✗ y"); + it("leaves info lines uncolored with no marker on any stream", () => { + stubStream(process.stdout, true, 24); + stubStream(process.stderr, true, 24); + expect(infoLine("hello")).toBe(" hello"); + expect(severityLine("info", "hello")).toBe(" hello"); }); }); diff --git a/src/lib/cli/terminal-style.ts b/src/lib/cli/terminal-style.ts index ef01beae491..bc70c6df8c7 100644 --- a/src/lib/cli/terminal-style.ts +++ b/src/lib/cli/terminal-style.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { styleText } from "node:util"; + const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); @@ -13,16 +15,47 @@ export const RD = useColor ? "\x1b[1;31m" : ""; export const YW = useColor ? "\x1b[1;33m" : ""; /** - * Preflight result line helpers (#6004). Render a warning (`⚠`) line in yellow - * and a failure (`✗`) line in red so they stand out from the default-colored - * `✓`/INFO lines in the lengthy onboard preflight output. Color is suppressed - * automatically when `NO_COLOR` is set or stdout is not a TTY (via `YW`/`RD`/`R` - * being empty strings), so CI output stays plain text. + * Semantic severity levels for onboard preflight output (#6004). + * + * `info` keeps the default terminal color; `ok`/`warn`/`error` add a colored + * marker so warnings and failures stand out in the lengthy preflight output. */ -export function warnLine(message: string): string { - return ` ${YW}⚠ ${message}${R}`; -} +export type SeverityLevel = "info" | "ok" | "warn" | "error"; + +type SeverityStyle = { + marker: string; + format: "green" | "yellow" | "red" | null; + stream: NodeJS.WriteStream; +}; + +// The stream each level is written to decides its color. `ok`/`info` are +// emitted on stdout (`console.log`/`console.info`); `warn`/`error` on stderr +// (`console.warn`/`console.error`). `styleText({ stream })` then keys color off +// that stream's own capability and honors NO_COLOR / NODE_DISABLE_COLORS / +// FORCE_COLOR (#6004). This replaces the previous helpers, which colored from +// `process.stdout.isTTY` while printing to stderr — so redirecting either +// stream independently mis-styled the other (dropped color on `onboard >log`, +// leaked ANSI into `onboard 2>log`). +const SEVERITY_STYLES: Record = { + info: { marker: "", format: null, stream: process.stdout }, + ok: { marker: "✓ ", format: "green", stream: process.stdout }, + warn: { marker: "⚠ ", format: "yellow", stream: process.stderr }, + error: { marker: "✗ ", format: "red", stream: process.stderr }, +}; -export function failLine(message: string): string { - return ` ${RD}✗ ${message}${R}`; +/** + * Render one indented preflight line at `level`. The returned string is meant + * to be passed to the matching console method (`ok`/`info` → `console.log` / + * `console.info`; `warn` → `console.warn`; `error` → `console.error`) so its + * color decision matches the stream it lands on. + */ +export function severityLine(level: SeverityLevel, message: string): string { + const { marker, format, stream } = SEVERITY_STYLES[level]; + const body = `${marker}${message}`; + return ` ${format ? styleText(format, body, { stream }) : body}`; } + +export const infoLine = (message: string): string => severityLine("info", message); +export const okLine = (message: string): string => severityLine("ok", message); +export const warnLine = (message: string): string => severityLine("warn", message); +export const failLine = (message: string): string => severityLine("error", message); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 110cc6ab5dd..dd455265d1a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -560,6 +560,7 @@ const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-f import type { CurlProbeResult } from "./adapters/http/probe"; import type { AgentDefinition } from "./agent/defs"; +import { failLine, warnLine } from "./cli/terminal-style"; import type { WebSearchConfig } from "./inference/web-search"; import { hydrateMessagingChannelConfig, @@ -1593,7 +1594,7 @@ type PreflightOptions = Pick< // running bridge/DNS diagnostics that would be misleading. function rejectUnsupportedContainerRuntime(host: ReturnType): void { if (isLinuxDockerDriverGatewayEnabled() && host.runtime === "podman") { - console.error(` ✗ ${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`); + console.error(failLine(`${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`)); console.error(` Podman is not supported for this ${cliDisplayName()} integration path.`); console.error(" Switch to Docker Engine and rerun onboarding."); process.exit(1); @@ -1654,8 +1655,10 @@ async function preflight( } const detectedStr = detected.length > 0 ? detected.join(" / ") : "unknown"; console.warn( - ` ⚠ Container runtime under-provisioned: ${detectedStr} detected ` + - `(recommended: ${preflightUtils.MIN_RECOMMENDED_DOCKER_CPUS} vCPU / ${preflightUtils.MIN_RECOMMENDED_DOCKER_MEM_GIB} GiB).`, + warnLine( + `Container runtime under-provisioned: ${detectedStr} detected ` + + `(recommended: ${preflightUtils.MIN_RECOMMENDED_DOCKER_CPUS} vCPU / ${preflightUtils.MIN_RECOMMENDED_DOCKER_MEM_GIB} GiB).`, + ), ); console.warn(" The sandbox build will be slow and may stall on default Colima settings."); if (host.runtime === "colima") { @@ -1910,8 +1913,10 @@ async function preflight( const mem = getMemoryInfo(); if (mem) { if (mem.totalMB < 12000) { - console.log( - ` ⚠ Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, + console.warn( + warnLine( + `Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, + ), ); let proceedWithSwap: boolean = false; @@ -1938,8 +1943,8 @@ async function preflight( console.log(` ✓ Memory OK: ${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap`); } } else { - console.log(` ⚠ Could not create swap: ${swapResult.reason}`); - console.log(" Sandbox creation may fail with OOM on low-memory systems."); + console.warn(warnLine(`Could not create swap: ${swapResult.reason}`)); + console.warn(" Sandbox creation may fail with OOM on low-memory systems."); } } } else { @@ -3241,9 +3246,9 @@ async function createSandbox( // cannot be verified via CLI yet — only gateway-level existence is checked). for (const p of messagingProviders) { if (!providerExistsInGateway(p)) { - console.error(` ⚠ Messaging provider '${p}' was not found in the gateway.`); - console.error(` The credential may not be available inside the sandbox.`); - console.error( + console.warn(warnLine(`Messaging provider '${p}' was not found in the gateway.`)); + console.warn(` The credential may not be available inside the sandbox.`); + console.warn( ` To fix: openshell provider create --name ${p} --type generic --credential `, ); } diff --git a/src/lib/onboard/gateway-sandbox-reachability.ts b/src/lib/onboard/gateway-sandbox-reachability.ts index 86c7f489a09..4eb7f620a3a 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.ts @@ -13,6 +13,7 @@ import os from "node:os"; import { dockerCapture, dockerRun } from "../adapters/docker/run"; +import { failLine, warnLine } from "../cli/terminal-style"; import { GATEWAY_PORT } from "../core/ports"; import { cliDisplayName, cliName } from "./branding"; import { @@ -406,7 +407,7 @@ export function formatSandboxBridgeUnreachableMessage( const includeWslIntegrationHint = opts.isWsl ?? isRunningInWsl(); if (result.reason === "probe_unavailable") { return [ - " ⚠ Could not verify sandbox bridge reachability.", + warnLine("Could not verify sandbox bridge reachability."), " This does not prove the gateway is unreachable; continuing.", result.detail ? ` ${result.detail}` : undefined, ] @@ -416,7 +417,7 @@ export function formatSandboxBridgeUnreachableMessage( if (result.reason === "veth_unsupported") { return [ - " ✗ Docker could not create the sandbox bridge veth pair.", + failLine("Docker could not create the sandbox bridge veth pair."), result.detail ? ` ${result.detail}` : undefined, " This matches Jetson kernel/Docker bridge environments where veth creation returns `operation not supported`.", ` Update the host kernel/Docker bridge networking support, or run ${cliDisplayName()} on a host whose Docker bridge networking can create veth interfaces.`, @@ -427,7 +428,7 @@ export function formatSandboxBridgeUnreachableMessage( if (result.reason === "probe_timeout") { return [ - " ✗ Docker-driver sandbox bridge reachability probe timed out.", + failLine("Docker-driver sandbox bridge reachability probe timed out."), result.detail ? ` ${result.detail}` : undefined, ` Restart Docker and check for stuck container/network operations before retrying \`${cliName()} onboard\`.`, ] @@ -437,7 +438,7 @@ export function formatSandboxBridgeUnreachableMessage( if (result.reason === "docker_daemon_unreachable") { return [ - " ✗ Docker daemon is not reachable for the sandbox bridge probe.", + failLine("Docker daemon is not reachable for the sandbox bridge probe."), result.detail ? ` ${result.detail}` : undefined, includeWslIntegrationHint ? ` ${DOCKER_DESKTOP_WSL_INTEGRATION_HINT}` : undefined, " Restart the Docker daemon (e.g. `sudo systemctl restart docker`, or restart Docker Desktop/Colima)", @@ -449,7 +450,7 @@ export function formatSandboxBridgeUnreachableMessage( if (result.routeKind === "host_gateway") { return [ - ` ✗ Sandbox containers cannot reach the gateway at ${HOST_INTERNAL_NAME}:${port}.`, + failLine(`Sandbox containers cannot reach the gateway at ${HOST_INTERNAL_NAME}:${port}.`), " The probe used Docker's host-gateway route, matching Docker Desktop/VM-backed Docker.", ` Restart Docker and the OpenShell gateway, then re-run \`${cliName()} onboard\`.`, ].join("\n"); @@ -468,7 +469,7 @@ export function formatSandboxBridgeUnreachableMessage( ? `${HOST_INTERNAL_NAME}:${port} (${result.gatewayIp}:${port})` : `${HOST_INTERNAL_NAME}:${port}`; return [ - ` ✗ Sandbox containers cannot reach the gateway at ${target}.`, + failLine(`Sandbox containers cannot reach the gateway at ${target}.`), " A host firewall may be blocking traffic from the OpenShell Docker bridge.", " To allow it:", allowCmd, From 61a3eccab02d7ee901360d80c3c017dfc5ca7669 Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Fri, 3 Jul 2026 16:13:56 +0800 Subject: [PATCH 05/15] fix(onboard): keep onboard.ts net-neutral under growth guardrail (#6004) Revert the inline severity-line conversions in src/lib/onboard.ts. The codebase-growth-guardrails check requires onboard.ts to be net-neutral or smaller, and wrapping its multi-line WARN/ERROR messages in warnLine/failLine (plus the new import) grew the file. Converting those inline lines needs a module extraction, which is deferred to the same follow-up as the log-sink severity refactor. The severity renderer and the already-extracted preflight modules (bridge-dns-preflight, sandbox-gpu-preflight, gateway-sandbox-reachability) still carry the stream-keyed color fix, covering the Docker/DNS/GPU preflight failures including the #6004 Docker-not-running repro. Signed-off-by: Jason Ma --- src/lib/onboard.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index dd455265d1a..110cc6ab5dd 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -560,7 +560,6 @@ const sandboxCreateFailureDiagnostics: typeof import("./onboard/sandbox-create-f import type { CurlProbeResult } from "./adapters/http/probe"; import type { AgentDefinition } from "./agent/defs"; -import { failLine, warnLine } from "./cli/terminal-style"; import type { WebSearchConfig } from "./inference/web-search"; import { hydrateMessagingChannelConfig, @@ -1594,7 +1593,7 @@ type PreflightOptions = Pick< // running bridge/DNS diagnostics that would be misleading. function rejectUnsupportedContainerRuntime(host: ReturnType): void { if (isLinuxDockerDriverGatewayEnabled() && host.runtime === "podman") { - console.error(failLine(`${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`)); + console.error(` ✗ ${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`); console.error(` Podman is not supported for this ${cliDisplayName()} integration path.`); console.error(" Switch to Docker Engine and rerun onboarding."); process.exit(1); @@ -1655,10 +1654,8 @@ async function preflight( } const detectedStr = detected.length > 0 ? detected.join(" / ") : "unknown"; console.warn( - warnLine( - `Container runtime under-provisioned: ${detectedStr} detected ` + - `(recommended: ${preflightUtils.MIN_RECOMMENDED_DOCKER_CPUS} vCPU / ${preflightUtils.MIN_RECOMMENDED_DOCKER_MEM_GIB} GiB).`, - ), + ` ⚠ Container runtime under-provisioned: ${detectedStr} detected ` + + `(recommended: ${preflightUtils.MIN_RECOMMENDED_DOCKER_CPUS} vCPU / ${preflightUtils.MIN_RECOMMENDED_DOCKER_MEM_GIB} GiB).`, ); console.warn(" The sandbox build will be slow and may stall on default Colima settings."); if (host.runtime === "colima") { @@ -1913,10 +1910,8 @@ async function preflight( const mem = getMemoryInfo(); if (mem) { if (mem.totalMB < 12000) { - console.warn( - warnLine( - `Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, - ), + console.log( + ` ⚠ Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, ); let proceedWithSwap: boolean = false; @@ -1943,8 +1938,8 @@ async function preflight( console.log(` ✓ Memory OK: ${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap`); } } else { - console.warn(warnLine(`Could not create swap: ${swapResult.reason}`)); - console.warn(" Sandbox creation may fail with OOM on low-memory systems."); + console.log(` ⚠ Could not create swap: ${swapResult.reason}`); + console.log(" Sandbox creation may fail with OOM on low-memory systems."); } } } else { @@ -3246,9 +3241,9 @@ async function createSandbox( // cannot be verified via CLI yet — only gateway-level existence is checked). for (const p of messagingProviders) { if (!providerExistsInGateway(p)) { - console.warn(warnLine(`Messaging provider '${p}' was not found in the gateway.`)); - console.warn(` The credential may not be available inside the sandbox.`); - console.warn( + console.error(` ⚠ Messaging provider '${p}' was not found in the gateway.`); + console.error(` The credential may not be available inside the sandbox.`); + console.error( ` To fix: openshell provider create --name ${p} --type generic --credential `, ); } From d004a266d3e80cbf86608836352dc93603443742 Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Fri, 3 Jul 2026 16:28:42 +0800 Subject: [PATCH 06/15] fix(onboard): extract preflight severity messages so onboard.ts lines adopt the renderer (#6004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the inline onboard.ts preflight WARN/ERROR emitters — unsupported runtime, under-provisioned runtime, low-memory, failed swap, and missing messaging provider — into src/lib/onboard/preflight-messages.ts, where they use the shared stream-keyed warnLine/failLine renderer. onboard.ts now calls one-liners, so it shrinks (net -9 lines) and stays within the codebase-growth / onboard-entrypoint budget instead of growing as the earlier inline attempt did. WARN lines route through console.warn and ERROR lines through console.error, so the renderer's stderr-keyed color matches the stream. The two low-memory warnings therefore move from console.log to console.warn. Adds representative call-site tests for each emitter, including the colima vs docker-desktop resize branch. This closes the onboard.ts side of #6004; the single-sink log() surfaces (wsl-docker-desktop-gpu, provider/messaging verifiers) remain a separate follow-up. Signed-off-by: Jason Ma --- src/lib/onboard.ts | 43 +++++------- src/lib/onboard/preflight-messages.test.ts | 77 +++++++++++++++++++++ src/lib/onboard/preflight-messages.ts | 80 ++++++++++++++++++++++ 3 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 src/lib/onboard/preflight-messages.test.ts create mode 100644 src/lib/onboard/preflight-messages.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 110cc6ab5dd..80eae269662 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -592,6 +592,13 @@ import { setupPoliciesWithSelection as setupPoliciesWithSelectionImpl, } from "./onboard/policy-selection"; import { createPolicySelectionPromptHelpers } from "./onboard/policy-selection-prompts"; +import { + printLowMemoryWarning, + printMessagingProviderMissing, + printSwapCreationFailed, + printUnderProvisionedRuntimeWarning, + printUnsupportedRuntimeError, +} from "./onboard/preflight-messages"; import { backupSandboxBeforeRecreate, shouldSkipPreRecreateBackup, @@ -1593,9 +1600,7 @@ type PreflightOptions = Pick< // running bridge/DNS diagnostics that would be misleading. function rejectUnsupportedContainerRuntime(host: ReturnType): void { if (isLinuxDockerDriverGatewayEnabled() && host.runtime === "podman") { - console.error(` ✗ ${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`); - console.error(` Podman is not supported for this ${cliDisplayName()} integration path.`); - console.error(" Switch to Docker Engine and rerun onboarding."); + printUnsupportedRuntimeError(); process.exit(1); } } @@ -1653,19 +1658,12 @@ async function preflight( detected.push(`${gib.toFixed(1)} GiB`); } const detectedStr = detected.length > 0 ? detected.join(" / ") : "unknown"; - console.warn( - ` ⚠ Container runtime under-provisioned: ${detectedStr} detected ` + - `(recommended: ${preflightUtils.MIN_RECOMMENDED_DOCKER_CPUS} vCPU / ${preflightUtils.MIN_RECOMMENDED_DOCKER_MEM_GIB} GiB).`, - ); - console.warn(" The sandbox build will be slow and may stall on default Colima settings."); - if (host.runtime === "colima") { - console.warn( - ` Suggested: colima stop && colima start --cpu ${preflightUtils.MIN_RECOMMENDED_DOCKER_CPUS} --memory ${preflightUtils.MIN_RECOMMENDED_DOCKER_MEM_GIB}`, - ); - } else if (host.runtime === "docker-desktop") { - console.warn(" Suggested: Docker Desktop → Settings → Resources, raise CPU/memory."); - } - console.warn(" Set NEMOCLAW_IGNORE_RUNTIME_RESOURCES=1 to silence this check."); + printUnderProvisionedRuntimeWarning({ + detectedStr, + runtime: host.runtime, + recommendedCpus: preflightUtils.MIN_RECOMMENDED_DOCKER_CPUS, + recommendedMemGib: preflightUtils.MIN_RECOMMENDED_DOCKER_MEM_GIB, + }); if (isNonInteractive()) { console.warn( " WARNING: Non-interactive mode is continuing despite under-provisioned runtime.", @@ -1910,9 +1908,7 @@ async function preflight( const mem = getMemoryInfo(); if (mem) { if (mem.totalMB < 12000) { - console.log( - ` ⚠ Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, - ); + printLowMemoryWarning(mem); let proceedWithSwap: boolean = false; if (!isNonInteractive()) { @@ -1938,8 +1934,7 @@ async function preflight( console.log(` ✓ Memory OK: ${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap`); } } else { - console.log(` ⚠ Could not create swap: ${swapResult.reason}`); - console.log(" Sandbox creation may fail with OOM on low-memory systems."); + printSwapCreationFailed(swapResult.reason); } } } else { @@ -3241,11 +3236,7 @@ async function createSandbox( // cannot be verified via CLI yet — only gateway-level existence is checked). for (const p of messagingProviders) { if (!providerExistsInGateway(p)) { - console.error(` ⚠ Messaging provider '${p}' was not found in the gateway.`); - console.error(` The credential may not be available inside the sandbox.`); - console.error( - ` To fix: openshell provider create --name ${p} --type generic --credential `, - ); + printMessagingProviderMissing(p); } } diff --git a/src/lib/onboard/preflight-messages.test.ts b/src/lib/onboard/preflight-messages.test.ts new file mode 100644 index 00000000000..e803ab4677e --- /dev/null +++ b/src/lib/onboard/preflight-messages.test.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + printLowMemoryWarning, + printMessagingProviderMissing, + printSwapCreationFailed, + printUnderProvisionedRuntimeWarning, + printUnsupportedRuntimeError, +} from "./preflight-messages"; + +function lines(spy: ReturnType): string[] { + return spy.mock.calls.map((call: unknown[]) => String(call[0])); +} + +describe("onboard preflight severity messages (#6004)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("prints the unsupported-runtime failure to stderr with a ✗ marker", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + printUnsupportedRuntimeError(); + expect(err).toHaveBeenCalledTimes(3); + expect(lines(err)[0]).toContain("✗"); + expect(lines(err)[0]).toContain("Docker driver"); + expect(lines(err).join("\n")).toContain("Switch to Docker Engine"); + }); + + it("prints the under-provisioned warning to stderr with a ⚠ marker and colima resize", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + printUnderProvisionedRuntimeWarning({ + detectedStr: "2 vCPU / 2.0 GiB", + runtime: "colima", + recommendedCpus: 4, + recommendedMemGib: 12, + }); + expect(lines(warn)[0]).toContain("⚠"); + expect(lines(warn)[0]).toContain("under-provisioned: 2 vCPU / 2.0 GiB"); + expect(lines(warn).join("\n")).toContain("colima start --cpu 4 --memory 12"); + }); + + it("prints the Docker Desktop resize hint for the docker-desktop runtime", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + printUnderProvisionedRuntimeWarning({ + detectedStr: "x", + runtime: "docker-desktop", + recommendedCpus: 4, + recommendedMemGib: 12, + }); + expect(lines(warn).join("\n")).toContain("Docker Desktop → Settings → Resources"); + }); + + it("prints the low-memory warning to stderr with a ⚠ marker", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + printLowMemoryWarning({ totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }); + expect(lines(warn)[0]).toContain( + "⚠ Low memory detected (4000 MB RAM + 0 MB swap = 4000 MB total)", + ); + }); + + it("prints the swap-creation failure to stderr with a ⚠ marker", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + printSwapCreationFailed("mkswap failed"); + expect(lines(warn)[0]).toContain("⚠ Could not create swap: mkswap failed"); + expect(lines(warn).join("\n")).toContain("may fail with OOM"); + }); + + it("prints a missing messaging provider to stderr with a ⚠ marker and fix hint", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + printMessagingProviderMissing("slack"); + expect(lines(warn)[0]).toContain("⚠ Messaging provider 'slack' was not found in the gateway."); + expect(lines(warn).join("\n")).toContain("openshell provider create --name slack"); + }); +}); diff --git a/src/lib/onboard/preflight-messages.ts b/src/lib/onboard/preflight-messages.ts new file mode 100644 index 00000000000..cb736059d69 --- /dev/null +++ b/src/lib/onboard/preflight-messages.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Onboard preflight severity messages, extracted from `onboard.ts` so they can + * adopt the shared `warnLine`/`failLine` renderer (#6004) without growing the + * top-level entrypoint past the `onboard-entrypoint-budget` / codebase-growth + * CI ceiling (same extraction pattern as `bridge-dns-preflight.ts`). + * + * Every WARN line here is emitted through `console.warn` and every ERROR line + * through `console.error`, so the renderer's stderr-keyed color decision + * matches the stream the line lands on. + */ + +import { failLine, warnLine } from "../cli/terminal-style"; +import { cliDisplayName } from "./branding"; + +/** Podman under the Linux Docker-driver path is unsupported. */ +export function printUnsupportedRuntimeError(): void { + console.error(failLine(`${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`)); + console.error(` Podman is not supported for this ${cliDisplayName()} integration path.`); + console.error(" Switch to Docker Engine and rerun onboarding."); +} + +export interface UnderProvisionedRuntimeWarning { + /** Human-readable detected resources, e.g. "2 vCPU / 2.0 GiB". */ + detectedStr: string; + /** Container runtime kind (drives the resize suggestion). */ + runtime: string; + recommendedCpus: number; + recommendedMemGib: number; +} + +/** Container runtime detected below the recommended CPU/memory floor. */ +export function printUnderProvisionedRuntimeWarning(opts: UnderProvisionedRuntimeWarning): void { + const { detectedStr, runtime, recommendedCpus, recommendedMemGib } = opts; + console.warn( + warnLine( + `Container runtime under-provisioned: ${detectedStr} detected ` + + `(recommended: ${recommendedCpus} vCPU / ${recommendedMemGib} GiB).`, + ), + ); + console.warn(" The sandbox build will be slow and may stall on default Colima settings."); + if (runtime === "colima") { + console.warn( + ` Suggested: colima stop && colima start --cpu ${recommendedCpus} --memory ${recommendedMemGib}`, + ); + } else if (runtime === "docker-desktop") { + console.warn(" Suggested: Docker Desktop → Settings → Resources, raise CPU/memory."); + } + console.warn(" Set NEMOCLAW_IGNORE_RUNTIME_RESOURCES=1 to silence this check."); +} + +/** Total system memory is below the sandbox-build comfort threshold. */ +export function printLowMemoryWarning(mem: { + totalRamMB: number; + totalSwapMB: number; + totalMB: number; +}): void { + console.warn( + warnLine( + `Low memory detected (${mem.totalRamMB} MB RAM + ${mem.totalSwapMB} MB swap = ${mem.totalMB} MB total)`, + ), + ); +} + +/** Swap-file creation failed on a low-memory host. */ +export function printSwapCreationFailed(reason: string | undefined): void { + console.warn(warnLine(`Could not create swap: ${reason}`)); + console.warn(" Sandbox creation may fail with OOM on low-memory systems."); +} + +/** A configured messaging provider was not present in the gateway. */ +export function printMessagingProviderMissing(providerName: string): void { + console.warn(warnLine(`Messaging provider '${providerName}' was not found in the gateway.`)); + console.warn(" The credential may not be available inside the sandbox."); + console.warn( + ` To fix: openshell provider create --name ${providerName} --type generic --credential `, + ); +} From a3bbfe477af6dd5db769c6e6be0da0408a74ca31 Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Fri, 3 Jul 2026 16:57:47 +0800 Subject: [PATCH 07/15] test(onboard): drop unused beforeEach import in terminal-style test (#6004) CodeQL / code-quality flagged the unused `beforeEach` named import left behind when the color tests moved to stream stubbing in afterEach. Signed-off-by: Jason Ma --- src/lib/cli/terminal-style.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/cli/terminal-style.test.ts b/src/lib/cli/terminal-style.test.ts index 0f414ef8208..fb5518f08d1 100644 --- a/src/lib/cli/terminal-style.test.ts +++ b/src/lib/cli/terminal-style.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { B, From 1bd7555467145b0d6e9edcf96ca27817376daa13 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 3 Jul 2026 13:58:17 -0700 Subject: [PATCH 08/15] fix(onboard): color Docker-unreachable preflight --- src/lib/cli/terminal-style.ts | 5 +++++ src/lib/onboard.ts | 3 ++- src/lib/onboard/preflight-messages.test.ts | 8 ++++++++ src/lib/onboard/preflight-messages.ts | 5 +++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/cli/terminal-style.ts b/src/lib/cli/terminal-style.ts index bc70c6df8c7..8556672998d 100644 --- a/src/lib/cli/terminal-style.ts +++ b/src/lib/cli/terminal-style.ts @@ -3,6 +3,11 @@ import { styleText } from "node:util"; +/** + * Legacy color constants (`G`, `B`, `D`, `R`, `RD`, `YW`) are frozen at module + * import time; import after `NO_COLOR` and TTY state are configured. Prefer the + * call-time severity helpers below for new output. + */ const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 80eae269662..2e8229bd58c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -593,6 +593,7 @@ import { } from "./onboard/policy-selection"; import { createPolicySelectionPromptHelpers } from "./onboard/policy-selection-prompts"; import { + printDockerNotReachableError, printLowMemoryWarning, printMessagingProviderMissing, printSwapCreationFailed, @@ -1614,7 +1615,7 @@ async function preflight( // Docker / runtime if (!host.dockerReachable) { - console.error(" Docker is not reachable. Please fix Docker and try again."); + printDockerNotReachableError(); printRemediationActions(planHostRemediation(host)); process.exit(1); } diff --git a/src/lib/onboard/preflight-messages.test.ts b/src/lib/onboard/preflight-messages.test.ts index e803ab4677e..df98e53ab0a 100644 --- a/src/lib/onboard/preflight-messages.test.ts +++ b/src/lib/onboard/preflight-messages.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + printDockerNotReachableError, printLowMemoryWarning, printMessagingProviderMissing, printSwapCreationFailed, @@ -20,6 +21,13 @@ describe("onboard preflight severity messages (#6004)", () => { vi.restoreAllMocks(); }); + it("prints the Docker-unreachable failure to stderr with a ✗ marker", () => { + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + printDockerNotReachableError(); + expect(err).toHaveBeenCalledOnce(); + expect(lines(err)[0]).toBe(" ✗ Docker is not reachable. Please fix Docker and try again."); + }); + it("prints the unsupported-runtime failure to stderr with a ✗ marker", () => { const err = vi.spyOn(console, "error").mockImplementation(() => undefined); printUnsupportedRuntimeError(); diff --git a/src/lib/onboard/preflight-messages.ts b/src/lib/onboard/preflight-messages.ts index cb736059d69..4af0e4d15a6 100644 --- a/src/lib/onboard/preflight-messages.ts +++ b/src/lib/onboard/preflight-messages.ts @@ -15,6 +15,11 @@ import { failLine, warnLine } from "../cli/terminal-style"; import { cliDisplayName } from "./branding"; +/** Docker cannot be reached, so onboarding cannot continue. */ +export function printDockerNotReachableError(): void { + console.error(failLine("Docker is not reachable. Please fix Docker and try again.")); +} + /** Podman under the Linux Docker-driver path is unsupported. */ export function printUnsupportedRuntimeError(): void { console.error(failLine(`${cliDisplayName()} onboarding now uses OpenShell's Docker driver.`)); From 210c6a0483a8e7c0d27dde8fa48d3dafbbd6f049 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 3 Jul 2026 14:29:09 -0700 Subject: [PATCH 09/15] fix(onboard): tighten preflight severity helpers Signed-off-by: Carlos Villela --- src/lib/cli/terminal-style.test.ts | 35 ++++------- src/lib/cli/terminal-style.ts | 59 +++++-------------- src/lib/onboard/bridge-dns-preflight.ts | 7 ++- .../gateway-sandbox-reachability.test.ts | 42 +++++++++++++ 4 files changed, 74 insertions(+), 69 deletions(-) diff --git a/src/lib/cli/terminal-style.test.ts b/src/lib/cli/terminal-style.test.ts index fb5518f08d1..39e92314b68 100644 --- a/src/lib/cli/terminal-style.test.ts +++ b/src/lib/cli/terminal-style.test.ts @@ -3,19 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - B, - D, - failLine, - G, - infoLine, - okLine, - R, - RD, - severityLine, - warnLine, - YW, -} from "./terminal-style"; +import { B, D, failLine, G, R, RD, warnLine, YW } from "./terminal-style"; describe("terminal-style", () => { it("exports terminal style strings", () => { @@ -59,11 +47,10 @@ function restoreStream( // SGR color codes with a `39` (default-foreground) reset. const YELLOW = (s: string) => `\x1b[33m${s}\x1b[39m`; const RED = (s: string) => `\x1b[31m${s}\x1b[39m`; -const GREEN = (s: string) => `\x1b[32m${s}\x1b[39m`; - describe("preflight severity lines (#6004)", () => { afterEach(() => { vi.unstubAllEnvs(); + vi.resetModules(); restoreStream(process.stdout, ORIGINAL_STDOUT); restoreStream(process.stderr, ORIGINAL_STDERR); }); @@ -76,18 +63,16 @@ describe("preflight severity lines (#6004)", () => { stubStream(process.stdout, false, 1); expect(warnLine("disk low")).toBe(` ${YELLOW("⚠ disk low")}`); expect(failLine("docker down")).toBe(` ${RED("✗ docker down")}`); - expect(okLine("ready")).toBe(" ✓ ready"); }); it("drops warn/error color when stderr is redirected but stdout is a TTY (#6004)", () => { // The inverse leak: stderr redirected to a log, stdout still a terminal. - // warn/error must go plain so no raw ANSI lands in the log; stdout-bound - // ok lines stay colored. The old helpers colored from stdout and leaked. + // warn/error must go plain so no raw ANSI lands in the log. The old + // helpers colored from stdout and leaked. stubStream(process.stdout, true, 24); stubStream(process.stderr, false, 1); expect(warnLine("disk low")).toBe(" ⚠ disk low"); expect(failLine("docker down")).toBe(" ✗ docker down"); - expect(okLine("ready")).toBe(` ${GREEN("✓ ready")}`); }); it("emits plain text with no ANSI when the stream reports no color (NO_COLOR / CI)", () => { @@ -98,13 +83,15 @@ describe("preflight severity lines (#6004)", () => { stubStream(process.stderr, true, 1); expect(warnLine("a")).toBe(" ⚠ a"); expect(failLine("b")).toBe(" ✗ b"); - expect(okLine("c")).toBe(" ✓ c"); }); - it("leaves info lines uncolored with no marker on any stream", () => { + it("selects the legacy true-color green when configured before import", async () => { stubStream(process.stdout, true, 24); - stubStream(process.stderr, true, 24); - expect(infoLine("hello")).toBe(" hello"); - expect(severityLine("info", "hello")).toBe(" hello"); + vi.stubEnv("NO_COLOR", ""); + vi.stubEnv("COLORTERM", "truecolor"); + vi.resetModules(); + + const freshStyles = await import("./terminal-style"); + expect(freshStyles.G).toBe("\x1b[38;2;118;185;0m"); }); }); diff --git a/src/lib/cli/terminal-style.ts b/src/lib/cli/terminal-style.ts index 8556672998d..46f8c8fd434 100644 --- a/src/lib/cli/terminal-style.ts +++ b/src/lib/cli/terminal-style.ts @@ -6,7 +6,9 @@ import { styleText } from "node:util"; /** * Legacy color constants (`G`, `B`, `D`, `R`, `RD`, `YW`) are frozen at module * import time; import after `NO_COLOR` and TTY state are configured. Prefer the - * call-time severity helpers below for new output. + * call-time severity helpers below for new output. The constants intentionally + * retain their historical raw ANSI values, while new output uses `styleText` + * so color capability is evaluated for the destination stream at call time. */ const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = @@ -19,48 +21,17 @@ export const R = useColor ? "\x1b[0m" : ""; export const RD = useColor ? "\x1b[1;31m" : ""; export const YW = useColor ? "\x1b[1;33m" : ""; -/** - * Semantic severity levels for onboard preflight output (#6004). - * - * `info` keeps the default terminal color; `ok`/`warn`/`error` add a colored - * marker so warnings and failures stand out in the lengthy preflight output. - */ -export type SeverityLevel = "info" | "ok" | "warn" | "error"; - -type SeverityStyle = { - marker: string; - format: "green" | "yellow" | "red" | null; - stream: NodeJS.WriteStream; -}; - -// The stream each level is written to decides its color. `ok`/`info` are -// emitted on stdout (`console.log`/`console.info`); `warn`/`error` on stderr -// (`console.warn`/`console.error`). `styleText({ stream })` then keys color off -// that stream's own capability and honors NO_COLOR / NODE_DISABLE_COLORS / -// FORCE_COLOR (#6004). This replaces the previous helpers, which colored from -// `process.stdout.isTTY` while printing to stderr — so redirecting either -// stream independently mis-styled the other (dropped color on `onboard >log`, -// leaked ANSI into `onboard 2>log`). -const SEVERITY_STYLES: Record = { - info: { marker: "", format: null, stream: process.stdout }, - ok: { marker: "✓ ", format: "green", stream: process.stdout }, - warn: { marker: "⚠ ", format: "yellow", stream: process.stderr }, - error: { marker: "✗ ", format: "red", stream: process.stderr }, -}; - -/** - * Render one indented preflight line at `level`. The returned string is meant - * to be passed to the matching console method (`ok`/`info` → `console.log` / - * `console.info`; `warn` → `console.warn`; `error` → `console.error`) so its - * color decision matches the stream it lands on. - */ -export function severityLine(level: SeverityLevel, message: string): string { - const { marker, format, stream } = SEVERITY_STYLES[level]; - const body = `${marker}${message}`; - return ` ${format ? styleText(format, body, { stream }) : body}`; +// WARN and ERROR lines are emitted on stderr. `styleText({ stream })` therefore +// keys color off stderr's capability and honors NO_COLOR / NODE_DISABLE_COLORS / +// FORCE_COLOR (#6004). The old output keyed color off stdout, which dropped +// color on `onboard >log` and leaked ANSI into `onboard 2>log`. +function stderrSeverityLine( + marker: "⚠ " | "✗ ", + format: "yellow" | "red", + message: string, +): string { + return ` ${styleText(format, `${marker}${message}`, { stream: process.stderr })}`; } -export const infoLine = (message: string): string => severityLine("info", message); -export const okLine = (message: string): string => severityLine("ok", message); -export const warnLine = (message: string): string => severityLine("warn", message); -export const failLine = (message: string): string => severityLine("error", message); +export const warnLine = (message: string): string => stderrSeverityLine("⚠ ", "yellow", message); +export const failLine = (message: string): string => stderrSeverityLine("✗ ", "red", message); diff --git a/src/lib/onboard/bridge-dns-preflight.ts b/src/lib/onboard/bridge-dns-preflight.ts index cd7f8aae39c..4acc21e8ff6 100644 --- a/src/lib/onboard/bridge-dns-preflight.ts +++ b/src/lib/onboard/bridge-dns-preflight.ts @@ -43,7 +43,12 @@ interface DaemonJsonDnsPatchOpts { * - refuses to write if the existing file is not parseable, asking * the user to fix it manually first. * - * The snippet is printed verbatim; nothing here executes it. + * Source boundary: this repairs privileged, platform-owned Docker daemon + * configuration outside NemoClaw's state. Unprivileged onboarding cannot + * safely mutate that file or restart Docker without explicit user consent, so + * the commands remain plain, copy-pastable output and nothing here executes + * them. Remove this workaround only when Docker/OpenShell exposes a managed + * daemon-DNS configuration API that preserves those ownership boundaries. */ function printDaemonJsonDnsPatch(opts: DaemonJsonDnsPatchOpts): void { const { daemonJsonPath, configDir, dnsValue, sudo, installJqHint, indent } = opts; diff --git a/src/lib/onboard/gateway-sandbox-reachability.test.ts b/src/lib/onboard/gateway-sandbox-reachability.test.ts index 4b5f246c998..63d9bd513d5 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.test.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.test.ts @@ -11,6 +11,28 @@ import { verifySandboxBridgeGatewayReachableOrExit, } from "./gateway-sandbox-reachability"; +function withColoredStderr(callback: () => T): T { + const originalIsTTY = process.stderr.isTTY; + const originalGetColorDepth = process.stderr.getColorDepth; + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: () => 24, + configurable: true, + }); + try { + return callback(); + } finally { + Object.defineProperty(process.stderr, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: originalGetColorDepth, + configurable: true, + }); + } +} + describe("gateway sandbox reachability route modeling", () => { it("parses Docker network IPAM config for subnet and gateway", () => { expect( @@ -324,6 +346,26 @@ describe("isSandboxBridgeGatewayReachable", () => { }); describe("formatSandboxBridgeUnreachableMessage", () => { + it("routes warning and fatal first lines through the stderr severity renderer (#6004)", () => { + withColoredStderr(() => { + const warning = formatSandboxBridgeUnreachableMessage({ + ok: false, + reason: "probe_unavailable", + }); + const fatal = formatSandboxBridgeUnreachableMessage({ + ok: false, + reason: "veth_unsupported", + }); + + expect(warning.split("\n")[0]).toBe( + " \x1b[33m⚠ Could not verify sandbox bridge reachability.\x1b[39m", + ); + expect(fatal.split("\n")[0]).toBe( + " \x1b[31m✗ Docker could not create the sandbox bridge veth pair.\x1b[39m", + ); + }); + }); + it("emits a UFW command only for bridge-gateway TCP failures", () => { const msg = formatSandboxBridgeUnreachableMessage({ ok: false, From 7ee50d1799ed39c93fac92b906e252fcfb385dd6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 3 Jul 2026 14:55:04 -0700 Subject: [PATCH 10/15] test(onboard): isolate severity regressions Signed-off-by: Carlos Villela --- src/lib/cli/terminal-style.test.ts | 77 ++++++++++------- src/lib/onboard/bridge-dns-preflight.ts | 35 +++----- ...eway-sandbox-reachability-severity.test.ts | 85 +++++++++++++++++++ .../gateway-sandbox-reachability.test.ts | 42 --------- .../onboard/gateway-sandbox-reachability.ts | 4 +- 5 files changed, 143 insertions(+), 100 deletions(-) create mode 100644 src/lib/onboard/gateway-sandbox-reachability-severity.test.ts diff --git a/src/lib/cli/terminal-style.test.ts b/src/lib/cli/terminal-style.test.ts index 39e92314b68..1b1530a1c41 100644 --- a/src/lib/cli/terminal-style.test.ts +++ b/src/lib/cli/terminal-style.test.ts @@ -43,6 +43,15 @@ function restoreStream( }); } +async function withRestoredStreams(callback: () => T | Promise): Promise { + try { + return await callback(); + } finally { + restoreStream(process.stdout, ORIGINAL_STDOUT); + restoreStream(process.stderr, ORIGINAL_STDERR); + } +} + // styleText's `yellow`/`red`/`green` formats (as of Node 22.16) wrap text in // SGR color codes with a `39` (default-foreground) reset. const YELLOW = (s: string) => `\x1b[33m${s}\x1b[39m`; @@ -51,47 +60,51 @@ describe("preflight severity lines (#6004)", () => { afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); - restoreStream(process.stdout, ORIGINAL_STDOUT); - restoreStream(process.stderr, ORIGINAL_STDERR); }); - it("colors warn/error from stderr — their real stream — not stdout (#6004)", () => { - // stdout redirected to a file, terminal still on stderr: warn/error must - // stay colored (they land on the color-capable stderr) while stdout-bound - // ok lines go plain. The old helpers colored from stdout and dropped these. - stubStream(process.stderr, true, 24); - stubStream(process.stdout, false, 1); - expect(warnLine("disk low")).toBe(` ${YELLOW("⚠ disk low")}`); - expect(failLine("docker down")).toBe(` ${RED("✗ docker down")}`); + it("colors warn/error from stderr — their real stream — not stdout (#6004)", async () => { + await withRestoredStreams(() => { + // stdout redirected to a file, terminal still on stderr: warn/error must + // stay colored because they land on the color-capable stderr. + stubStream(process.stderr, true, 24); + stubStream(process.stdout, false, 1); + expect(warnLine("disk low")).toBe(` ${YELLOW("⚠ disk low")}`); + expect(failLine("docker down")).toBe(` ${RED("✗ docker down")}`); + }); }); - it("drops warn/error color when stderr is redirected but stdout is a TTY (#6004)", () => { - // The inverse leak: stderr redirected to a log, stdout still a terminal. - // warn/error must go plain so no raw ANSI lands in the log. The old - // helpers colored from stdout and leaked. - stubStream(process.stdout, true, 24); - stubStream(process.stderr, false, 1); - expect(warnLine("disk low")).toBe(" ⚠ disk low"); - expect(failLine("docker down")).toBe(" ✗ docker down"); + it("drops warn/error color when stderr is redirected but stdout is a TTY (#6004)", async () => { + await withRestoredStreams(() => { + // The inverse leak: stderr redirected to a log, stdout still a terminal. + // warn/error must go plain so no raw ANSI lands in the log. + stubStream(process.stdout, true, 24); + stubStream(process.stderr, false, 1); + expect(warnLine("disk low")).toBe(" ⚠ disk low"); + expect(failLine("docker down")).toBe(" ✗ docker down"); + }); }); - it("emits plain text with no ANSI when the stream reports no color (NO_COLOR / CI)", () => { - // A real terminal under NO_COLOR reports color depth 1; redirected pipes - // and CI do the same. Assert the observable #6004 guarantee: no escapes. - vi.stubEnv("NO_COLOR", "1"); - stubStream(process.stdout, true, 1); - stubStream(process.stderr, true, 1); - expect(warnLine("a")).toBe(" ⚠ a"); - expect(failLine("b")).toBe(" ✗ b"); + it("emits plain text with no ANSI when the stream reports no color (NO_COLOR / CI)", async () => { + await withRestoredStreams(() => { + // A real terminal under NO_COLOR reports color depth 1; redirected pipes + // and CI do the same. Assert the observable #6004 guarantee: no escapes. + vi.stubEnv("NO_COLOR", "1"); + stubStream(process.stdout, true, 1); + stubStream(process.stderr, true, 1); + expect(warnLine("a")).toBe(" ⚠ a"); + expect(failLine("b")).toBe(" ✗ b"); + }); }); it("selects the legacy true-color green when configured before import", async () => { - stubStream(process.stdout, true, 24); - vi.stubEnv("NO_COLOR", ""); - vi.stubEnv("COLORTERM", "truecolor"); - vi.resetModules(); + await withRestoredStreams(async () => { + stubStream(process.stdout, true, 24); + vi.stubEnv("NO_COLOR", ""); + vi.stubEnv("COLORTERM", "truecolor"); + vi.resetModules(); - const freshStyles = await import("./terminal-style"); - expect(freshStyles.G).toBe("\x1b[38;2;118;185;0m"); + const freshStyles = await import("./terminal-style"); + expect(freshStyles.G).toBe("\x1b[38;2;118;185;0m"); + }); }); }); diff --git a/src/lib/onboard/bridge-dns-preflight.ts b/src/lib/onboard/bridge-dns-preflight.ts index 4acc21e8ff6..010f42baae4 100644 --- a/src/lib/onboard/bridge-dns-preflight.ts +++ b/src/lib/onboard/bridge-dns-preflight.ts @@ -2,14 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Bridge + DNS preflight gate, extracted from `onboard.ts` so it can be - * reused as a `--resume` backstop without growing the top-level file - * past the `onboard-entrypoint-budget` CI ceiling. - * - * - `assertDockerBridgeAndContainerDnsHealthy(host)` runs the bridge - * container start probe (#3508 Jetson veth) and the DNS-from-inside- - * container probe (#3630), and exits with platform-aware remediation - * on the fatal reasons described in `[[isFatalContainerDnsProbeFailure]]`. + * Bridge + DNS preflight gate extracted from `onboard.ts` for reuse as a + * `--resume` backstop. It validates bridge container start (#3508 Jetson veth) + * and container DNS (#3630), with platform-aware remediation on fatal results. */ import { failLine, warnLine } from "../cli/terminal-style"; @@ -31,24 +26,14 @@ interface DaemonJsonDnsPatchOpts { } /** - * Print a copy-pastable shell snippet that adds a `dns` key to the - * given daemon.json safely. The snippet: - * - creates the containing directory, - * - backs up the existing daemon.json, - * - requires `jq` (prints an install hint and aborts if missing — no - * bare-echo fallback that would clobber an existing daemon.json), - * - merges into an existing JSON object via `jq '. + {...}'`, - * - creates a new JSON object via `jq -n {...}` when daemon.json is - * absent, - * - refuses to write if the existing file is not parseable, asking - * the user to fix it manually first. + * Print a copy-pastable shell snippet that creates the config directory, backs + * up daemon.json, requires `jq`, merges or creates the `dns` key, and refuses + * to write invalid JSON. * - * Source boundary: this repairs privileged, platform-owned Docker daemon - * configuration outside NemoClaw's state. Unprivileged onboarding cannot - * safely mutate that file or restart Docker without explicit user consent, so - * the commands remain plain, copy-pastable output and nothing here executes - * them. Remove this workaround only when Docker/OpenShell exposes a managed - * daemon-DNS configuration API that preserves those ownership boundaries. + * Source boundary: this is privileged, platform-owned Docker configuration. + * Unprivileged onboarding cannot safely mutate it or restart Docker without + * explicit user consent, so the commands stay plain and nothing executes them. + * Remove this only when Docker/OpenShell exposes a managed daemon-DNS API. */ function printDaemonJsonDnsPatch(opts: DaemonJsonDnsPatchOpts): void { const { daemonJsonPath, configDir, dnsValue, sudo, installJqHint, indent } = opts; diff --git a/src/lib/onboard/gateway-sandbox-reachability-severity.test.ts b/src/lib/onboard/gateway-sandbox-reachability-severity.test.ts new file mode 100644 index 00000000000..1935efc15aa --- /dev/null +++ b/src/lib/onboard/gateway-sandbox-reachability-severity.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + formatSandboxBridgeUnreachableMessage, + verifySandboxBridgeGatewayReachableOrExit, +} from "./gateway-sandbox-reachability"; + +async function withColoredStderr(callback: () => T | Promise): Promise { + const originalIsTTY = process.stderr.isTTY; + const originalGetColorDepth = process.stderr.getColorDepth; + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: () => 24, + configurable: true, + }); + try { + return await callback(); + } finally { + Object.defineProperty(process.stderr, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: originalGetColorDepth, + configurable: true, + }); + } +} + +describe("sandbox bridge reachability severity (#6004)", () => { + it("routes warning and fatal first lines through the stderr severity renderer", async () => { + await withColoredStderr(() => { + const warning = formatSandboxBridgeUnreachableMessage({ + ok: false, + reason: "probe_unavailable", + }); + const fatal = formatSandboxBridgeUnreachableMessage({ + ok: false, + reason: "veth_unsupported", + }); + + expect(warning.split("\n")[0]).toBe( + " \x1b[33m⚠ Could not verify sandbox bridge reachability.\x1b[39m", + ); + expect(fatal.split("\n")[0]).toBe( + " \x1b[31m✗ Docker could not create the sandbox bridge veth pair.\x1b[39m", + ); + }); + }); + + it("colors the UFW auto-apply fallback warning", async () => { + await withColoredStderr(async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + await expect( + verifySandboxBridgeGatewayReachableOrExit(false, { + autoApplyImpl: () => ({ + applied: false, + reason: "sudo_unavailable", + detail: "passwordless sudo is unavailable", + }), + autoApplyOptedInImpl: () => true, + reachabilityImpl: () => ({ + ok: false, + reason: "tcp_failed", + routeKind: "bridge_gateway", + subnet: "172.18.0.0/16", + gatewayIp: "172.18.0.1", + }), + }), + ).rejects.toThrow("sandbox-bridge unreachable"); + expect(warn.mock.calls[0]?.[0]).toMatch( + /^ \x1b\[33m⚠ NEMOCLAW_AUTO_FIX_FIREWALL=1 set but could not auto-apply UFW rule/, + ); + } finally { + warn.mockRestore(); + error.mockRestore(); + } + }); + }); +}); diff --git a/src/lib/onboard/gateway-sandbox-reachability.test.ts b/src/lib/onboard/gateway-sandbox-reachability.test.ts index 63d9bd513d5..4b5f246c998 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.test.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.test.ts @@ -11,28 +11,6 @@ import { verifySandboxBridgeGatewayReachableOrExit, } from "./gateway-sandbox-reachability"; -function withColoredStderr(callback: () => T): T { - const originalIsTTY = process.stderr.isTTY; - const originalGetColorDepth = process.stderr.getColorDepth; - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stderr, "getColorDepth", { - value: () => 24, - configurable: true, - }); - try { - return callback(); - } finally { - Object.defineProperty(process.stderr, "isTTY", { - value: originalIsTTY, - configurable: true, - }); - Object.defineProperty(process.stderr, "getColorDepth", { - value: originalGetColorDepth, - configurable: true, - }); - } -} - describe("gateway sandbox reachability route modeling", () => { it("parses Docker network IPAM config for subnet and gateway", () => { expect( @@ -346,26 +324,6 @@ describe("isSandboxBridgeGatewayReachable", () => { }); describe("formatSandboxBridgeUnreachableMessage", () => { - it("routes warning and fatal first lines through the stderr severity renderer (#6004)", () => { - withColoredStderr(() => { - const warning = formatSandboxBridgeUnreachableMessage({ - ok: false, - reason: "probe_unavailable", - }); - const fatal = formatSandboxBridgeUnreachableMessage({ - ok: false, - reason: "veth_unsupported", - }); - - expect(warning.split("\n")[0]).toBe( - " \x1b[33m⚠ Could not verify sandbox bridge reachability.\x1b[39m", - ); - expect(fatal.split("\n")[0]).toBe( - " \x1b[31m✗ Docker could not create the sandbox bridge veth pair.\x1b[39m", - ); - }); - }); - it("emits a UFW command only for bridge-gateway TCP failures", () => { const msg = formatSandboxBridgeUnreachableMessage({ ok: false, diff --git a/src/lib/onboard/gateway-sandbox-reachability.ts b/src/lib/onboard/gateway-sandbox-reachability.ts index 4eb7f620a3a..50c23700c64 100644 --- a/src/lib/onboard/gateway-sandbox-reachability.ts +++ b/src/lib/onboard/gateway-sandbox-reachability.ts @@ -563,7 +563,9 @@ export async function verifySandboxBridgeGatewayReachableOrExit( if (reach.ok) return; } else if (!SILENT_UFW_AUTO_APPLY_REASONS.has(autoApplyResult.reason)) { console.warn( - ` ⚠ NEMOCLAW_AUTO_FIX_FIREWALL=1 set but could not auto-apply UFW rule (${autoApplyResult.reason}${autoApplyResult.detail ? `: ${autoApplyResult.detail}` : ""}); falling back to manual instructions.`, + warnLine( + `NEMOCLAW_AUTO_FIX_FIREWALL=1 set but could not auto-apply UFW rule (${autoApplyResult.reason}${autoApplyResult.detail ? `: ${autoApplyResult.detail}` : ""}); falling back to manual instructions.`, + ), ); } } From e54e9c46f1656d9695b33dc00ee7edba4359884c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 3 Jul 2026 15:39:20 -0700 Subject: [PATCH 11/15] fix(onboard): complete stderr severity coverage Signed-off-by: Carlos Villela --- src/lib/onboard/http-proxy-preflight.test.ts | 38 +++++++++++++ src/lib/onboard/http-proxy-preflight.ts | 6 +- src/lib/onboard/preflight-messages.test.ts | 59 +++++++++++++++----- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/lib/onboard/http-proxy-preflight.test.ts b/src/lib/onboard/http-proxy-preflight.test.ts index db7fb98e3ee..cdcfb963cb9 100644 --- a/src/lib/onboard/http-proxy-preflight.test.ts +++ b/src/lib/onboard/http-proxy-preflight.test.ts @@ -5,6 +5,25 @@ import { describe, expect, it } from "vitest"; import { redactProxyCredentials, warnIfHostProxyMissesLoopback } from "./http-proxy-preflight"; +function withStderrColorDepth(colorDepth: number, callback: () => T): T { + const originalIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + const originalColorDepth = Object.getOwnPropertyDescriptor(process.stderr, "getColorDepth"); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: () => colorDepth, + configurable: true, + }); + try { + return callback(); + } finally { + if (originalIsTTY) Object.defineProperty(process.stderr, "isTTY", originalIsTTY); + else Reflect.deleteProperty(process.stderr, "isTTY"); + if (originalColorDepth) + Object.defineProperty(process.stderr, "getColorDepth", originalColorDepth); + else Reflect.deleteProperty(process.stderr, "getColorDepth"); + } +} + describe("redactProxyCredentials", () => { it("returns plain proxy URLs unchanged", () => { expect(redactProxyCredentials("http://127.0.0.1:8118")).toBe("http://127.0.0.1:8118"); @@ -110,6 +129,25 @@ describe("warnIfHostProxyMissesLoopback", () => { expect(joined).toContain("proxy.example.com:3128"); }); + it("colors only the warning line on color-capable stderr and keeps proxy credentials redacted", () => { + withStderrColorDepth(24, () => { + const lines: string[] = []; + warnIfHostProxyMissesLoopback( + { http_proxy: "http://alice:s3cret@proxy.example.com:3128" }, + (line) => lines.push(line), + ); + + expect(lines[0]).toBe( + " \x1b[33m⚠ HTTP_PROXY/http_proxy is set without " + + "NO_PROXY=localhost,127.0.0.1,inference.local.\x1b[39m", + ); + expect(lines.slice(1).join("\n")).not.toContain("\x1b["); + expect(lines.join("\n")).not.toContain("alice"); + expect(lines.join("\n")).not.toContain("s3cret"); + expect(lines.join("\n")).toContain("****@proxy.example.com:3128"); + }); + }); + it("respects uppercase HTTP_PROXY too", () => { const lines: string[] = []; const fired = warnIfHostProxyMissesLoopback({ HTTP_PROXY: "http://corp-proxy:3128" }, (line) => diff --git a/src/lib/onboard/http-proxy-preflight.ts b/src/lib/onboard/http-proxy-preflight.ts index 68990faa821..02a53ccb8a6 100644 --- a/src/lib/onboard/http-proxy-preflight.ts +++ b/src/lib/onboard/http-proxy-preflight.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { warnLine } from "../cli/terminal-style"; + /** * Preflight warning when the user's shell has HTTP_PROXY set without a * NO_PROXY bypass for loopback and the managed inference hostname. @@ -27,7 +29,9 @@ export function warnIfHostProxyMissesLoopback( const hasLoopback = /(^|,)\s*127\.0\.0\.1\s*(,|$)/.test(noProxyEnv); const hasInference = /(^|,)\s*inference\.local\s*(,|$)/.test(noProxyEnv); if (hasLocalhost && hasLoopback && hasInference) return false; - warn(" ⚠ HTTP_PROXY/http_proxy is set without NO_PROXY=localhost,127.0.0.1,inference.local."); + warn( + warnLine("HTTP_PROXY/http_proxy is set without NO_PROXY=localhost,127.0.0.1,inference.local."), + ); warn(` Detected proxy: ${redactProxyCredentials(proxyEnv)}`); warn(" NemoClaw injects NO_PROXY for its own subprocess spawns (loopback hosts,"); warn(" container-host aliases, and the managed inference hostname inference.local),"); diff --git a/src/lib/onboard/preflight-messages.test.ts b/src/lib/onboard/preflight-messages.test.ts index df98e53ab0a..1bedaf9d9ba 100644 --- a/src/lib/onboard/preflight-messages.test.ts +++ b/src/lib/onboard/preflight-messages.test.ts @@ -16,16 +16,57 @@ function lines(spy: ReturnType): string[] { return spy.mock.calls.map((call: unknown[]) => String(call[0])); } +function withStderrColorDepth(colorDepth: number, callback: () => T): T { + const originalIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + const originalColorDepth = Object.getOwnPropertyDescriptor(process.stderr, "getColorDepth"); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: () => colorDepth, + configurable: true, + }); + try { + return callback(); + } finally { + if (originalIsTTY) Object.defineProperty(process.stderr, "isTTY", originalIsTTY); + else Reflect.deleteProperty(process.stderr, "isTTY"); + if (originalColorDepth) + Object.defineProperty(process.stderr, "getColorDepth", originalColorDepth); + else Reflect.deleteProperty(process.stderr, "getColorDepth"); + } +} + describe("onboard preflight severity messages (#6004)", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("prints the Docker-unreachable failure to stderr with a ✗ marker", () => { - const err = vi.spyOn(console, "error").mockImplementation(() => undefined); - printDockerNotReachableError(); - expect(err).toHaveBeenCalledOnce(); - expect(lines(err)[0]).toBe(" ✗ Docker is not reachable. Please fix Docker and try again."); + it("colors representative failure and warning messages when stderr supports color", () => { + withStderrColorDepth(24, () => { + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + printDockerNotReachableError(); + printLowMemoryWarning({ totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }); + expect(lines(err)[0]).toBe( + " \x1b[31m✗ Docker is not reachable. Please fix Docker and try again.\x1b[39m", + ); + expect(lines(warn)[0]).toBe( + " \x1b[33m⚠ Low memory detected (4000 MB RAM + 0 MB swap = 4000 MB total)\x1b[39m", + ); + }); + }); + + it("prints representative failure and warning messages without ANSI on plain stderr", () => { + withStderrColorDepth(1, () => { + const err = vi.spyOn(console, "error").mockImplementation(() => undefined); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + printDockerNotReachableError(); + printLowMemoryWarning({ totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }); + expect(lines(err)[0]).toBe(" ✗ Docker is not reachable. Please fix Docker and try again."); + expect(lines(warn)[0]).toBe( + " ⚠ Low memory detected (4000 MB RAM + 0 MB swap = 4000 MB total)", + ); + expect([...lines(err), ...lines(warn)].join("\n")).not.toContain("\x1b["); + }); }); it("prints the unsupported-runtime failure to stderr with a ✗ marker", () => { @@ -61,14 +102,6 @@ describe("onboard preflight severity messages (#6004)", () => { expect(lines(warn).join("\n")).toContain("Docker Desktop → Settings → Resources"); }); - it("prints the low-memory warning to stderr with a ⚠ marker", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - printLowMemoryWarning({ totalRamMB: 4000, totalSwapMB: 0, totalMB: 4000 }); - expect(lines(warn)[0]).toContain( - "⚠ Low memory detected (4000 MB RAM + 0 MB swap = 4000 MB total)", - ); - }); - it("prints the swap-creation failure to stderr with a ⚠ marker", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); printSwapCreationFailed("mkswap failed"); From e074fd7c549703591ee827fd9eb7d4fad0122d94 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 3 Jul 2026 16:13:33 -0700 Subject: [PATCH 12/15] test(onboard): linearize stream restoration Signed-off-by: Carlos Villela --- src/lib/onboard/http-proxy-preflight.test.ts | 17 ++++++++++------- src/lib/onboard/preflight-messages.test.ts | 17 ++++++++++------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/lib/onboard/http-proxy-preflight.test.ts b/src/lib/onboard/http-proxy-preflight.test.ts index cdcfb963cb9..2e3cb9e6d5b 100644 --- a/src/lib/onboard/http-proxy-preflight.test.ts +++ b/src/lib/onboard/http-proxy-preflight.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { redactProxyCredentials, warnIfHostProxyMissesLoopback } from "./http-proxy-preflight"; function withStderrColorDepth(colorDepth: number, callback: () => T): T { - const originalIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); - const originalColorDepth = Object.getOwnPropertyDescriptor(process.stderr, "getColorDepth"); + const originalIsTTY = process.stderr.isTTY; + const originalGetColorDepth = process.stderr.getColorDepth; Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stderr, "getColorDepth", { value: () => colorDepth, @@ -16,11 +16,14 @@ function withStderrColorDepth(colorDepth: number, callback: () => T): T { try { return callback(); } finally { - if (originalIsTTY) Object.defineProperty(process.stderr, "isTTY", originalIsTTY); - else Reflect.deleteProperty(process.stderr, "isTTY"); - if (originalColorDepth) - Object.defineProperty(process.stderr, "getColorDepth", originalColorDepth); - else Reflect.deleteProperty(process.stderr, "getColorDepth"); + Object.defineProperty(process.stderr, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: originalGetColorDepth, + configurable: true, + }); } } diff --git a/src/lib/onboard/preflight-messages.test.ts b/src/lib/onboard/preflight-messages.test.ts index 1bedaf9d9ba..e91629115f6 100644 --- a/src/lib/onboard/preflight-messages.test.ts +++ b/src/lib/onboard/preflight-messages.test.ts @@ -17,8 +17,8 @@ function lines(spy: ReturnType): string[] { } function withStderrColorDepth(colorDepth: number, callback: () => T): T { - const originalIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); - const originalColorDepth = Object.getOwnPropertyDescriptor(process.stderr, "getColorDepth"); + const originalIsTTY = process.stderr.isTTY; + const originalGetColorDepth = process.stderr.getColorDepth; Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); Object.defineProperty(process.stderr, "getColorDepth", { value: () => colorDepth, @@ -27,11 +27,14 @@ function withStderrColorDepth(colorDepth: number, callback: () => T): T { try { return callback(); } finally { - if (originalIsTTY) Object.defineProperty(process.stderr, "isTTY", originalIsTTY); - else Reflect.deleteProperty(process.stderr, "isTTY"); - if (originalColorDepth) - Object.defineProperty(process.stderr, "getColorDepth", originalColorDepth); - else Reflect.deleteProperty(process.stderr, "getColorDepth"); + Object.defineProperty(process.stderr, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + Object.defineProperty(process.stderr, "getColorDepth", { + value: originalGetColorDepth, + configurable: true, + }); } } From 5054368170cd24aa5991061bf35e92a7f7603691 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 3 Jul 2026 17:13:43 -0700 Subject: [PATCH 13/15] fix(onboard): harden preflight severity output Keep NO_COLOR authoritative when FORCE_COLOR is also set. Make the color-depth fixtures branch-free. Refresh generated source references after the preflight extraction. Co-authored-by: Jason Ma Signed-off-by: Jason Ma Signed-off-by: Apurv Kumaria --- ci/platform-matrix.json | 6 ++--- docs/inference/inference-options.mdx | 2 +- docs/reference/platform-support.mdx | 6 ++--- src/lib/cli/terminal-style.test.ts | 11 ++++---- src/lib/cli/terminal-style.ts | 3 ++- ...eway-sandbox-reachability-severity.test.ts | 2 ++ src/lib/onboard/http-proxy-preflight.test.ts | 25 +++++++------------ src/lib/onboard/preflight-messages.test.ts | 23 ++++++----------- 8 files changed, 34 insertions(+), 44 deletions(-) diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index c133f0b098e..d6abe12757d 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -129,7 +129,7 @@ "name": "Local NVIDIA NIM", "status": "experimental", "endpoint_type": "Local OpenAI-compatible", - "notes": "Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts:78`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`." + "notes": "Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`." }, { "name": "Local vLLM (already running)", @@ -218,7 +218,7 @@ { "name": "Podman / other container runtimes", "status": "unsupported", - "notes": "Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard/fatal-runtime-preflight.ts:50` prints the rejection; `src/lib/onboard/preflight.ts:677` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed)." + "notes": "Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard/fatal-runtime-preflight.ts` prints the rejection; `src/lib/onboard/preflight.ts` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed)." }, { "name": "Intel Mac (macOS x86_64)", @@ -248,7 +248,7 @@ { "name": "Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal)", "status": "unsupported", - "notes": "Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard/fatal-runtime-preflight.ts:78`). NemoClaw does not install non-NVIDIA accelerator drivers." + "notes": "Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard/fatal-runtime-preflight.ts`). NemoClaw does not install non-NVIDIA accelerator drivers." }, { "name": "Other LangChain, AutoGen, CrewAI, or non-listed agent harnesses", diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index 421c1556c4d..4970e5f9a94 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -49,7 +49,7 @@ NemoClaw uses provider-specific local tokens for those routes, and rebuilds of l | Google Gemini | Tested | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | | Hermes Provider | Hermes only | OpenAI-compatible route | Available when onboarding Hermes Agent through `nemohermes` | | Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). | -| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts:78`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | +| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | | Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. | | Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. | {/* provider-status:end */} diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index d9053575804..341fce65745 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -101,7 +101,7 @@ NemoClaw routes inference through the OpenShell gateway. Each row below is a pro | Google Gemini | Tested | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | | Hermes Provider | Hermes only | OpenAI-compatible route | Available when onboarding Hermes Agent through `nemohermes` | | Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). | -| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts:78`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | +| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard/fatal-runtime-preflight.ts`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | | Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. | | Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `deepseek-ai/DeepSeek-V4-Flash`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. | {/* provider-status-full:end */} @@ -160,13 +160,13 @@ They are listed here so launch material, sales conversations, and support triage {/* out-of-scope:begin */} | Item | Status | Why | |------|--------|-----| -| Podman / other container runtimes | Unsupported | Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard/fatal-runtime-preflight.ts:50` prints the rejection; `src/lib/onboard/preflight.ts:677` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed). | +| Podman / other container runtimes | Unsupported | Onboard surfaces an explicit unsupported-runtime error for Podman (`src/lib/onboard/fatal-runtime-preflight.ts` prints the rejection; `src/lib/onboard/preflight.ts` flags the unsupported runtime upstream). Only Docker Engine, Docker Desktop, and Colima are supported. See issue #420 (closed). | | Intel Mac (macOS x86_64) | Unsupported | OpenShell does not publish macOS x86_64 standalone gateway assets. Install hard-fails on x86_64 macOS (`scripts/install-openshell.sh:663`). See issue #954 (closed). | | Non-Ubuntu/Debian Linux distros | Unsupported | Installer assumes `apt-get`. Fedora/Rocky/Alma/Arch/NixOS are not validated and the installer's package-manager probes do not cover them. See open issue #899 (Fedora hang). | | Native Kubernetes or OpenShift deployments | Unsupported | NemoClaw runs the sandbox as a Docker container, not a Kubernetes pod. The default Docker-driver topology does not embed k3s. Operator-managed K8s/OpenShift deployments are out of scope; see issue #407 (community OpenShift through agent-sandbox CRD). | | Air-gapped / offline installs | Unsupported | Onboard assumes network reachability for package fetches, container pulls, and provider validation. See open issues #4872 and #2218 (production-deployment epic covering air-gapped support, China network guidance, multi-host topology). | | Windows-on-ARM GPU passthrough | Unsupported | Windows-on-ARM CPU paths run under WSL2 'tested with limitations', but GPU passthrough on WOA is denylisted (`src/lib/onboard/wsl-docker-desktop-gpu.ts:188`, `src/lib/inference/gpu-trust.test.ts:70`). See closed issue #4565. | -| Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal) | Unsupported | Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard/fatal-runtime-preflight.ts:78`). NemoClaw does not install non-NVIDIA accelerator drivers. | +| Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal) | Unsupported | Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard/fatal-runtime-preflight.ts`). NemoClaw does not install non-NVIDIA accelerator drivers. | | Other LangChain, AutoGen, CrewAI, or non-listed agent harnesses | Unsupported | LangChain Deep Agents Code is the only integrated LangChain-family harness (see the Agents section above; status `Experimental`). Other LangChain harnesses, AutoGen, CrewAI, and any agent runtime not listed in the Agents table are not integrated. Bringing more harnesses is tracked as a research epic (see open issue #4861) but is not on the current roadmap. | | Multi-user host sharing | Unsupported | Sandboxes are scoped to a single host user. NemoClaw treats multi-user hosts as a risk and warns at onboard; see `docs/security/openclaw-controls.mdx` Multi-user detection. | | Hosted SaaS / managed NemoClaw | Unsupported | There is no managed offering. Supported deployment paths are Local CLI onboard, Remote GPU with Brev CLI, and Brev web UI. | diff --git a/src/lib/cli/terminal-style.test.ts b/src/lib/cli/terminal-style.test.ts index 1b1530a1c41..852b13a0be7 100644 --- a/src/lib/cli/terminal-style.test.ts +++ b/src/lib/cli/terminal-style.test.ts @@ -66,6 +66,7 @@ describe("preflight severity lines (#6004)", () => { await withRestoredStreams(() => { // stdout redirected to a file, terminal still on stderr: warn/error must // stay colored because they land on the color-capable stderr. + vi.stubEnv("NO_COLOR", ""); stubStream(process.stderr, true, 24); stubStream(process.stdout, false, 1); expect(warnLine("disk low")).toBe(` ${YELLOW("⚠ disk low")}`); @@ -77,6 +78,7 @@ describe("preflight severity lines (#6004)", () => { await withRestoredStreams(() => { // The inverse leak: stderr redirected to a log, stdout still a terminal. // warn/error must go plain so no raw ANSI lands in the log. + vi.stubEnv("NO_COLOR", ""); stubStream(process.stdout, true, 24); stubStream(process.stderr, false, 1); expect(warnLine("disk low")).toBe(" ⚠ disk low"); @@ -84,13 +86,12 @@ describe("preflight severity lines (#6004)", () => { }); }); - it("emits plain text with no ANSI when the stream reports no color (NO_COLOR / CI)", async () => { + it("keeps NO_COLOR authoritative when FORCE_COLOR is also set", async () => { await withRestoredStreams(() => { - // A real terminal under NO_COLOR reports color depth 1; redirected pipes - // and CI do the same. Assert the observable #6004 guarantee: no escapes. vi.stubEnv("NO_COLOR", "1"); - stubStream(process.stdout, true, 1); - stubStream(process.stderr, true, 1); + vi.stubEnv("FORCE_COLOR", "1"); + stubStream(process.stdout, true, 24); + stubStream(process.stderr, true, 24); expect(warnLine("a")).toBe(" ⚠ a"); expect(failLine("b")).toBe(" ✗ b"); }); diff --git a/src/lib/cli/terminal-style.ts b/src/lib/cli/terminal-style.ts index 46f8c8fd434..d93b11cea59 100644 --- a/src/lib/cli/terminal-style.ts +++ b/src/lib/cli/terminal-style.ts @@ -30,7 +30,8 @@ function stderrSeverityLine( format: "yellow" | "red", message: string, ): string { - return ` ${styleText(format, `${marker}${message}`, { stream: process.stderr })}`; + const line = `${marker}${message}`; + return ` ${process.env.NO_COLOR ? line : styleText(format, line, { stream: process.stderr })}`; } export const warnLine = (message: string): string => stderrSeverityLine("⚠ ", "yellow", message); diff --git a/src/lib/onboard/gateway-sandbox-reachability-severity.test.ts b/src/lib/onboard/gateway-sandbox-reachability-severity.test.ts index 1935efc15aa..9c12dd4178a 100644 --- a/src/lib/onboard/gateway-sandbox-reachability-severity.test.ts +++ b/src/lib/onboard/gateway-sandbox-reachability-severity.test.ts @@ -16,6 +16,7 @@ async function withColoredStderr(callback: () => T | Promise): Promise value: () => 24, configurable: true, }); + vi.stubEnv("NO_COLOR", ""); try { return await callback(); } finally { @@ -27,6 +28,7 @@ async function withColoredStderr(callback: () => T | Promise): Promise value: originalGetColorDepth, configurable: true, }); + vi.unstubAllEnvs(); } } diff --git a/src/lib/onboard/http-proxy-preflight.test.ts b/src/lib/onboard/http-proxy-preflight.test.ts index 2e3cb9e6d5b..7496494547c 100644 --- a/src/lib/onboard/http-proxy-preflight.test.ts +++ b/src/lib/onboard/http-proxy-preflight.test.ts @@ -1,29 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { redactProxyCredentials, warnIfHostProxyMissesLoopback } from "./http-proxy-preflight"; function withStderrColorDepth(colorDepth: number, callback: () => T): T { - const originalIsTTY = process.stderr.isTTY; - const originalGetColorDepth = process.stderr.getColorDepth; - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stderr, "getColorDepth", { - value: () => colorDepth, - configurable: true, - }); + const stderr = Object.assign(Object.create(process.stderr), { + getColorDepth: () => colorDepth, + isTTY: true, + }) as typeof process.stderr; + const getStderr = vi.spyOn(process, "stderr", "get").mockReturnValue(stderr); + vi.stubEnv("NO_COLOR", ""); try { return callback(); } finally { - Object.defineProperty(process.stderr, "isTTY", { - value: originalIsTTY, - configurable: true, - }); - Object.defineProperty(process.stderr, "getColorDepth", { - value: originalGetColorDepth, - configurable: true, - }); + getStderr.mockRestore(); + vi.unstubAllEnvs(); } } diff --git a/src/lib/onboard/preflight-messages.test.ts b/src/lib/onboard/preflight-messages.test.ts index e91629115f6..f416401ceec 100644 --- a/src/lib/onboard/preflight-messages.test.ts +++ b/src/lib/onboard/preflight-messages.test.ts @@ -17,24 +17,17 @@ function lines(spy: ReturnType): string[] { } function withStderrColorDepth(colorDepth: number, callback: () => T): T { - const originalIsTTY = process.stderr.isTTY; - const originalGetColorDepth = process.stderr.getColorDepth; - Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); - Object.defineProperty(process.stderr, "getColorDepth", { - value: () => colorDepth, - configurable: true, - }); + const stderr = Object.assign(Object.create(process.stderr), { + getColorDepth: () => colorDepth, + isTTY: true, + }) as typeof process.stderr; + const getStderr = vi.spyOn(process, "stderr", "get").mockReturnValue(stderr); + vi.stubEnv("NO_COLOR", ""); try { return callback(); } finally { - Object.defineProperty(process.stderr, "isTTY", { - value: originalIsTTY, - configurable: true, - }); - Object.defineProperty(process.stderr, "getColorDepth", { - value: originalGetColorDepth, - configurable: true, - }); + getStderr.mockRestore(); + vi.unstubAllEnvs(); } } From ce84032e95c2935ae58a7fa58d590eed83939e95 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 3 Jul 2026 17:31:38 -0700 Subject: [PATCH 14/15] fix(onboard): color fatal CDI preflight Route the missing or stale CDI failure through the shared stderr renderer. Cover red terminal output and NO_COLOR plain output. Preserve remediation and exit behavior. Co-authored-by: Jason Ma Signed-off-by: Jason Ma Signed-off-by: Apurv Kumaria --- src/lib/onboard/preflight-cdi.test.ts | 69 ++++++++++++++++++++++++++- src/lib/onboard/preflight.ts | 5 +- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/preflight-cdi.test.ts b/src/lib/onboard/preflight-cdi.test.ts index 5930a94b24b..6001dfaebbe 100644 --- a/src/lib/onboard/preflight-cdi.test.ts +++ b/src/lib/onboard/preflight-cdi.test.ts @@ -1,9 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; // Import source directly so tests cannot pass against a stale build. -import { assessHost, planHostRemediation, shouldEnforceCdiNvidiaGpuSpec } from "./preflight"; +import { + assertCdiNvidiaGpuSpecPresent, + assessHost, + planHostRemediation, + shouldEnforceCdiNvidiaGpuSpec, +} from "./preflight"; type HostAssessment = Parameters[0]; @@ -37,6 +42,21 @@ function baseAssessment(overrides: Partial = {}): HostAssessment }; } +function withStderrColorDepth(colorDepth: number, noColor: string, callback: () => T): T { + const stderr = Object.assign(Object.create(process.stderr), { + getColorDepth: () => colorDepth, + isTTY: true, + }) as typeof process.stderr; + const getStderr = vi.spyOn(process, "stderr", "get").mockReturnValue(stderr); + vi.stubEnv("NO_COLOR", noColor); + try { + return callback(); + } finally { + getStderr.mockRestore(); + vi.unstubAllEnvs(); + } +} + function runCaptureWithLspci(lspciOutput: string): (command: readonly string[]) => string { const resultByCmd: Record = { "nvidia-smi": "", lspci: lspciOutput }; return (command) => { @@ -474,3 +494,48 @@ describe("shouldEnforceCdiNvidiaGpuSpec enforcement gate (#5489)", () => { ).toBe(false); }); }); + +describe("assertCdiNvidiaGpuSpecPresent severity (#6004)", () => { + it("colors the fatal missing-CDI line red before exiting", () => { + withStderrColorDepth(24, "", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const exitProcess = vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }); + + expect(() => + assertCdiNvidiaGpuSpecPresent( + baseAssessment({ cdiNvidiaGpuSpecMissing: true }), + false, + null, + exitProcess, + ), + ).toThrow("exit 1"); + expect(error.mock.calls[0]?.[0]).toBe( + " \x1b[31m✗ Docker is configured for CDI device injection (CDISpecDirs is set), but the NVIDIA GPU CDI spec is missing or stale. OpenShell GPU startup can fail until the CDI spec is refreshed.\x1b[39m", + ); + expect(exitProcess).toHaveBeenCalledWith(1); + error.mockRestore(); + }); + }); + + it("keeps the fatal missing-CDI line plain under NO_COLOR", () => { + withStderrColorDepth(24, "1", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(() => + assertCdiNvidiaGpuSpecPresent( + baseAssessment({ cdiNvidiaGpuSpecNeedsRepair: true }), + false, + null, + (code): never => { + throw new Error(`exit ${code}`); + }, + ), + ).toThrow("exit 1"); + expect(String(error.mock.calls[0]?.[0])).toContain(" ✗ Docker is configured for CDI"); + expect(String(error.mock.calls[0]?.[0])).not.toContain("\x1b["); + error.mockRestore(); + }); + }); +}); diff --git a/src/lib/onboard/preflight.ts b/src/lib/onboard/preflight.ts index c11188f3525..fe94b3145de 100644 --- a/src/lib/onboard/preflight.ts +++ b/src/lib/onboard/preflight.ts @@ -15,6 +15,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { failLine } from "../cli/terminal-style"; import { DASHBOARD_PORT } from "../core/ports"; import { assessNvidiaCdiHost, @@ -728,7 +729,9 @@ export function assertCdiNvidiaGpuSpecPresent( ) return; console.error( - " Docker is configured for CDI device injection (CDISpecDirs is set), but the NVIDIA GPU CDI spec is missing or stale. OpenShell GPU startup can fail until the CDI spec is refreshed.", + failLine( + "Docker is configured for CDI device injection (CDISpecDirs is set), but the NVIDIA GPU CDI spec is missing or stale. OpenShell GPU startup can fail until the CDI spec is refreshed.", + ), ); printRemediationActions(planHostRemediation(host)); exitProcess(1); From 9e06a5336499b680aba18f9e1413b070882a0f5b Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Fri, 3 Jul 2026 17:39:58 -0700 Subject: [PATCH 15/15] fix(onboard): color deferred gateway warning Route the preflight gateway recreation warning through the stderr severity renderer. Preserve cleanup behavior and cover color-capable and NO_COLOR output. Co-authored-by: Jason Ma Signed-off-by: Jason Ma Signed-off-by: Apurv Kumaria --- src/lib/onboard.ts | 1 + ...preflight-gateway-cleanup-decision.test.ts | 41 +++++++++++++++++-- .../preflight-gateway-cleanup-decision.ts | 6 ++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index c6f22cf8409..0038f375064 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1658,6 +1658,7 @@ async function preflight( cliDisplayName: cliDisplayName(), dashboardPort: getOnboardDashboardPort(), log: console.log, + warn: console.warn, runOpenshell, destroyGateway, destroyGatewayForReuse, diff --git a/src/lib/onboard/preflight-gateway-cleanup-decision.test.ts b/src/lib/onboard/preflight-gateway-cleanup-decision.test.ts index 2c2b90b2a73..6e8021f12bb 100644 --- a/src/lib/onboard/preflight-gateway-cleanup-decision.test.ts +++ b/src/lib/onboard/preflight-gateway-cleanup-decision.test.ts @@ -1,16 +1,30 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { GatewayReuseState } from "../state/gateway"; import { - PREFLIGHT_DEFERRED_RECREATE_MESSAGE, applyPreflightGatewayCleanup, + PREFLIGHT_DEFERRED_RECREATE_MESSAGE, preflightGatewayCleanupDecision, } from "./preflight-gateway-cleanup-decision"; +function stubStderrColorDepth(colorDepth: number): void { + const stderr = Object.assign(Object.create(process.stderr), { + getColorDepth: () => colorDepth, + isTTY: true, + }) as typeof process.stderr; + vi.spyOn(process, "stderr", "get").mockReturnValue(stderr); + vi.stubEnv("NO_COLOR", ""); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + describe("preflightGatewayCleanupDecision", () => { it("defers when state is stale and Docker-driver gateway is enabled", () => { expect( @@ -69,6 +83,7 @@ describe("applyPreflightGatewayCleanup", () => { isDockerDriverGatewayEnabled: boolean; }) { const log = vi.fn(); + const warn = vi.fn(); const runOpenshell = vi.fn(() => ({ status: 0 })); const destroyGateway = vi.fn(() => true); const destroyGatewayForReuse = vi.fn< @@ -84,27 +99,44 @@ describe("applyPreflightGatewayCleanup", () => { cliDisplayName: "NemoClaw", dashboardPort: 8081, log, + warn, runOpenshell, destroyGateway, destroyGatewayForReuse, }, log, + warn, runOpenshell, destroyGateway, destroyGatewayForReuse, }; } - it("logs the deferral notice without invoking destroy on the Docker-driver path", () => { + it("warns in yellow without invoking destroy on the Docker-driver path", () => { + stubStderrColorDepth(24); const ctx = makeDeps({ gatewayReuseState: "stale", isDockerDriverGatewayEnabled: true }); const next = applyPreflightGatewayCleanup(ctx.deps); expect(next).toBe("stale"); - expect(ctx.log).toHaveBeenCalledWith(PREFLIGHT_DEFERRED_RECREATE_MESSAGE); + expect(ctx.warn).toHaveBeenCalledWith( + ` \x1b[33m⚠ ${PREFLIGHT_DEFERRED_RECREATE_MESSAGE}\x1b[39m`, + ); + expect(ctx.log).not.toHaveBeenCalled(); expect(ctx.destroyGateway).not.toHaveBeenCalled(); expect(ctx.destroyGatewayForReuse).not.toHaveBeenCalled(); expect(ctx.runOpenshell).not.toHaveBeenCalled(); }); + it("prints the deferral warning without ANSI when NO_COLOR is set", () => { + stubStderrColorDepth(24); + vi.stubEnv("NO_COLOR", "1"); + const ctx = makeDeps({ gatewayReuseState: "stale", isDockerDriverGatewayEnabled: true }); + + applyPreflightGatewayCleanup(ctx.deps); + + expect(ctx.warn).toHaveBeenCalledWith(` ⚠ ${PREFLIGHT_DEFERRED_RECREATE_MESSAGE}`); + expect(String(ctx.warn.mock.calls[0]?.[0])).not.toContain("\x1b["); + }); + it("destroys the legacy gateway and stops the dashboard forward on the non-Docker-driver path", () => { const ctx = makeDeps({ gatewayReuseState: "stale", isDockerDriverGatewayEnabled: false }); const next = applyPreflightGatewayCleanup(ctx.deps); @@ -123,6 +155,7 @@ describe("applyPreflightGatewayCleanup", () => { const next = applyPreflightGatewayCleanup(ctx.deps); expect(next).toBe(state); expect(ctx.log).not.toHaveBeenCalled(); + expect(ctx.warn).not.toHaveBeenCalled(); expect(ctx.destroyGateway).not.toHaveBeenCalled(); expect(ctx.destroyGatewayForReuse).not.toHaveBeenCalled(); expect(ctx.runOpenshell).not.toHaveBeenCalled(); diff --git a/src/lib/onboard/preflight-gateway-cleanup-decision.ts b/src/lib/onboard/preflight-gateway-cleanup-decision.ts index 1b4312e93d9..1b4e47da93a 100644 --- a/src/lib/onboard/preflight-gateway-cleanup-decision.ts +++ b/src/lib/onboard/preflight-gateway-cleanup-decision.ts @@ -1,12 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { warnLine } from "../cli/terminal-style"; import type { GatewayReuseState } from "../state/gateway"; export type PreflightGatewayCleanupAction = "defer" | "destroy-legacy" | "noop"; export const PREFLIGHT_DEFERRED_RECREATE_MESSAGE = - " ⚠ Gateway will be recreated when sandbox creation starts — this will affect running sandboxes."; + "Gateway will be recreated when sandbox creation starts — this will affect running sandboxes."; export function preflightGatewayCleanupDecision(opts: { gatewayReuseState: GatewayReuseState; @@ -24,6 +25,7 @@ export interface PreflightGatewayCleanupDeps { cliDisplayName: string; dashboardPort: number; log: (line: string) => void; + warn: (line: string) => void; runOpenshell: (args: string[], options: { ignoreError: true }) => unknown; destroyGateway: () => boolean; destroyGatewayForReuse: ( @@ -39,7 +41,7 @@ export function applyPreflightGatewayCleanup(deps: PreflightGatewayCleanupDeps): isDockerDriverGatewayEnabled: deps.isDockerDriverGatewayEnabled, }); if (action === "defer") { - deps.log(PREFLIGHT_DEFERRED_RECREATE_MESSAGE); + deps.warn(warnLine(PREFLIGHT_DEFERRED_RECREATE_MESSAGE)); return deps.gatewayReuseState; } if (action === "destroy-legacy") {