From ebf2b5dd0688554be7697fc0a7ae1fa1ed285f7d Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 1 Apr 2026 23:57:00 -0700 Subject: [PATCH 1/4] refactor(cli): port debug.sh to TypeScript (#1296) Replace `spawnSync("bash", ["scripts/debug.sh", ...])` with a direct call to `src/lib/debug.ts` compiled module, following the established migration pattern from #924. - Add `src/lib/debug.ts` with `runDebug()` entry point, `redact()` secret filtering, platform-aware diagnostics (macOS / Linux), and tarball creation using execa for subprocess execution - Add `src/lib/debug.test.ts` testing all redaction patterns - Add `bin/lib/debug.js` thin CJS re-export shim - Update `bin/nemoclaw.js` debug() to parse --quick, --output, --sandbox, --help flags and call runDebug() directly - Preserve `scripts/debug.sh` for standalone curl-pipe usage Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/lib/debug.js | 4 + bin/nemoclaw.js | 47 +++- nemoclaw/src/lib/debug.test.ts | 47 ++++ nemoclaw/src/lib/debug.ts | 465 +++++++++++++++++++++++++++++++++ 4 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 bin/lib/debug.js create mode 100644 nemoclaw/src/lib/debug.test.ts create mode 100644 nemoclaw/src/lib/debug.ts diff --git a/bin/lib/debug.js b/bin/lib/debug.js new file mode 100644 index 000000000..0a62a36a1 --- /dev/null +++ b/bin/lib/debug.js @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +module.exports = require("../../nemoclaw/dist/lib/debug.js"); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 868b00b83..2f6a8cd5e 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -216,16 +216,41 @@ function stop() { run(`bash "${SCRIPTS}/start-services.sh" --stop`); } -function debug(args) { - const result = spawnSync("bash", [path.join(SCRIPTS, "debug.sh"), ...args], { - stdio: "inherit", - cwd: ROOT, - env: { - ...process.env, - SANDBOX_NAME: registry.listSandboxes().defaultSandbox || "", - }, - }); - exitWithSpawnResult(result); +async function debug(args) { + const { runDebug } = require("./lib/debug"); + const opts = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--help" || args[i] === "-h") { + console.log(`Usage: nemoclaw debug [OPTIONS] + +Collect NemoClaw diagnostic information for bug reports. + +Options: + --sandbox NAME Target sandbox (default: $NEMOCLAW_SANDBOX or auto-detect) + --quick, -q Collect minimal diagnostics only + --output PATH Write tarball to PATH (e.g. /tmp/nemoclaw-debug.tar.gz) + --help Show this help + +Examples: + nemoclaw debug + nemoclaw debug --quick + nemoclaw debug --output /tmp/diag.tar.gz`); + return; + } else if (args[i] === "--quick" || args[i] === "-q") { + opts.quick = true; + } else if ((args[i] === "--output" || args[i] === "-o") && args[i + 1]) { + opts.output = args[++i]; + } else if (args[i] === "--sandbox" && args[i + 1]) { + opts.sandboxName = args[++i]; + } else { + console.error(`Unknown option: ${args[i]} (see --help)`); + process.exit(1); + } + } + if (!opts.sandboxName) { + opts.sandboxName = registry.listSandboxes().defaultSandbox || ""; + } + await runDebug(opts); } function uninstall(args) { @@ -466,7 +491,7 @@ const [cmd, ...args] = process.argv.slice(2); case "start": await start(); break; case "stop": stop(); break; case "status": showStatus(); break; - case "debug": debug(args); break; + case "debug": await debug(args); break; case "uninstall": uninstall(args); break; case "list": listSandboxes(); break; case "--version": diff --git a/nemoclaw/src/lib/debug.test.ts b/nemoclaw/src/lib/debug.test.ts new file mode 100644 index 000000000..fe7dbe1eb --- /dev/null +++ b/nemoclaw/src/lib/debug.test.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { redact } from "../../dist/lib/debug.js"; + +describe("redact", () => { + it("redacts NVIDIA_API_KEY=value patterns", () => { + const key = ["NVIDIA", "API", "KEY"].join("_"); + expect(redact(`${key}=some-value`)).toBe(`${key}=`); + }); + + it("redacts generic KEY/TOKEN/SECRET/PASSWORD env vars", () => { + expect(redact("API_KEY=secret123")).toBe("API_KEY="); + expect(redact("MY_TOKEN=tok_abc")).toBe("MY_TOKEN="); + expect(redact("DB_PASSWORD=hunter2")).toBe("DB_PASSWORD="); + expect(redact("MY_SECRET=s3cret")).toBe("MY_SECRET="); + expect(redact("CREDENTIAL=cred")).toBe("CREDENTIAL="); + }); + + it("redacts nvapi- prefixed keys", () => { + expect(redact("using key nvapi-AbCdEfGhIj1234")).toBe("using key "); + }); + + it("redacts GitHub personal access tokens", () => { + expect(redact("token: ghp_" + "a".repeat(36))).toBe("token: "); + }); + + it("redacts Bearer tokens", () => { + expect(redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig")).toBe( + "Authorization: Bearer ", + ); + }); + + it("handles multiple patterns in one string", () => { + const input = "API_KEY=secret nvapi-abcdefghijk Bearer tok123"; + const result = redact(input); + expect(result).not.toContain("secret"); + expect(result).not.toContain("nvapi-abcdefghijk"); + expect(result).not.toContain("tok123"); + }); + + it("leaves clean text unchanged", () => { + const clean = "Hello world, no secrets here"; + expect(redact(clean)).toBe(clean); + }); +}); diff --git a/nemoclaw/src/lib/debug.ts b/nemoclaw/src/lib/debug.ts new file mode 100644 index 000000000..427c3904a --- /dev/null +++ b/nemoclaw/src/lib/debug.ts @@ -0,0 +1,465 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { platform, tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { execa } from "execa"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DebugOptions { + /** Target sandbox name (auto-detected if omitted). */ + sandboxName?: string; + /** Only collect minimal diagnostics. */ + quick?: boolean; + /** Write a tarball to this path. */ + output?: string; +} + +// --------------------------------------------------------------------------- +// Colour helpers — respect NO_COLOR +// --------------------------------------------------------------------------- + +const useColor = !process.env.NO_COLOR && process.stdout.isTTY; +const GREEN = useColor ? "\x1b[0;32m" : ""; +const YELLOW = useColor ? "\x1b[1;33m" : ""; +const CYAN = useColor ? "\x1b[0;36m" : ""; +const NC = useColor ? "\x1b[0m" : ""; + +function info(msg: string): void { + console.log(`${GREEN}[debug]${NC} ${msg}`); +} + +function warn(msg: string): void { + console.log(`${YELLOW}[debug]${NC} ${msg}`); +} + +function section(title: string): void { + console.log(`\n${CYAN}═══ ${title} ═══${NC}\n`); +} + +// --------------------------------------------------------------------------- +// Secret redaction +// --------------------------------------------------------------------------- + +const REDACT_PATTERNS: [RegExp, string][] = [ + [/(NVIDIA_API_KEY|API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)=\S+/gi, "$1="], + [/nvapi-[A-Za-z0-9_-]{10,}/g, ""], + [/ghp_[A-Za-z0-9]{30,}/g, ""], + [/(Bearer )\S+/gi, "$1"], +]; + +export function redact(text: string): string { + let result = text; + for (const [pattern, replacement] of REDACT_PATTERNS) { + result = result.replace(pattern, replacement); + } + return result; +} + +// --------------------------------------------------------------------------- +// Command runner +// --------------------------------------------------------------------------- + +const isMacOS = platform() === "darwin"; + +async function commandExists(cmd: string): Promise { + try { + await execa("command", ["-v", cmd], { shell: true, stdout: "ignore", stderr: "ignore" }); + return true; + } catch { + return false; + } +} + +async function collect( + collectDir: string, + label: string, + command: string, + args: string[], +): Promise { + const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); + const outfile = join(collectDir, `${filename}.txt`); + + if (!(await commandExists(command))) { + const msg = ` (${command} not found, skipping)`; + console.log(msg); + writeFileSync(outfile, msg + "\n"); + return; + } + + const result = await execa(command, args, { + reject: false, + timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + shell: command === "sh", + }); + + const raw = result.stdout + "\n" + result.stderr; + const redacted = redact(raw); + writeFileSync(outfile, redacted); + console.log(redacted.trimEnd()); + + if (result.exitCode !== 0) { + console.log(" (command exited with non-zero status)"); + } +} + +/** Run a shell one-liner via `sh -c`. */ +async function collectShell(collectDir: string, label: string, shellCmd: string): Promise { + const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); + const outfile = join(collectDir, `${filename}.txt`); + + const result = await execa("sh", ["-c", shellCmd], { + reject: false, + timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + }); + + const raw = result.stdout + "\n" + result.stderr; + const redacted = redact(raw); + writeFileSync(outfile, redacted); + console.log(redacted.trimEnd()); + + if (result.exitCode !== 0) { + console.log(" (command exited with non-zero status)"); + } +} + +// --------------------------------------------------------------------------- +// Auto-detect sandbox name +// --------------------------------------------------------------------------- + +async function detectSandboxName(): Promise { + if (!(await commandExists("openshell"))) return "default"; + try { + const result = await execa("openshell", ["sandbox", "list"], { + reject: false, + timeout: 10_000, + stdout: "pipe", + stderr: "ignore", + }); + const lines = result.stdout.split("\n").filter((l) => l.trim().length > 0); + for (const line of lines) { + const first = line.trim().split(/\s+/)[0]; + if (first && first.toLowerCase() !== "name") return first; + } + } catch { + /* ignore */ + } + return "default"; +} + +// --------------------------------------------------------------------------- +// Diagnostic sections +// --------------------------------------------------------------------------- + +async function collectSystem(collectDir: string, quick: boolean): Promise { + section("System"); + await collect(collectDir, "date", "date", []); + await collect(collectDir, "uname", "uname", ["-a"]); + await collect(collectDir, "uptime", "uptime", []); + + if (isMacOS) { + await collectShell( + collectDir, + "memory", + 'echo "Physical: $(($(sysctl -n hw.memsize) / 1048576)) MB"; vm_stat', + ); + } else { + await collect(collectDir, "free", "free", ["-m"]); + } + + if (!quick) { + await collect(collectDir, "df", "df", ["-h"]); + } +} + +async function collectProcesses(collectDir: string, quick: boolean): Promise { + section("Processes"); + if (isMacOS) { + await collectShell( + collectDir, + "ps-cpu", + "ps -eo pid,ppid,comm,%mem,%cpu | sort -k5 -rn | head -30", + ); + } else { + await collectShell( + collectDir, + "ps-cpu", + "ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head -30", + ); + } + + if (!quick) { + if (isMacOS) { + await collectShell( + collectDir, + "ps-mem", + "ps -eo pid,ppid,comm,%mem,%cpu | sort -k4 -rn | head -30", + ); + await collectShell(collectDir, "top", "top -l 1 | head -50"); + } else { + await collectShell( + collectDir, + "ps-mem", + "ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%mem | head -30", + ); + await collectShell(collectDir, "top", "top -b -n 1 | head -50"); + } + } +} + +async function collectGpu(collectDir: string, quick: boolean): Promise { + section("GPU"); + await collect(collectDir, "nvidia-smi", "nvidia-smi", []); + + if (!quick) { + await collect(collectDir, "nvidia-smi-dmon", "nvidia-smi", [ + "dmon", + "-s", + "pucvmet", + "-c", + "10", + ]); + await collect(collectDir, "nvidia-smi-query", "nvidia-smi", [ + "--query-gpu=name,utilization.gpu,utilization.memory,memory.total,memory.used,temperature.gpu,power.draw", + "--format=csv", + ]); + } +} + +async function collectDocker(collectDir: string, quick: boolean): Promise { + section("Docker"); + await collect(collectDir, "docker-ps", "docker", ["ps", "-a"]); + await collect(collectDir, "docker-stats", "docker", ["stats", "--no-stream"]); + + if (!quick) { + await collect(collectDir, "docker-info", "docker", ["info"]); + await collect(collectDir, "docker-df", "docker", ["system", "df"]); + } + + // NemoClaw-labelled containers + if (await commandExists("docker")) { + try { + const result = await execa( + "docker", + ["ps", "-a", "--filter", "label=com.nvidia.nemoclaw", "--format", "{{.Names}}"], + { reject: false, stdout: "pipe", stderr: "ignore" }, + ); + const containers = result.stdout.split("\n").filter((c) => c.trim().length > 0); + for (const cid of containers) { + await collect(collectDir, `docker-logs-${cid}`, "docker", ["logs", "--tail", "200", cid]); + if (!quick) { + await collect(collectDir, `docker-inspect-${cid}`, "docker", ["inspect", cid]); + } + } + } catch { + /* docker not available */ + } + } +} + +async function collectOpenshell( + collectDir: string, + sandboxName: string, + quick: boolean, +): Promise { + section("OpenShell"); + await collect(collectDir, "openshell-status", "openshell", ["status"]); + await collect(collectDir, "openshell-sandbox-list", "openshell", ["sandbox", "list"]); + await collect(collectDir, "openshell-sandbox-get", "openshell", ["sandbox", "get", sandboxName]); + await collect(collectDir, "openshell-logs", "openshell", ["logs", sandboxName]); + + if (!quick) { + await collect(collectDir, "openshell-gateway-info", "openshell", ["gateway", "info"]); + } +} + +async function collectSandboxInternals( + collectDir: string, + sandboxName: string, + quick: boolean, +): Promise { + if (!(await commandExists("openshell"))) return; + + // Check if sandbox exists + try { + const result = await execa("openshell", ["sandbox", "list"], { + reject: false, + timeout: 10_000, + stdout: "pipe", + stderr: "ignore", + }); + const names = result.stdout + .split("\n") + .map((l) => l.trim().split(/\s+/)[0]) + .filter((n) => n && n.toLowerCase() !== "name"); + if (!names.includes(sandboxName)) return; + } catch { + return; + } + + section("Sandbox Internals"); + + // Generate temporary SSH config + const sshConfigPath = join(tmpdir(), `nemoclaw-ssh-${String(Date.now())}`); + try { + const sshResult = await execa("openshell", ["sandbox", "ssh-config", sandboxName], { + reject: false, + stdout: "pipe", + stderr: "ignore", + }); + if (sshResult.exitCode !== 0) { + warn(`Could not generate SSH config for sandbox '${sandboxName}', skipping internals`); + return; + } + writeFileSync(sshConfigPath, sshResult.stdout); + + const sshHost = `openshell-${sandboxName}`; + const sshBase = [ + "-F", + sshConfigPath, + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=10", + sshHost, + ]; + + await collect(collectDir, "sandbox-ps", "ssh", [...sshBase, "ps", "-ef"]); + await collect(collectDir, "sandbox-free", "ssh", [...sshBase, "free", "-m"]); + if (!quick) { + await collectShell( + collectDir, + "sandbox-top", + `ssh ${sshBase.map((a) => `'${a}'`).join(" ")} 'top -b -n 1 | head -50'`, + ); + await collect(collectDir, "sandbox-gateway-log", "ssh", [ + ...sshBase, + "tail", + "-200", + "/tmp/gateway.log", + ]); + } + } finally { + if (existsSync(sshConfigPath)) { + unlinkSync(sshConfigPath); + } + } +} + +async function collectNetwork(collectDir: string): Promise { + section("Network"); + if (isMacOS) { + await collectShell(collectDir, "listening", "netstat -anp tcp | grep LISTEN"); + await collect(collectDir, "ifconfig", "ifconfig", []); + await collect(collectDir, "routes", "netstat", ["-rn"]); + await collect(collectDir, "dns-config", "scutil", ["--dns"]); + } else { + await collect(collectDir, "ss", "ss", ["-ltnp"]); + await collect(collectDir, "ip-addr", "ip", ["addr"]); + await collect(collectDir, "ip-route", "ip", ["route"]); + await collectShell(collectDir, "resolv-conf", "cat /etc/resolv.conf"); + } + await collect(collectDir, "nslookup", "nslookup", ["integrate.api.nvidia.com"]); + await collectShell( + collectDir, + "curl-models", + 'code=$(curl -s -o /dev/null -w "%{http_code}" https://integrate.api.nvidia.com/v1/models); echo "HTTP $code"; if [ "$code" -ge 200 ] && [ "$code" -lt 500 ]; then echo "NIM API reachable"; else echo "NIM API unreachable"; exit 1; fi', + ); + await collectShell(collectDir, "lsof-net", "lsof -i -P -n 2>/dev/null | head -50"); + await collect(collectDir, "lsof-18789", "lsof", ["-i", ":18789"]); +} + +async function collectKernel(collectDir: string): Promise { + section("Kernel / IO"); + if (isMacOS) { + await collect(collectDir, "vmstat", "vm_stat", []); + await collect(collectDir, "iostat", "iostat", ["-c", "5", "-w", "1"]); + } else { + await collect(collectDir, "vmstat", "vmstat", ["1", "5"]); + await collect(collectDir, "iostat", "iostat", ["-xz", "1", "5"]); + } +} + +async function collectKernelMessages(collectDir: string): Promise { + section("Kernel Messages"); + if (isMacOS) { + await collectShell( + collectDir, + "system-log", + 'log show --last 5m --predicate "eventType == logEvent" --style compact 2>/dev/null | tail -100', + ); + } else { + await collectShell(collectDir, "dmesg", "dmesg | tail -100"); + } +} + +// --------------------------------------------------------------------------- +// Tarball +// --------------------------------------------------------------------------- + +async function createTarball(collectDir: string, output: string): Promise { + await execa("tar", ["czf", output, "-C", dirname(collectDir), basename(collectDir)]); + info(`Tarball written to ${output}`); + warn( + "Known secrets are auto-redacted, but please review for any remaining sensitive data before sharing.", + ); + info("Attach this file to your GitHub issue."); +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function runDebug(opts: DebugOptions = {}): Promise { + const quick = opts.quick ?? false; + const output = opts.output ?? ""; + + // Resolve sandbox name + let sandboxName = + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? ""; + if (!sandboxName) { + sandboxName = await detectSandboxName(); + } + + // Create temp collection directory + const collectDir = mkdtempSync(join(tmpdir(), "nemoclaw-debug-")); + + try { + info(`Collecting diagnostics for sandbox '${sandboxName}'...`); + info(`Quick mode: ${String(quick)}`); + if (output) info(`Tarball output: ${output}`); + console.log(""); + + await collectSystem(collectDir, quick); + await collectProcesses(collectDir, quick); + await collectGpu(collectDir, quick); + await collectDocker(collectDir, quick); + await collectOpenshell(collectDir, sandboxName, quick); + await collectSandboxInternals(collectDir, sandboxName, quick); + + if (!quick) { + await collectNetwork(collectDir); + await collectKernel(collectDir); + } + + await collectKernelMessages(collectDir); + + if (output) { + await createTarball(collectDir, output); + } + + console.log(""); + info("Done. If filing a bug, run with --output and attach the tarball to your issue:"); + info(" nemoclaw debug --output /tmp/nemoclaw-debug.tar.gz"); + } finally { + rmSync(collectDir, { recursive: true, force: true }); + } +} From 726dcb2c567233ab583c90590d818aa68bb75275 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 1 Apr 2026 23:57:00 -0700 Subject: [PATCH 2/4] refactor(cli): port debug.sh to TypeScript (#1296) Replace `spawnSync("bash", ["scripts/debug.sh", ...])` with a direct call to `src/lib/debug.ts` compiled module, following the established migration pattern from #924. - Add `src/lib/debug.ts` with `runDebug()` entry point, `redact()` secret filtering, platform-aware diagnostics (macOS / Linux), and tarball creation using execa for subprocess execution - Add `src/lib/debug.test.ts` testing all redaction patterns - Add `bin/lib/debug.js` thin CJS re-export shim - Update `bin/nemoclaw.js` debug() to parse --quick, --output, --sandbox, --help flags and call runDebug() directly - Preserve `scripts/debug.sh` for standalone curl-pipe usage Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/lib/debug.js | 4 + nemoclaw/src/lib/debug.test.ts | 47 ++++ nemoclaw/src/lib/debug.ts | 465 +++++++++++++++++++++++++++++++++ 3 files changed, 516 insertions(+) create mode 100644 bin/lib/debug.js create mode 100644 nemoclaw/src/lib/debug.test.ts create mode 100644 nemoclaw/src/lib/debug.ts diff --git a/bin/lib/debug.js b/bin/lib/debug.js new file mode 100644 index 000000000..0a62a36a1 --- /dev/null +++ b/bin/lib/debug.js @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +module.exports = require("../../nemoclaw/dist/lib/debug.js"); diff --git a/nemoclaw/src/lib/debug.test.ts b/nemoclaw/src/lib/debug.test.ts new file mode 100644 index 000000000..fe7dbe1eb --- /dev/null +++ b/nemoclaw/src/lib/debug.test.ts @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { redact } from "../../dist/lib/debug.js"; + +describe("redact", () => { + it("redacts NVIDIA_API_KEY=value patterns", () => { + const key = ["NVIDIA", "API", "KEY"].join("_"); + expect(redact(`${key}=some-value`)).toBe(`${key}=`); + }); + + it("redacts generic KEY/TOKEN/SECRET/PASSWORD env vars", () => { + expect(redact("API_KEY=secret123")).toBe("API_KEY="); + expect(redact("MY_TOKEN=tok_abc")).toBe("MY_TOKEN="); + expect(redact("DB_PASSWORD=hunter2")).toBe("DB_PASSWORD="); + expect(redact("MY_SECRET=s3cret")).toBe("MY_SECRET="); + expect(redact("CREDENTIAL=cred")).toBe("CREDENTIAL="); + }); + + it("redacts nvapi- prefixed keys", () => { + expect(redact("using key nvapi-AbCdEfGhIj1234")).toBe("using key "); + }); + + it("redacts GitHub personal access tokens", () => { + expect(redact("token: ghp_" + "a".repeat(36))).toBe("token: "); + }); + + it("redacts Bearer tokens", () => { + expect(redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig")).toBe( + "Authorization: Bearer ", + ); + }); + + it("handles multiple patterns in one string", () => { + const input = "API_KEY=secret nvapi-abcdefghijk Bearer tok123"; + const result = redact(input); + expect(result).not.toContain("secret"); + expect(result).not.toContain("nvapi-abcdefghijk"); + expect(result).not.toContain("tok123"); + }); + + it("leaves clean text unchanged", () => { + const clean = "Hello world, no secrets here"; + expect(redact(clean)).toBe(clean); + }); +}); diff --git a/nemoclaw/src/lib/debug.ts b/nemoclaw/src/lib/debug.ts new file mode 100644 index 000000000..427c3904a --- /dev/null +++ b/nemoclaw/src/lib/debug.ts @@ -0,0 +1,465 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { platform, tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { execa } from "execa"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DebugOptions { + /** Target sandbox name (auto-detected if omitted). */ + sandboxName?: string; + /** Only collect minimal diagnostics. */ + quick?: boolean; + /** Write a tarball to this path. */ + output?: string; +} + +// --------------------------------------------------------------------------- +// Colour helpers — respect NO_COLOR +// --------------------------------------------------------------------------- + +const useColor = !process.env.NO_COLOR && process.stdout.isTTY; +const GREEN = useColor ? "\x1b[0;32m" : ""; +const YELLOW = useColor ? "\x1b[1;33m" : ""; +const CYAN = useColor ? "\x1b[0;36m" : ""; +const NC = useColor ? "\x1b[0m" : ""; + +function info(msg: string): void { + console.log(`${GREEN}[debug]${NC} ${msg}`); +} + +function warn(msg: string): void { + console.log(`${YELLOW}[debug]${NC} ${msg}`); +} + +function section(title: string): void { + console.log(`\n${CYAN}═══ ${title} ═══${NC}\n`); +} + +// --------------------------------------------------------------------------- +// Secret redaction +// --------------------------------------------------------------------------- + +const REDACT_PATTERNS: [RegExp, string][] = [ + [/(NVIDIA_API_KEY|API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)=\S+/gi, "$1="], + [/nvapi-[A-Za-z0-9_-]{10,}/g, ""], + [/ghp_[A-Za-z0-9]{30,}/g, ""], + [/(Bearer )\S+/gi, "$1"], +]; + +export function redact(text: string): string { + let result = text; + for (const [pattern, replacement] of REDACT_PATTERNS) { + result = result.replace(pattern, replacement); + } + return result; +} + +// --------------------------------------------------------------------------- +// Command runner +// --------------------------------------------------------------------------- + +const isMacOS = platform() === "darwin"; + +async function commandExists(cmd: string): Promise { + try { + await execa("command", ["-v", cmd], { shell: true, stdout: "ignore", stderr: "ignore" }); + return true; + } catch { + return false; + } +} + +async function collect( + collectDir: string, + label: string, + command: string, + args: string[], +): Promise { + const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); + const outfile = join(collectDir, `${filename}.txt`); + + if (!(await commandExists(command))) { + const msg = ` (${command} not found, skipping)`; + console.log(msg); + writeFileSync(outfile, msg + "\n"); + return; + } + + const result = await execa(command, args, { + reject: false, + timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + shell: command === "sh", + }); + + const raw = result.stdout + "\n" + result.stderr; + const redacted = redact(raw); + writeFileSync(outfile, redacted); + console.log(redacted.trimEnd()); + + if (result.exitCode !== 0) { + console.log(" (command exited with non-zero status)"); + } +} + +/** Run a shell one-liner via `sh -c`. */ +async function collectShell(collectDir: string, label: string, shellCmd: string): Promise { + const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); + const outfile = join(collectDir, `${filename}.txt`); + + const result = await execa("sh", ["-c", shellCmd], { + reject: false, + timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + }); + + const raw = result.stdout + "\n" + result.stderr; + const redacted = redact(raw); + writeFileSync(outfile, redacted); + console.log(redacted.trimEnd()); + + if (result.exitCode !== 0) { + console.log(" (command exited with non-zero status)"); + } +} + +// --------------------------------------------------------------------------- +// Auto-detect sandbox name +// --------------------------------------------------------------------------- + +async function detectSandboxName(): Promise { + if (!(await commandExists("openshell"))) return "default"; + try { + const result = await execa("openshell", ["sandbox", "list"], { + reject: false, + timeout: 10_000, + stdout: "pipe", + stderr: "ignore", + }); + const lines = result.stdout.split("\n").filter((l) => l.trim().length > 0); + for (const line of lines) { + const first = line.trim().split(/\s+/)[0]; + if (first && first.toLowerCase() !== "name") return first; + } + } catch { + /* ignore */ + } + return "default"; +} + +// --------------------------------------------------------------------------- +// Diagnostic sections +// --------------------------------------------------------------------------- + +async function collectSystem(collectDir: string, quick: boolean): Promise { + section("System"); + await collect(collectDir, "date", "date", []); + await collect(collectDir, "uname", "uname", ["-a"]); + await collect(collectDir, "uptime", "uptime", []); + + if (isMacOS) { + await collectShell( + collectDir, + "memory", + 'echo "Physical: $(($(sysctl -n hw.memsize) / 1048576)) MB"; vm_stat', + ); + } else { + await collect(collectDir, "free", "free", ["-m"]); + } + + if (!quick) { + await collect(collectDir, "df", "df", ["-h"]); + } +} + +async function collectProcesses(collectDir: string, quick: boolean): Promise { + section("Processes"); + if (isMacOS) { + await collectShell( + collectDir, + "ps-cpu", + "ps -eo pid,ppid,comm,%mem,%cpu | sort -k5 -rn | head -30", + ); + } else { + await collectShell( + collectDir, + "ps-cpu", + "ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head -30", + ); + } + + if (!quick) { + if (isMacOS) { + await collectShell( + collectDir, + "ps-mem", + "ps -eo pid,ppid,comm,%mem,%cpu | sort -k4 -rn | head -30", + ); + await collectShell(collectDir, "top", "top -l 1 | head -50"); + } else { + await collectShell( + collectDir, + "ps-mem", + "ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%mem | head -30", + ); + await collectShell(collectDir, "top", "top -b -n 1 | head -50"); + } + } +} + +async function collectGpu(collectDir: string, quick: boolean): Promise { + section("GPU"); + await collect(collectDir, "nvidia-smi", "nvidia-smi", []); + + if (!quick) { + await collect(collectDir, "nvidia-smi-dmon", "nvidia-smi", [ + "dmon", + "-s", + "pucvmet", + "-c", + "10", + ]); + await collect(collectDir, "nvidia-smi-query", "nvidia-smi", [ + "--query-gpu=name,utilization.gpu,utilization.memory,memory.total,memory.used,temperature.gpu,power.draw", + "--format=csv", + ]); + } +} + +async function collectDocker(collectDir: string, quick: boolean): Promise { + section("Docker"); + await collect(collectDir, "docker-ps", "docker", ["ps", "-a"]); + await collect(collectDir, "docker-stats", "docker", ["stats", "--no-stream"]); + + if (!quick) { + await collect(collectDir, "docker-info", "docker", ["info"]); + await collect(collectDir, "docker-df", "docker", ["system", "df"]); + } + + // NemoClaw-labelled containers + if (await commandExists("docker")) { + try { + const result = await execa( + "docker", + ["ps", "-a", "--filter", "label=com.nvidia.nemoclaw", "--format", "{{.Names}}"], + { reject: false, stdout: "pipe", stderr: "ignore" }, + ); + const containers = result.stdout.split("\n").filter((c) => c.trim().length > 0); + for (const cid of containers) { + await collect(collectDir, `docker-logs-${cid}`, "docker", ["logs", "--tail", "200", cid]); + if (!quick) { + await collect(collectDir, `docker-inspect-${cid}`, "docker", ["inspect", cid]); + } + } + } catch { + /* docker not available */ + } + } +} + +async function collectOpenshell( + collectDir: string, + sandboxName: string, + quick: boolean, +): Promise { + section("OpenShell"); + await collect(collectDir, "openshell-status", "openshell", ["status"]); + await collect(collectDir, "openshell-sandbox-list", "openshell", ["sandbox", "list"]); + await collect(collectDir, "openshell-sandbox-get", "openshell", ["sandbox", "get", sandboxName]); + await collect(collectDir, "openshell-logs", "openshell", ["logs", sandboxName]); + + if (!quick) { + await collect(collectDir, "openshell-gateway-info", "openshell", ["gateway", "info"]); + } +} + +async function collectSandboxInternals( + collectDir: string, + sandboxName: string, + quick: boolean, +): Promise { + if (!(await commandExists("openshell"))) return; + + // Check if sandbox exists + try { + const result = await execa("openshell", ["sandbox", "list"], { + reject: false, + timeout: 10_000, + stdout: "pipe", + stderr: "ignore", + }); + const names = result.stdout + .split("\n") + .map((l) => l.trim().split(/\s+/)[0]) + .filter((n) => n && n.toLowerCase() !== "name"); + if (!names.includes(sandboxName)) return; + } catch { + return; + } + + section("Sandbox Internals"); + + // Generate temporary SSH config + const sshConfigPath = join(tmpdir(), `nemoclaw-ssh-${String(Date.now())}`); + try { + const sshResult = await execa("openshell", ["sandbox", "ssh-config", sandboxName], { + reject: false, + stdout: "pipe", + stderr: "ignore", + }); + if (sshResult.exitCode !== 0) { + warn(`Could not generate SSH config for sandbox '${sandboxName}', skipping internals`); + return; + } + writeFileSync(sshConfigPath, sshResult.stdout); + + const sshHost = `openshell-${sandboxName}`; + const sshBase = [ + "-F", + sshConfigPath, + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=10", + sshHost, + ]; + + await collect(collectDir, "sandbox-ps", "ssh", [...sshBase, "ps", "-ef"]); + await collect(collectDir, "sandbox-free", "ssh", [...sshBase, "free", "-m"]); + if (!quick) { + await collectShell( + collectDir, + "sandbox-top", + `ssh ${sshBase.map((a) => `'${a}'`).join(" ")} 'top -b -n 1 | head -50'`, + ); + await collect(collectDir, "sandbox-gateway-log", "ssh", [ + ...sshBase, + "tail", + "-200", + "/tmp/gateway.log", + ]); + } + } finally { + if (existsSync(sshConfigPath)) { + unlinkSync(sshConfigPath); + } + } +} + +async function collectNetwork(collectDir: string): Promise { + section("Network"); + if (isMacOS) { + await collectShell(collectDir, "listening", "netstat -anp tcp | grep LISTEN"); + await collect(collectDir, "ifconfig", "ifconfig", []); + await collect(collectDir, "routes", "netstat", ["-rn"]); + await collect(collectDir, "dns-config", "scutil", ["--dns"]); + } else { + await collect(collectDir, "ss", "ss", ["-ltnp"]); + await collect(collectDir, "ip-addr", "ip", ["addr"]); + await collect(collectDir, "ip-route", "ip", ["route"]); + await collectShell(collectDir, "resolv-conf", "cat /etc/resolv.conf"); + } + await collect(collectDir, "nslookup", "nslookup", ["integrate.api.nvidia.com"]); + await collectShell( + collectDir, + "curl-models", + 'code=$(curl -s -o /dev/null -w "%{http_code}" https://integrate.api.nvidia.com/v1/models); echo "HTTP $code"; if [ "$code" -ge 200 ] && [ "$code" -lt 500 ]; then echo "NIM API reachable"; else echo "NIM API unreachable"; exit 1; fi', + ); + await collectShell(collectDir, "lsof-net", "lsof -i -P -n 2>/dev/null | head -50"); + await collect(collectDir, "lsof-18789", "lsof", ["-i", ":18789"]); +} + +async function collectKernel(collectDir: string): Promise { + section("Kernel / IO"); + if (isMacOS) { + await collect(collectDir, "vmstat", "vm_stat", []); + await collect(collectDir, "iostat", "iostat", ["-c", "5", "-w", "1"]); + } else { + await collect(collectDir, "vmstat", "vmstat", ["1", "5"]); + await collect(collectDir, "iostat", "iostat", ["-xz", "1", "5"]); + } +} + +async function collectKernelMessages(collectDir: string): Promise { + section("Kernel Messages"); + if (isMacOS) { + await collectShell( + collectDir, + "system-log", + 'log show --last 5m --predicate "eventType == logEvent" --style compact 2>/dev/null | tail -100', + ); + } else { + await collectShell(collectDir, "dmesg", "dmesg | tail -100"); + } +} + +// --------------------------------------------------------------------------- +// Tarball +// --------------------------------------------------------------------------- + +async function createTarball(collectDir: string, output: string): Promise { + await execa("tar", ["czf", output, "-C", dirname(collectDir), basename(collectDir)]); + info(`Tarball written to ${output}`); + warn( + "Known secrets are auto-redacted, but please review for any remaining sensitive data before sharing.", + ); + info("Attach this file to your GitHub issue."); +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function runDebug(opts: DebugOptions = {}): Promise { + const quick = opts.quick ?? false; + const output = opts.output ?? ""; + + // Resolve sandbox name + let sandboxName = + opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? ""; + if (!sandboxName) { + sandboxName = await detectSandboxName(); + } + + // Create temp collection directory + const collectDir = mkdtempSync(join(tmpdir(), "nemoclaw-debug-")); + + try { + info(`Collecting diagnostics for sandbox '${sandboxName}'...`); + info(`Quick mode: ${String(quick)}`); + if (output) info(`Tarball output: ${output}`); + console.log(""); + + await collectSystem(collectDir, quick); + await collectProcesses(collectDir, quick); + await collectGpu(collectDir, quick); + await collectDocker(collectDir, quick); + await collectOpenshell(collectDir, sandboxName, quick); + await collectSandboxInternals(collectDir, sandboxName, quick); + + if (!quick) { + await collectNetwork(collectDir); + await collectKernel(collectDir); + } + + await collectKernelMessages(collectDir); + + if (output) { + await createTarball(collectDir, output); + } + + console.log(""); + info("Done. If filing a bug, run with --output and attach the tarball to your issue:"); + info(" nemoclaw debug --output /tmp/nemoclaw-debug.tar.gz"); + } finally { + rmSync(collectDir, { recursive: true, force: true }); + } +} From f7f0c2eb9a75942d5c933148ab1150c030a38efb Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 2 Apr 2026 10:53:19 -0700 Subject: [PATCH 3/4] fix: move debug to src/lib/, fix shell injection and add github_pat_ redaction Address review feedback: - Move TS source from nemoclaw/src/lib/ to src/lib/ (correct directory pattern) - Replace execa with child_process (execFileSync/spawnSync) for CJS compat - Fix shell injection: use collect() with array args for SSH sandbox commands instead of collectShell() with string interpolation of sandboxName - Add github_pat_ fine-grained token redaction pattern - Add timeouts (30s) to all ad-hoc Docker/OpenShell probes - Reject flag-shaped values for --output/--sandbox in argument parsing - Add Onboard Session diagnostic section - Update bin/lib/debug.js shim to point to dist/lib/debug - Make all functions synchronous (no more async/execa dependency) Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/lib/debug.js | 2 +- bin/nemoclaw.js | 51 ++++- {nemoclaw/src => src}/lib/debug.test.ts | 9 +- {nemoclaw/src => src}/lib/debug.ts | 274 +++++++++++++----------- 4 files changed, 196 insertions(+), 140 deletions(-) rename {nemoclaw/src => src}/lib/debug.test.ts (82%) rename {nemoclaw/src => src}/lib/debug.ts (54%) diff --git a/bin/lib/debug.js b/bin/lib/debug.js index 0a62a36a1..5465b8042 100644 --- a/bin/lib/debug.js +++ b/bin/lib/debug.js @@ -1,4 +1,4 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -module.exports = require("../../nemoclaw/dist/lib/debug.js"); +module.exports = require("../../dist/lib/debug"); diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index ba21a2aa4..0ec55e3c5 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -789,15 +789,48 @@ function stop() { } function debug(args) { - const result = spawnSync("bash", [path.join(SCRIPTS, "debug.sh"), ...args], { - stdio: "inherit", - cwd: ROOT, - env: { - ...process.env, - SANDBOX_NAME: registry.listSandboxes().defaultSandbox || "", - }, - }); - exitWithSpawnResult(result); + const { runDebug } = require("./lib/debug"); + const opts = {}; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--help": + case "-h": + console.log("Collect NemoClaw diagnostic information\n"); + console.log("Usage: nemoclaw debug [--quick] [--output FILE] [--sandbox NAME]\n"); + console.log("Options:"); + console.log(" --quick, -q Only collect minimal diagnostics"); + console.log(" --output, -o FILE Write a tarball to FILE"); + console.log(" --sandbox NAME Target sandbox name"); + process.exit(0); + break; + case "--quick": + case "-q": + opts.quick = true; + break; + case "--output": + case "-o": + if (!args[i + 1] || args[i + 1].startsWith("-")) { + console.error("Error: --output requires a file path argument"); + process.exit(1); + } + opts.output = args[++i]; + break; + case "--sandbox": + if (!args[i + 1] || args[i + 1].startsWith("-")) { + console.error("Error: --sandbox requires a name argument"); + process.exit(1); + } + opts.sandboxName = args[++i]; + break; + default: + console.error(`Unknown option: ${args[i]}`); + process.exit(1); + } + } + if (!opts.sandboxName) { + opts.sandboxName = registry.listSandboxes().defaultSandbox || undefined; + } + runDebug(opts); } function uninstall(args) { diff --git a/nemoclaw/src/lib/debug.test.ts b/src/lib/debug.test.ts similarity index 82% rename from nemoclaw/src/lib/debug.test.ts rename to src/lib/debug.test.ts index fe7dbe1eb..7aa76d195 100644 --- a/nemoclaw/src/lib/debug.test.ts +++ b/src/lib/debug.test.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { redact } from "../../dist/lib/debug.js"; +// Import from compiled dist/ so coverage is attributed correctly. +import { redact } from "../../dist/lib/debug"; describe("redact", () => { it("redacts NVIDIA_API_KEY=value patterns", () => { @@ -22,10 +23,14 @@ describe("redact", () => { expect(redact("using key nvapi-AbCdEfGhIj1234")).toBe("using key "); }); - it("redacts GitHub personal access tokens", () => { + it("redacts classic GitHub personal access tokens (ghp_)", () => { expect(redact("token: ghp_" + "a".repeat(36))).toBe("token: "); }); + it("redacts fine-grained GitHub personal access tokens (github_pat_)", () => { + expect(redact("token: github_pat_" + "A".repeat(40))).toBe("token: "); + }); + it("redacts Bearer tokens", () => { expect(redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig")).toBe( "Authorization: Bearer ", diff --git a/nemoclaw/src/lib/debug.ts b/src/lib/debug.ts similarity index 54% rename from nemoclaw/src/lib/debug.ts rename to src/lib/debug.ts index 427c3904a..eaedf3493 100644 --- a/nemoclaw/src/lib/debug.ts +++ b/src/lib/debug.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { execFileSync, execSync, spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { platform, tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; -import { execa } from "execa"; // --------------------------------------------------------------------------- // Types @@ -48,7 +48,7 @@ function section(title: string): void { const REDACT_PATTERNS: [RegExp, string][] = [ [/(NVIDIA_API_KEY|API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)=\S+/gi, "$1="], [/nvapi-[A-Za-z0-9_-]{10,}/g, ""], - [/ghp_[A-Za-z0-9]{30,}/g, ""], + [/(?:ghp_|github_pat_)[A-Za-z0-9_]{30,}/g, ""], [/(Bearer )\S+/gi, "$1"], ]; @@ -65,68 +65,61 @@ export function redact(text: string): string { // --------------------------------------------------------------------------- const isMacOS = platform() === "darwin"; +const TIMEOUT_MS = 30_000; -async function commandExists(cmd: string): Promise { +function commandExists(cmd: string): boolean { try { - await execa("command", ["-v", cmd], { shell: true, stdout: "ignore", stderr: "ignore" }); + execSync(`command -v ${cmd}`, { stdio: ["ignore", "ignore", "ignore"] }); return true; } catch { return false; } } -async function collect( - collectDir: string, - label: string, - command: string, - args: string[], -): Promise { +function collect(collectDir: string, label: string, command: string, args: string[]): void { const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); const outfile = join(collectDir, `${filename}.txt`); - if (!(await commandExists(command))) { + if (!commandExists(command)) { const msg = ` (${command} not found, skipping)`; console.log(msg); writeFileSync(outfile, msg + "\n"); return; } - const result = await execa(command, args, { - reject: false, - timeout: 30_000, - stdout: "pipe", - stderr: "pipe", - shell: command === "sh", + const result = spawnSync(command, args, { + timeout: TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", }); - const raw = result.stdout + "\n" + result.stderr; + const raw = (result.stdout ?? "") + "\n" + (result.stderr ?? ""); const redacted = redact(raw); writeFileSync(outfile, redacted); console.log(redacted.trimEnd()); - if (result.exitCode !== 0) { + if (result.status !== 0) { console.log(" (command exited with non-zero status)"); } } /** Run a shell one-liner via `sh -c`. */ -async function collectShell(collectDir: string, label: string, shellCmd: string): Promise { +function collectShell(collectDir: string, label: string, shellCmd: string): void { const filename = label.replace(/[ /]/g, (c) => (c === " " ? "_" : "-")); const outfile = join(collectDir, `${filename}.txt`); - const result = await execa("sh", ["-c", shellCmd], { - reject: false, - timeout: 30_000, - stdout: "pipe", - stderr: "pipe", + const result = spawnSync("sh", ["-c", shellCmd], { + timeout: TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", }); - const raw = result.stdout + "\n" + result.stderr; + const raw = (result.stdout ?? "") + "\n" + (result.stderr ?? ""); const redacted = redact(raw); writeFileSync(outfile, redacted); console.log(redacted.trimEnd()); - if (result.exitCode !== 0) { + if (result.status !== 0) { console.log(" (command exited with non-zero status)"); } } @@ -135,16 +128,15 @@ async function collectShell(collectDir: string, label: string, shellCmd: string) // Auto-detect sandbox name // --------------------------------------------------------------------------- -async function detectSandboxName(): Promise { - if (!(await commandExists("openshell"))) return "default"; +function detectSandboxName(): string { + if (!commandExists("openshell")) return "default"; try { - const result = await execa("openshell", ["sandbox", "list"], { - reject: false, + const output = execFileSync("openshell", ["sandbox", "list"], { + encoding: "utf-8", timeout: 10_000, - stdout: "pipe", - stderr: "ignore", + stdio: ["ignore", "pipe", "ignore"], }); - const lines = result.stdout.split("\n").filter((l) => l.trim().length > 0); + const lines = output.split("\n").filter((l) => l.trim().length > 0); for (const line of lines) { const first = line.trim().split(/\s+/)[0]; if (first && first.toLowerCase() !== "name") return first; @@ -159,37 +151,37 @@ async function detectSandboxName(): Promise { // Diagnostic sections // --------------------------------------------------------------------------- -async function collectSystem(collectDir: string, quick: boolean): Promise { +function collectSystem(collectDir: string, quick: boolean): void { section("System"); - await collect(collectDir, "date", "date", []); - await collect(collectDir, "uname", "uname", ["-a"]); - await collect(collectDir, "uptime", "uptime", []); + collect(collectDir, "date", "date", []); + collect(collectDir, "uname", "uname", ["-a"]); + collect(collectDir, "uptime", "uptime", []); if (isMacOS) { - await collectShell( + collectShell( collectDir, "memory", 'echo "Physical: $(($(sysctl -n hw.memsize) / 1048576)) MB"; vm_stat', ); } else { - await collect(collectDir, "free", "free", ["-m"]); + collect(collectDir, "free", "free", ["-m"]); } if (!quick) { - await collect(collectDir, "df", "df", ["-h"]); + collect(collectDir, "df", "df", ["-h"]); } } -async function collectProcesses(collectDir: string, quick: boolean): Promise { +function collectProcesses(collectDir: string, quick: boolean): void { section("Processes"); if (isMacOS) { - await collectShell( + collectShell( collectDir, "ps-cpu", "ps -eo pid,ppid,comm,%mem,%cpu | sort -k5 -rn | head -30", ); } else { - await collectShell( + collectShell( collectDir, "ps-cpu", "ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head -30", @@ -198,105 +190,104 @@ async function collectProcesses(collectDir: string, quick: boolean): Promise { +function collectGpu(collectDir: string, quick: boolean): void { section("GPU"); - await collect(collectDir, "nvidia-smi", "nvidia-smi", []); + collect(collectDir, "nvidia-smi", "nvidia-smi", []); if (!quick) { - await collect(collectDir, "nvidia-smi-dmon", "nvidia-smi", [ + collect(collectDir, "nvidia-smi-dmon", "nvidia-smi", [ "dmon", "-s", "pucvmet", "-c", "10", ]); - await collect(collectDir, "nvidia-smi-query", "nvidia-smi", [ + collect(collectDir, "nvidia-smi-query", "nvidia-smi", [ "--query-gpu=name,utilization.gpu,utilization.memory,memory.total,memory.used,temperature.gpu,power.draw", "--format=csv", ]); } } -async function collectDocker(collectDir: string, quick: boolean): Promise { +function collectDocker(collectDir: string, quick: boolean): void { section("Docker"); - await collect(collectDir, "docker-ps", "docker", ["ps", "-a"]); - await collect(collectDir, "docker-stats", "docker", ["stats", "--no-stream"]); + collect(collectDir, "docker-ps", "docker", ["ps", "-a"]); + collect(collectDir, "docker-stats", "docker", ["stats", "--no-stream"]); if (!quick) { - await collect(collectDir, "docker-info", "docker", ["info"]); - await collect(collectDir, "docker-df", "docker", ["system", "df"]); + collect(collectDir, "docker-info", "docker", ["info"]); + collect(collectDir, "docker-df", "docker", ["system", "df"]); } // NemoClaw-labelled containers - if (await commandExists("docker")) { + if (commandExists("docker")) { try { - const result = await execa( + const output = execFileSync( "docker", ["ps", "-a", "--filter", "label=com.nvidia.nemoclaw", "--format", "{{.Names}}"], - { reject: false, stdout: "pipe", stderr: "ignore" }, + { encoding: "utf-8", timeout: TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }, ); - const containers = result.stdout.split("\n").filter((c) => c.trim().length > 0); + const containers = output.split("\n").filter((c) => c.trim().length > 0); for (const cid of containers) { - await collect(collectDir, `docker-logs-${cid}`, "docker", ["logs", "--tail", "200", cid]); + collect(collectDir, `docker-logs-${cid}`, "docker", ["logs", "--tail", "200", cid]); if (!quick) { - await collect(collectDir, `docker-inspect-${cid}`, "docker", ["inspect", cid]); + collect(collectDir, `docker-inspect-${cid}`, "docker", ["inspect", cid]); } } } catch { - /* docker not available */ + /* docker not available or timed out */ } } } -async function collectOpenshell( +function collectOpenshell( collectDir: string, sandboxName: string, quick: boolean, -): Promise { +): void { section("OpenShell"); - await collect(collectDir, "openshell-status", "openshell", ["status"]); - await collect(collectDir, "openshell-sandbox-list", "openshell", ["sandbox", "list"]); - await collect(collectDir, "openshell-sandbox-get", "openshell", ["sandbox", "get", sandboxName]); - await collect(collectDir, "openshell-logs", "openshell", ["logs", sandboxName]); + collect(collectDir, "openshell-status", "openshell", ["status"]); + collect(collectDir, "openshell-sandbox-list", "openshell", ["sandbox", "list"]); + collect(collectDir, "openshell-sandbox-get", "openshell", ["sandbox", "get", sandboxName]); + collect(collectDir, "openshell-logs", "openshell", ["logs", sandboxName]); if (!quick) { - await collect(collectDir, "openshell-gateway-info", "openshell", ["gateway", "info"]); + collect(collectDir, "openshell-gateway-info", "openshell", ["gateway", "info"]); } } -async function collectSandboxInternals( +function collectSandboxInternals( collectDir: string, sandboxName: string, quick: boolean, -): Promise { - if (!(await commandExists("openshell"))) return; +): void { + if (!commandExists("openshell")) return; // Check if sandbox exists try { - const result = await execa("openshell", ["sandbox", "list"], { - reject: false, + const output = execFileSync("openshell", ["sandbox", "list"], { + encoding: "utf-8", timeout: 10_000, - stdout: "pipe", - stderr: "ignore", + stdio: ["ignore", "pipe", "ignore"], }); - const names = result.stdout + const names = output .split("\n") .map((l) => l.trim().split(/\s+/)[0]) .filter((n) => n && n.toLowerCase() !== "name"); @@ -310,16 +301,16 @@ async function collectSandboxInternals( // Generate temporary SSH config const sshConfigPath = join(tmpdir(), `nemoclaw-ssh-${String(Date.now())}`); try { - const sshResult = await execa("openshell", ["sandbox", "ssh-config", sandboxName], { - reject: false, - stdout: "pipe", - stderr: "ignore", + const sshResult = spawnSync("openshell", ["sandbox", "ssh-config", sandboxName], { + timeout: TIMEOUT_MS, + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf-8", }); - if (sshResult.exitCode !== 0) { + if (sshResult.status !== 0) { warn(`Could not generate SSH config for sandbox '${sandboxName}', skipping internals`); return; } - writeFileSync(sshConfigPath, sshResult.stdout); + writeFileSync(sshConfigPath, sshResult.stdout ?? ""); const sshHost = `openshell-${sandboxName}`; const sshBase = [ @@ -332,15 +323,18 @@ async function collectSandboxInternals( sshHost, ]; - await collect(collectDir, "sandbox-ps", "ssh", [...sshBase, "ps", "-ef"]); - await collect(collectDir, "sandbox-free", "ssh", [...sshBase, "free", "-m"]); + // Use collect() with array args — no shell interpolation of sandboxName + collect(collectDir, "sandbox-ps", "ssh", [...sshBase, "ps", "-ef"]); + collect(collectDir, "sandbox-free", "ssh", [...sshBase, "free", "-m"]); if (!quick) { - await collectShell( - collectDir, - "sandbox-top", - `ssh ${sshBase.map((a) => `'${a}'`).join(" ")} 'top -b -n 1 | head -50'`, - ); - await collect(collectDir, "sandbox-gateway-log", "ssh", [ + collect(collectDir, "sandbox-top", "ssh", [ + ...sshBase, + "top", + "-b", + "-n", + "1", + ]); + collect(collectDir, "sandbox-gateway-log", "ssh", [ ...sshBase, "tail", "-200", @@ -354,50 +348,68 @@ async function collectSandboxInternals( } } -async function collectNetwork(collectDir: string): Promise { +function collectNetwork(collectDir: string): void { section("Network"); if (isMacOS) { - await collectShell(collectDir, "listening", "netstat -anp tcp | grep LISTEN"); - await collect(collectDir, "ifconfig", "ifconfig", []); - await collect(collectDir, "routes", "netstat", ["-rn"]); - await collect(collectDir, "dns-config", "scutil", ["--dns"]); + collectShell(collectDir, "listening", "netstat -anp tcp | grep LISTEN"); + collect(collectDir, "ifconfig", "ifconfig", []); + collect(collectDir, "routes", "netstat", ["-rn"]); + collect(collectDir, "dns-config", "scutil", ["--dns"]); } else { - await collect(collectDir, "ss", "ss", ["-ltnp"]); - await collect(collectDir, "ip-addr", "ip", ["addr"]); - await collect(collectDir, "ip-route", "ip", ["route"]); - await collectShell(collectDir, "resolv-conf", "cat /etc/resolv.conf"); + collect(collectDir, "ss", "ss", ["-ltnp"]); + collect(collectDir, "ip-addr", "ip", ["addr"]); + collect(collectDir, "ip-route", "ip", ["route"]); + collectShell(collectDir, "resolv-conf", "cat /etc/resolv.conf"); } - await collect(collectDir, "nslookup", "nslookup", ["integrate.api.nvidia.com"]); - await collectShell( + collect(collectDir, "nslookup", "nslookup", ["integrate.api.nvidia.com"]); + collectShell( collectDir, "curl-models", 'code=$(curl -s -o /dev/null -w "%{http_code}" https://integrate.api.nvidia.com/v1/models); echo "HTTP $code"; if [ "$code" -ge 200 ] && [ "$code" -lt 500 ]; then echo "NIM API reachable"; else echo "NIM API unreachable"; exit 1; fi', ); - await collectShell(collectDir, "lsof-net", "lsof -i -P -n 2>/dev/null | head -50"); - await collect(collectDir, "lsof-18789", "lsof", ["-i", ":18789"]); + collectShell(collectDir, "lsof-net", "lsof -i -P -n 2>/dev/null | head -50"); + collect(collectDir, "lsof-18789", "lsof", ["-i", ":18789"]); +} + +function collectOnboardSession(collectDir: string, repoDir: string): void { + section("Onboard Session"); + const helperPath = join(repoDir, "bin", "lib", "onboard-session.js"); + if (!existsSync(helperPath) || !commandExists("node")) { + console.log(" (onboard session helper not available, skipping)"); + return; + } + + const script = [ + "const helper = require(process.argv[1]);", + "const summary = helper.summarizeForDebug();", + "if (!summary) { process.stdout.write('No onboard session state found.\\n'); process.exit(0); }", + "process.stdout.write(JSON.stringify(summary, null, 2) + '\\n');", + ].join(" "); + + collect(collectDir, "onboard-session-summary", "node", ["-e", script, helperPath]); } -async function collectKernel(collectDir: string): Promise { +function collectKernel(collectDir: string): void { section("Kernel / IO"); if (isMacOS) { - await collect(collectDir, "vmstat", "vm_stat", []); - await collect(collectDir, "iostat", "iostat", ["-c", "5", "-w", "1"]); + collect(collectDir, "vmstat", "vm_stat", []); + collect(collectDir, "iostat", "iostat", ["-c", "5", "-w", "1"]); } else { - await collect(collectDir, "vmstat", "vmstat", ["1", "5"]); - await collect(collectDir, "iostat", "iostat", ["-xz", "1", "5"]); + collect(collectDir, "vmstat", "vmstat", ["1", "5"]); + collect(collectDir, "iostat", "iostat", ["-xz", "1", "5"]); } } -async function collectKernelMessages(collectDir: string): Promise { +function collectKernelMessages(collectDir: string): void { section("Kernel Messages"); if (isMacOS) { - await collectShell( + collectShell( collectDir, "system-log", 'log show --last 5m --predicate "eventType == logEvent" --style compact 2>/dev/null | tail -100', ); } else { - await collectShell(collectDir, "dmesg", "dmesg | tail -100"); + collectShell(collectDir, "dmesg", "dmesg | tail -100"); } } @@ -405,8 +417,11 @@ async function collectKernelMessages(collectDir: string): Promise { // Tarball // --------------------------------------------------------------------------- -async function createTarball(collectDir: string, output: string): Promise { - await execa("tar", ["czf", output, "-C", dirname(collectDir), basename(collectDir)]); +function createTarball(collectDir: string, output: string): void { + spawnSync("tar", ["czf", output, "-C", dirname(collectDir), basename(collectDir)], { + stdio: "inherit", + timeout: 60_000, + }); info(`Tarball written to ${output}`); warn( "Known secrets are auto-redacted, but please review for any remaining sensitive data before sharing.", @@ -418,15 +433,17 @@ async function createTarball(collectDir: string, output: string): Promise // Main entry point // --------------------------------------------------------------------------- -export async function runDebug(opts: DebugOptions = {}): Promise { +export function runDebug(opts: DebugOptions = {}): void { const quick = opts.quick ?? false; const output = opts.output ?? ""; + // Compiled location: dist/lib/debug.js → repo root is 2 levels up + const repoDir = join(__dirname, "..", ".."); // Resolve sandbox name let sandboxName = opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? ""; if (!sandboxName) { - sandboxName = await detectSandboxName(); + sandboxName = detectSandboxName(); } // Create temp collection directory @@ -438,22 +455,23 @@ export async function runDebug(opts: DebugOptions = {}): Promise { if (output) info(`Tarball output: ${output}`); console.log(""); - await collectSystem(collectDir, quick); - await collectProcesses(collectDir, quick); - await collectGpu(collectDir, quick); - await collectDocker(collectDir, quick); - await collectOpenshell(collectDir, sandboxName, quick); - await collectSandboxInternals(collectDir, sandboxName, quick); + collectSystem(collectDir, quick); + collectProcesses(collectDir, quick); + collectGpu(collectDir, quick); + collectDocker(collectDir, quick); + collectOpenshell(collectDir, sandboxName, quick); + collectOnboardSession(collectDir, repoDir); + collectSandboxInternals(collectDir, sandboxName, quick); if (!quick) { - await collectNetwork(collectDir); - await collectKernel(collectDir); + collectNetwork(collectDir); + collectKernel(collectDir); } - await collectKernelMessages(collectDir); + collectKernelMessages(collectDir); if (output) { - await createTarball(collectDir, output); + createTarball(collectDir, output); } console.log(""); From 4ffb5a524404b8752cd35e68bbb62de2d5a65b54 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 2 Apr 2026 11:26:48 -0700 Subject: [PATCH 4/4] fix(debug): prevent shell injection in commandExists Replace `execSync(`command -v ${cmd}`)` with `execFileSync("sh", ["-c", 'command -v "$1"', "--", cmd])` to avoid shell injection. Also remove the now-unused `execSync` import. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/debug.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/debug.ts b/src/lib/debug.ts index eaedf3493..2ce3bd480 100644 --- a/src/lib/debug.ts +++ b/src/lib/debug.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync, execSync, spawnSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { platform, tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; @@ -69,7 +69,11 @@ const TIMEOUT_MS = 30_000; function commandExists(cmd: string): boolean { try { - execSync(`command -v ${cmd}`, { stdio: ["ignore", "ignore", "ignore"] }); + // Use sh -c with the command as a separate argument to avoid shell injection. + // While cmd values are hardcoded internally, this is defensive. + execFileSync("sh", ["-c", `command -v "$1"`, "--", cmd], { + stdio: ["ignore", "ignore", "ignore"], + }); return true; } catch { return false;