Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,9 @@ $ nemoclaw debug [--quick|-q] [--sandbox NAME] [--output PATH|-o PATH]
| `--output PATH`, `-o PATH` | Write diagnostics tarball to the given path |

If `--output` is set and the tarball cannot be written (for example, the destination directory is missing or read-only), the command exits non-zero so scripts can detect the failure.
The tarball is written to a temporary sibling and renamed on success, so a pre-existing file at `--output` is preserved when `tar` fails.

When `--sandbox` is supplied explicitly (via flag or one of `NEMOCLAW_SANDBOX_NAME`, `NEMOCLAW_SANDBOX`, `SANDBOX_NAME` — flag wins, then the env vars in that order), the name must match a registered sandbox; if `openshell sandbox list` succeeds it must also appear in the live gateway. An unknown or stale name exits non-zero with an actionable error that names the sandbox and reports the source env var when applicable, and no tarball is written. Without an explicit name, `nemoclaw debug` falls back to the registry's default sandbox (and warns if that default is stale).

### `nemoclaw credentials list`

Expand Down
11 changes: 11 additions & 0 deletions src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,19 @@ function buildDebugCommandDeps(rootDir: string): RunDebugCommandDeps {
return defaultSandbox;
};

const isSandboxKnown = (name: string): boolean => {
const { sandboxes } = registry.listSandboxes();
if (!sandboxes.find((sandbox) => sandbox.name === name)) return false;
const liveList = captureOpenshell(rootDir, ["sandbox", "list"]);
if (liveList.status === 0 && !parseLiveSandboxNames(liveList.output).has(name)) {
return false;
}
return true;
};

return {
getDefaultSandbox,
isSandboxKnown,
runDebug,
};
}
Expand Down
119 changes: 119 additions & 0 deletions src/lib/diagnostics/debug-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe("debug command", () => {
{ quick: true, output: "/tmp/out.tgz" },
{
getDefaultSandbox: () => "alpha",
isSandboxKnown: () => true,
runDebug,
},
);
Expand All @@ -21,4 +22,122 @@ describe("debug command", () => {
sandboxName: "alpha",
});
});

it("accepts an explicit --sandbox name that is registered", () => {
const runDebug = vi.fn();
const isSandboxKnown = vi.fn().mockReturnValue(true);
runDebugCommandWithOptions(
{ sandboxName: "alpha" },
{
getDefaultSandbox: () => undefined,
isSandboxKnown,
runDebug,
},
);
expect(isSandboxKnown).toHaveBeenCalledWith("alpha");
expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha" });
});

it("rejects an explicit --sandbox name that is not registered, exits non-zero, skips runDebug", () => {
const runDebug = vi.fn();
const errorLines: string[] = [];
const exit = vi.fn(() => {
throw new Error("exit");
}) as unknown as (code: number) => never;
expect(() =>
runDebugCommandWithOptions(
{ sandboxName: "does-not-exist", output: "/tmp/out.tgz" },
{
getDefaultSandbox: () => "alpha",
isSandboxKnown: () => false,
runDebug,
errorLine: (msg) => errorLines.push(msg),
exit,
},
),
).toThrow("exit");
expect(exit).toHaveBeenCalledWith(1);
expect(runDebug).not.toHaveBeenCalled();
expect(errorLines[0]).toContain("does-not-exist");
expect(errorLines[0]).toContain("not registered");
expect(errorLines.join("\n")).toContain("nemoclaw list");
});

it("validates an env-sourced sandbox name and reports the env source on failure", () => {
const runDebug = vi.fn();
const errorLines: string[] = [];
const exit = vi.fn(() => {
throw new Error("exit");
}) as unknown as (code: number) => never;
expect(() =>
runDebugCommandWithOptions(
{},
{
env: { NEMOCLAW_SANDBOX_NAME: "ghost" } as NodeJS.ProcessEnv,
getDefaultSandbox: () => "alpha",
isSandboxKnown: () => false,
runDebug,
errorLine: (msg) => errorLines.push(msg),
exit,
},
),
).toThrow("exit");
expect(exit).toHaveBeenCalledWith(1);
expect(runDebug).not.toHaveBeenCalled();
expect(errorLines[0]).toContain("ghost");
expect(errorLines[0]).toContain("NEMOCLAW_SANDBOX_NAME");
});

it("prefers NEMOCLAW_SANDBOX_NAME over NEMOCLAW_SANDBOX and SANDBOX_NAME", () => {
const runDebug = vi.fn();
const isSandboxKnown = vi.fn().mockReturnValue(true);
runDebugCommandWithOptions(
{},
{
env: {
NEMOCLAW_SANDBOX_NAME: "primary",
NEMOCLAW_SANDBOX: "secondary",
SANDBOX_NAME: "tertiary",
} as NodeJS.ProcessEnv,
getDefaultSandbox: () => undefined,
isSandboxKnown,
runDebug,
},
);
expect(isSandboxKnown).toHaveBeenCalledWith("primary");
expect(runDebug).toHaveBeenCalledWith({ sandboxName: "primary" });
});

it("flag overrides env vars when both are present", () => {
const runDebug = vi.fn();
const isSandboxKnown = vi.fn().mockReturnValue(true);
runDebugCommandWithOptions(
{ sandboxName: "alpha" },
{
env: { NEMOCLAW_SANDBOX: "beta" } as NodeJS.ProcessEnv,
getDefaultSandbox: () => undefined,
isSandboxKnown,
runDebug,
},
);
expect(isSandboxKnown).toHaveBeenCalledWith("alpha");
expect(isSandboxKnown).not.toHaveBeenCalledWith("beta");
expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha" });
});

it("falls back to getDefaultSandbox when neither flag nor env is set", () => {
const runDebug = vi.fn();
const isSandboxKnown = vi.fn();
runDebugCommandWithOptions(
{},
{
env: {} as NodeJS.ProcessEnv,
getDefaultSandbox: () => "alpha",
isSandboxKnown,
runDebug,
},
);
expect(isSandboxKnown).not.toHaveBeenCalled();
expect(runDebug).toHaveBeenCalledWith({ sandboxName: "alpha" });
});
});
41 changes: 40 additions & 1 deletion src/lib/diagnostics/debug-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,52 @@ import type { DebugOptions } from "./debug";

export interface RunDebugCommandDeps {
getDefaultSandbox: () => string | undefined;
isSandboxKnown: (name: string) => boolean;
runDebug: (options: DebugOptions) => void;
env?: NodeJS.ProcessEnv;
errorLine?: (message: string) => void;
exit?: (code: number) => never;
}

const SANDBOX_NAME_ENV_VARS = ["NEMOCLAW_SANDBOX_NAME", "NEMOCLAW_SANDBOX", "SANDBOX_NAME"] as const;

function resolveExplicitName(
options: DebugOptions,
env: NodeJS.ProcessEnv,
): { name: string; source: "flag" | "env"; envVar?: string } | null {
const flagName = options.sandboxName?.trim();
if (flagName) return { name: flagName, source: "flag" };
for (const envVar of SANDBOX_NAME_ENV_VARS) {
const value = env[envVar]?.trim();
if (value) return { name: value, source: "env", envVar };
}
return null;
}

export function runDebugCommandWithOptions(options: DebugOptions, deps: RunDebugCommandDeps): void {
const opts = { ...options };
if (!opts.sandboxName) {
const env = deps.env ?? process.env;
const errorLine = deps.errorLine ?? ((msg: string) => console.error(msg));
const exit =
deps.exit ??
((code: number) => {
process.exit(code);
});

const explicit = resolveExplicitName(opts, env);
if (explicit) {
if (!deps.isSandboxKnown(explicit.name)) {
const sourceLabel =
explicit.source === "env" && explicit.envVar ? ` (from ${explicit.envVar})` : "";
errorLine(`Error: Sandbox '${explicit.name}'${sourceLabel} is not registered.`);
errorLine(" Run `nemoclaw list` to see available sandboxes.");
exit(1);
return;
}
opts.sandboxName = explicit.name;
} else {
opts.sandboxName = deps.getDefaultSandbox();
}

deps.runDebug(opts);
}
25 changes: 24 additions & 1 deletion src/lib/diagnostics/debug.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -82,6 +82,29 @@ describe("createTarball", () => {
expect(process.exitCode).toBe(1);
});

it("leaves pre-existing user output untouched and removes the temp sibling when tar fails", () => {
tempDir = mkdtempSync(join(tmpdir(), "debug-test-"));
writeFileSync(join(tempDir, "payload.txt"), "test data");
outputDir = mkdtempSync(join(tmpdir(), "debug-test-out-"));
const output = join(outputDir, "partial.tar.gz");
// Pre-existing user file must NOT be clobbered when tar fails.
const previous = "pre-existing user content";
writeFileSync(output, previous);
// Removing the source dir forces tar to fail without racing in-progress
// collection.
rmSync(tempDir, { recursive: true, force: true });
const ok = createTarball(tempDir, output);
expect(ok).toBe(false);
expect(process.exitCode).toBe(1);
expect(existsSync(output)).toBe(true);
expect(readFileSync(output, "utf-8")).toBe(previous);
// No .partial sibling should remain after cleanup.
const partials = readdirSync(outputDir).filter(
(name) => name.endsWith(".partial") || name.includes(".partial."),
);
expect(partials).toEqual([]);
});

it("creates tarball successfully and returns true for valid output path", () => {
tempDir = mkdtempSync(join(tmpdir(), "debug-test-"));
writeFileSync(join(tempDir, "dummy.txt"), "test data");
Expand Down
35 changes: 9 additions & 26 deletions src/lib/diagnostics/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { platform, tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { join } from "node:path";

import { dockerExecFileSync } from "../adapters/docker/exec";
import { DASHBOARD_PORT } from "../core/ports";
import { listSandboxes } from "../state/registry";
import { createTarball as createDiagnosticsTarball } from "./tarball";

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -503,29 +504,8 @@ function collectKernelMessages(collectDir: string): void {
// Tarball
// ---------------------------------------------------------------------------

/**
* Archive the collected diagnostics into a tarball and print the sharing
* guidance that goes with the generated file.
*/
export function createTarball(collectDir: string, output: string): boolean {
const result = spawnSync("tar", ["czf", output, "-C", dirname(collectDir), basename(collectDir)], {
stdio: "inherit",
timeout: 60_000,
});
if (result.status !== 0 || result.signal) {
const reason = result.signal
? `killed by signal ${result.signal}`
: `exited with code ${result.status ?? "unknown"}`;
error(`Failed to create tarball at ${output} (tar ${reason})`);
process.exitCode = 1;
return false;
}
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.");
return true;
return createDiagnosticsTarball(collectDir, output, { info, warn, error });
}

/**
Expand Down Expand Up @@ -558,9 +538,12 @@ export function runDebug(opts: DebugOptions = {}): void {
// Compiled location: dist/lib/diagnostics/debug.js → repo root is 3 levels up
const repoDir = join(__dirname, "..", "..", "..");

// Resolve sandbox name
let sandboxName =
opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "";
// Resolve sandbox name. The CLI wrapper (runDebugCommandWithOptions) is the
// sole supported caller; it already trims, validates, and applies the
// documented precedence (--sandbox > NEMOCLAW_SANDBOX_NAME > NEMOCLAW_SANDBOX
// > SANDBOX_NAME) before calling here. Reading env again would let
// whitespace-only values bypass validation, so only trim the option.
let sandboxName = opts.sandboxName?.trim() ?? "";
if (!sandboxName) {
sandboxName = detectSandboxName();
}
Expand Down
70 changes: 70 additions & 0 deletions src/lib/diagnostics/tarball.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import { renameSync, rmSync } from "node:fs";
import { basename, dirname } from "node:path";

export interface CreateTarballOptions {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
/** Timeout for the underlying `tar` invocation. Defaults to 60 seconds. */
timeoutMs?: number;
}

/**
* Archive `collectDir` into a tarball at `output`. Writes to a sibling
* `.partial.<pid>` path and renames atomically on success so a pre-existing
* file at `output` is preserved when `tar` fails. Sets `process.exitCode = 1`
* on failure so callers do not have to remember.
*/
export function createTarball(
collectDir: string,
output: string,
options: CreateTarballOptions,
): boolean {
const { info, warn, error, timeoutMs = 60_000 } = options;
const partial = `${output}.partial.${process.pid}`;
const result = spawnSync(
"tar",
["czf", partial, "-C", dirname(collectDir), basename(collectDir)],
{
stdio: "inherit",
timeout: timeoutMs,
},
);
if (result.status !== 0 || result.signal) {
const reason = result.signal
? `killed by signal ${result.signal}`
: `exited with code ${result.status ?? "unknown"}`;
error(`Failed to create tarball at ${output} (tar ${reason})`);
try {
rmSync(partial, { force: true });
} catch {
/* best-effort cleanup of partial tarball */
}
process.exitCode = 1;
return false;
}
try {
renameSync(partial, output);
} catch (err) {
error(
`Failed to move tarball into place at ${output}: ${err instanceof Error ? err.message : String(err)}`,
);
try {
rmSync(partial, { force: true });
} catch {
/* best-effort */
}
process.exitCode = 1;
return false;
}
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.");
return true;
}
Loading
Loading