diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 1b058d80fe..3e3110e66d 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -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` diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 895ed2b600..ba1bfb36d6 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -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, }; } diff --git a/src/lib/diagnostics/debug-command.test.ts b/src/lib/diagnostics/debug-command.test.ts index c9cb8cf04c..2bb1ee1e13 100644 --- a/src/lib/diagnostics/debug-command.test.ts +++ b/src/lib/diagnostics/debug-command.test.ts @@ -12,6 +12,7 @@ describe("debug command", () => { { quick: true, output: "/tmp/out.tgz" }, { getDefaultSandbox: () => "alpha", + isSandboxKnown: () => true, runDebug, }, ); @@ -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" }); + }); }); diff --git a/src/lib/diagnostics/debug-command.ts b/src/lib/diagnostics/debug-command.ts index 56d3d1f7be..a186c62a9a 100644 --- a/src/lib/diagnostics/debug-command.ts +++ b/src/lib/diagnostics/debug-command.ts @@ -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); } diff --git a/src/lib/diagnostics/debug.test.ts b/src/lib/diagnostics/debug.test.ts index 6900a97ec6..32abd12e84 100644 --- a/src/lib/diagnostics/debug.test.ts +++ b/src/lib/diagnostics/debug.test.ts @@ -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"; @@ -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"); diff --git a/src/lib/diagnostics/debug.ts b/src/lib/diagnostics/debug.ts index cde7ea9a8a..a4e697e317 100644 --- a/src/lib/diagnostics/debug.ts +++ b/src/lib/diagnostics/debug.ts @@ -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 @@ -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 }); } /** @@ -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(); } diff --git a/src/lib/diagnostics/tarball.ts b/src/lib/diagnostics/tarball.ts new file mode 100644 index 0000000000..15f29379e3 --- /dev/null +++ b/src/lib/diagnostics/tarball.ts @@ -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.` 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; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 5499e61f84..094bff251e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -279,17 +279,42 @@ function createCloudflaredServiceDir(prefix: string): { sandboxName: string; ser return { sandboxName, serviceDir }; } -function createDebugCommandTestEnv(prefix: string): Record { +function createDebugCommandTestEnv( + prefix: string, + options: { extraSandboxNames?: string[] } = {}, +): Record { const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); const localBin = path.join(home, "bin"); const sandboxName = `${prefix}${process.pid.toString(36)}-${Date.now().toString(36)}`; fs.mkdirSync(localBin, { recursive: true }); + // Register the env-sourced sandbox plus any extra names supplied via the + // --sandbox flag so the validation gate accepts them. + writeSandboxRegistry(home, sandboxName); + if (options.extraSandboxNames && options.extraSandboxNames.length > 0) { + const registryPath = path.join(home, ".nemoclaw", "sandboxes.json"); + const current = JSON.parse(fs.readFileSync(registryPath, "utf-8")) as { + sandboxes: Record; + defaultSandbox?: string | null; + }; + for (const extra of options.extraSandboxNames) { + current.sandboxes[extra] = { + name: extra, + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }; + } + fs.writeFileSync(registryPath, JSON.stringify(current), { mode: 0o600 }); + } + const registeredNames = [sandboxName, ...(options.extraSandboxNames ?? [])]; + const listLines = ["NAME", ...registeredNames.map((name) => `${name} Ready`)]; fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/bin/sh", 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', - " echo 'NAME'", + ...listLines.map((line) => ` echo ${JSON.stringify(line)}`), " exit 0", "fi", "echo 'openshell ok'", @@ -1489,13 +1514,68 @@ describe("CLI dispatch", () => { it("debug --sandbox NAME targets the specified sandbox", testTimeoutOptions(30_000), () => { const r = runWithEnv( "debug --quick --sandbox mybox", - createDebugCommandTestEnv("nemoclaw-cli-debug-sandbox-"), + createDebugCommandTestEnv("nemoclaw-cli-debug-sandbox-", { extraSandboxNames: ["mybox"] }), 30000, ); expect(r.code).toBe(0); expect(r.out).toContain("Collecting diagnostics for sandbox 'mybox'"); }); + it("debug --sandbox NAME rejects an unregistered name and exits non-zero", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-debug-unknown-")); + writeSandboxRegistry(home); + const tarball = path.join(home, "out.tar.gz"); + const r = runWithEnv( + `debug --sandbox does-not-exist --output ${tarball} 2>&1`, + { HOME: home }, + 30000, + ); + expect(r.code).not.toBe(0); + expect(r.out).toContain("does-not-exist"); + expect(r.out).toContain("not registered"); + expect(fs.existsSync(tarball)).toBe(false); + }); + + it( + "debug --sandbox NAME rejects a stale registry entry missing from the live gateway", + testTimeoutOptions(30_000), + () => { + // Same fixture pattern as createDebugCommandTestEnv but with an openshell + // stub whose live list intentionally omits the registry name, mirroring + // the bug where the local registry kept a name the gateway no longer + // serves. + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-debug-stale-")); + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home, "stale-box"); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + " echo 'NAME'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + const tarball = path.join(home, "out.tar.gz"); + const r = runWithEnv( + `debug --sandbox stale-box --output ${tarball} 2>&1`, + { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }, + 30000, + ); + expect(r.code).not.toBe(0); + expect(r.out).toContain("stale-box"); + expect(r.out).toContain("not registered"); + expect(fs.existsSync(tarball)).toBe(false); + }, + ); + it("debug --sandbox without a name exits 1", () => { const r = run("debug --sandbox"); expect(r.code).not.toBe(0); @@ -1522,10 +1602,42 @@ describe("CLI dispatch", () => { fs.mkdirSync(path.join(home, ".nemoclaw"), { recursive: true }); fs.writeFileSync( path.join(home, ".nemoclaw", "sandboxes.json"), - JSON.stringify({ sandboxes: {}, defaultSandbox: "ghost" }), + JSON.stringify({ + sandboxes: { + mybox: { + name: "mybox", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "ghost", + }), { mode: 0o600 }, ); - const r = runWithEnv("debug --quick --sandbox mybox 2>&1", { HOME: home }, 30000); + // Fake openshell so the live-list check sees `mybox`. Without this the + // host's real openshell (or absence thereof) decides the assertion. + const localBin = path.join(home, "bin"); + fs.mkdirSync(localBin, { recursive: true }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/bin/sh", + 'if [ "$1" = "sandbox" ] && [ "$2" = "list" ]; then', + " echo 'NAME'", + " echo 'mybox Ready'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + const r = runWithEnv( + "debug --quick --sandbox mybox 2>&1", + { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, + 30000, + ); expect(r.code).toBe(0); expect(r.out).not.toContain("default sandbox 'ghost'"); expect(r.out).not.toContain("--sandbox NAME"); diff --git a/test/e2e/test-diagnostics.sh b/test/e2e/test-diagnostics.sh index b9726adaac..9109f6d9ab 100755 --- a/test/e2e/test-diagnostics.sh +++ b/test/e2e/test-diagnostics.sh @@ -10,6 +10,7 @@ # TC-DIAG-04: nemoclaw --version (semver output, exit 0) # TC-DIAG-02: nemoclaw debug --quick (fast, non-empty archive) # TC-DIAG-01: nemoclaw debug --output (tarball, no credentials in archive) +# TC-DIAG-06: nemoclaw debug --sandbox rejected; registered name accepted # TC-DIAG-05: /nemoclaw status inside sandbox (model + provider) # TC-DIAG-03: credentials list (no values) + credentials reset # @@ -292,6 +293,65 @@ test_diag_01_debug_tarball() { rm -rf "$debug_dir" } +# ============================================================================= +# TC-DIAG-06: debug --sandbox NAME validation +# Registered names succeed; unknown names exit non-zero, name the sandbox, +# and leave no partial tarball. +# ============================================================================= +test_diag_06_debug_sandbox_validation() { + log "=== TC-DIAG-06: debug --sandbox NAME validation ===" + + local debug_dir + debug_dir=$(mktemp -d) + + local good_output="${debug_dir}/known.tar.gz" + local good_rc=0 good_log="" + good_log=$(${TIMEOUT_CMD:+$TIMEOUT_CMD 30} nemoclaw debug --quick --sandbox "$SANDBOX_NAME" --output "$good_output" 2>&1) || good_rc=$? + log " Registered name exit=$good_rc" + if [[ $good_rc -eq 0 ]] && [[ -s "$good_output" ]]; then + pass "TC-DIAG-06: Registered --sandbox produced non-empty archive" + else + fail "TC-DIAG-06: Registered name" "exit=$good_rc, output=${good_log:0:300}" + fi + + # Unique per-run name avoids collisions when another e2e job leaves a + # sandbox with a shared "does-not-exist" placeholder behind. + local bad_name + bad_name="nemoclaw-e2e-missing-$$-$(date +%s)-${RANDOM}" + local bad_output="${debug_dir}/unknown.tar.gz" + local bad_rc=0 bad_log="" + bad_log=$(${TIMEOUT_CMD:+$TIMEOUT_CMD 30} nemoclaw debug --quick --sandbox "$bad_name" --output "$bad_output" 2>&1) || bad_rc=$? + log " Unknown name exit=$bad_rc" + + if [[ $bad_rc -ne 0 ]]; then + pass "TC-DIAG-06: Unknown --sandbox exits non-zero" + else + fail "TC-DIAG-06: Unknown name exit code" "expected non-zero, got 0" + fi + + if echo "$bad_log" | grep -q "$bad_name"; then + pass "TC-DIAG-06: Error message names the unknown sandbox" + else + fail "TC-DIAG-06: Error message" "did not mention '$bad_name'" + fi + + if echo "$bad_log" | grep -qi "not registered"; then + pass "TC-DIAG-06: Error message reports 'not registered'" + else + fail "TC-DIAG-06: Error message" "missing 'not registered' guidance" + fi + + if [[ ! -e "$bad_output" ]]; then + pass "TC-DIAG-06: No partial tarball written for unknown sandbox" + else + local size + size=$(stat -c '%s' "$bad_output" 2>/dev/null || stat -f '%z' "$bad_output" 2>/dev/null || echo "?") + fail "TC-DIAG-06: Tarball cleanup" "partial tarball persisted at $bad_output (${size} bytes)" + fi + + rm -rf "$debug_dir" +} + # ============================================================================= # TC-DIAG-05: Sandbox inference config visible inside sandbox # ============================================================================= @@ -440,6 +500,7 @@ main() { fi test_diag_01_debug_tarball + test_diag_06_debug_sandbox_validation test_diag_05_sandbox_config test_diag_03_credentials # modifies state — runs last