From d564ea2a2056488c9997c267e6db33c49286a623 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Tue, 31 Mar 2026 10:17:16 -0400 Subject: [PATCH 1/6] fix: recover logs and reboot cli flows --- bin/nemoclaw.js | 240 ++++++++++++++++++++++++++- test/cli.test.js | 413 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 644 insertions(+), 9 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index f587e88d6..0d7cb6f76 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -33,17 +33,20 @@ const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); const { parseGatewayInference } = require("./lib/inference-config"); +const onboardSession = require("./lib/onboard-session"); +const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); // ── Global commands ────────────────────────────────────────────── const GLOBAL_COMMANDS = new Set([ "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "debug", "uninstall", + "start", "stop", "status", "reconnect", "debug", "uninstall", "help", "--help", "-h", "--version", "-v", ]); const REMOTE_UNINSTALL_URL = "https://raw.githubusercontent.com/NVIDIA/NemoClaw/refs/heads/main/uninstall.sh"; let OPENSHELL_BIN = null; +const MIN_LOGS_OPENSHELL_VERSION = "0.0.7"; function getOpenshellBinary() { if (!OPENSHELL_BIN) { @@ -83,11 +86,145 @@ function captureOpenshell(args, opts = {}) { }; } +function parseVersionFromText(value = "") { + const match = String(value || "").match(/([0-9]+\.[0-9]+\.[0-9]+)/); + return match ? match[1] : null; +} + +function versionGte(left = "0.0.0", right = "0.0.0") { + const lhs = String(left).split(".").map((part) => Number.parseInt(part, 10) || 0); + const rhs = String(right).split(".").map((part) => Number.parseInt(part, 10) || 0); + const length = Math.max(lhs.length, rhs.length); + for (let index = 0; index < length; index += 1) { + const a = lhs[index] || 0; + const b = rhs[index] || 0; + if (a > b) return true; + if (a < b) return false; + } + return true; +} + +function getInstalledOpenshellVersion() { + const versionResult = captureOpenshell(["--version"], { ignoreError: true }); + return parseVersionFromText(versionResult.output); +} + function stripAnsi(value = "") { // eslint-disable-next-line no-control-regex return String(value).replace(/\x1b\[[0-9;]*m/g, ""); } +function buildRecoveredSandboxEntry(name, metadata = {}) { + return { + name, + model: metadata.model || null, + provider: metadata.provider || null, + gpuEnabled: metadata.gpuEnabled === true, + policies: Array.isArray(metadata.policies) ? metadata.policies : [], + nimContainer: metadata.nimContainer || null, + }; +} + +function upsertRecoveredSandbox(name, metadata = {}) { + const entry = buildRecoveredSandboxEntry(name, metadata); + if (registry.getSandbox(name)) { + registry.updateSandbox(name, entry); + return false; + } + registry.registerSandbox(entry); + return true; +} + +function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { + const missingRequestedSandbox = + Boolean(requestedSandboxName) && + !current.sandboxes.some((sandbox) => sandbox.name === requestedSandboxName); + const hasRecoverySeed = current.sandboxes.length > 0 || Boolean(session?.sandboxName) || Boolean(requestedSandboxName); + return { + missingRequestedSandbox, + shouldRecover: hasRecoverySeed && (current.sandboxes.length === 0 || missingRequestedSandbox), + }; +} + +function seedRecoveryMetadata(current, session, requestedSandboxName) { + const metadataByName = new Map(current.sandboxes.map((sandbox) => [sandbox.name, sandbox])); + let recoveredFromSession = false; + + if (!session?.sandboxName) { + return { metadataByName, recoveredFromSession }; + } + + metadataByName.set( + session.sandboxName, + buildRecoveredSandboxEntry(session.sandboxName, { + model: session.model || null, + provider: session.provider || null, + nimContainer: session.nimContainer || null, + }) + ); + const shouldRecoverSessionSandbox = + current.sandboxes.length === 0 || requestedSandboxName === session.sandboxName; + if (shouldRecoverSessionSandbox) { + recoveredFromSession = upsertRecoveredSandbox(session.sandboxName, metadataByName.get(session.sandboxName)); + } + return { metadataByName, recoveredFromSession }; +} + +async function recoverRegistryFromLiveGateway(metadataByName) { + if (!resolveOpenshell()) { + return 0; + } + const recovery = await recoverNamedGatewayRuntime(); + const canInspectLiveGateway = + recovery.recovered || + recovery.before?.state === "healthy_named" || + recovery.after?.state === "healthy_named"; + if (!canInspectLiveGateway) { + return 0; + } + + let recoveredFromGateway = 0; + const liveList = captureOpenshell(["sandbox", "list"], { ignoreError: true }); + const liveNames = Array.from(parseLiveSandboxNames(liveList.output)); + for (const name of liveNames) { + const metadata = metadataByName.get(name) || {}; + if (upsertRecoveredSandbox(name, metadata)) { + recoveredFromGateway += 1; + } + } + return recoveredFromGateway; +} + +function applyRecoveredDefault(requestedSandboxName, session) { + const recovered = registry.listSandboxes(); + const preferredDefault = requestedSandboxName || session?.sandboxName || null; + if (preferredDefault && recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault)) { + registry.setDefault(preferredDefault); + } + return registry.listSandboxes(); +} + +async function recoverRegistryEntries({ requestedSandboxName = null } = {}) { + const current = registry.listSandboxes(); + const session = onboardSession.loadSession(); + const recoveryCheck = shouldRecoverRegistryEntries(current, session, requestedSandboxName); + if (!recoveryCheck.shouldRecover) { + return { ...current, recoveredFromSession: false, recoveredFromGateway: 0 }; + } + + const seeded = seedRecoveryMetadata(current, session, requestedSandboxName); + const shouldProbeLiveGateway = current.sandboxes.length > 0 || Boolean(session?.sandboxName); + const recoveredFromGateway = shouldProbeLiveGateway + ? await recoverRegistryFromLiveGateway(seeded.metadataByName) + : 0; + const recovered = applyRecoveredDefault(requestedSandboxName, session); + return { + ...recovered, + recoveredFromSession: seeded.recoveredFromSession, + recoveredFromGateway, + }; +} + function hasNamedGateway(output = "") { return stripAnsi(output).includes("Gateway: nemoclaw"); } @@ -302,6 +439,13 @@ async function ensureLiveSandboxOrExit(sandboxName) { process.exit(1); } +function printOldLogsCompatibilityGuidance(installedVersion = null) { + const versionText = installedVersion ? ` (${installedVersion})` : ""; + console.error(` Installed OpenShell${versionText} is too old or incompatible with \`nemoclaw logs\`.`); + console.error(` NemoClaw expects \`openshell logs \` and live streaming via \`--tail\`.`); + console.error(" Upgrade OpenShell by rerunning `nemoclaw onboard`, or reinstall the OpenShell CLI and try again."); +} + function resolveUninstallScript() { const candidates = [ path.join(ROOT, "uninstall.sh"), @@ -551,11 +695,18 @@ function showStatus() { run(`bash "${SCRIPTS}/start-services.sh" --status`); } -function listSandboxes() { - const { sandboxes, defaultSandbox } = registry.listSandboxes(); +async function listSandboxes() { + const recovery = await recoverRegistryEntries(); + const { sandboxes, defaultSandbox } = recovery; if (sandboxes.length === 0) { console.log(""); - console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); + const session = onboardSession.loadSession(); + if (session?.sandboxName) { + console.log(` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`); + console.log(" Retry `nemoclaw reconnect` or `nemoclaw status` once the gateway/runtime is healthy."); + } else { + console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); + } console.log(""); return; } @@ -566,6 +717,14 @@ function listSandboxes() { ); console.log(""); + if (recovery.recoveredFromSession) { + console.log(" Recovered sandbox inventory from the last onboard session."); + console.log(""); + } + if (recovery.recoveredFromGateway > 0) { + console.log(` Recovered ${recovery.recoveredFromGateway} sandbox entr${recovery.recoveredFromGateway === 1 ? "y" : "ies"} from the live OpenShell gateway.`); + console.log(""); + } console.log(" Sandboxes:"); for (const sb of sandboxes) { const def = sb.name === defaultSandbox ? " *" : ""; @@ -581,6 +740,26 @@ function listSandboxes() { console.log(""); } +function resolveReconnectSandboxName(requestedName) { + const sandboxName = requestedName || registry.getDefault() || onboardSession.loadSession()?.sandboxName || null; + if (!sandboxName) { + console.error(" No sandbox registered. Run `nemoclaw onboard` to create one first."); + process.exit(1); + } + validateName(sandboxName, "sandbox name"); + + if (requestedName) { + const existingSandbox = registry.getSandbox(sandboxName); + const sessionSandbox = onboardSession.loadSession()?.sandboxName || null; + if (!existingSandbox && sessionSandbox !== sandboxName) { + console.error(` Unknown sandbox '${sandboxName}'.`); + console.error(" Use `nemoclaw list` to view registered sandboxes."); + process.exit(1); + } + } + return sandboxName; +} + // ── Sandbox-scoped actions ─────────────────────────────────────── async function sandboxConnect(sandboxName) { @@ -664,9 +843,54 @@ async function sandboxStatus(sandboxName) { } function sandboxLogs(sandboxName, follow) { + const installedVersion = getInstalledOpenshellVersion(); + if (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) { + printOldLogsCompatibilityGuidance(installedVersion); + process.exit(1); + } + const args = ["logs", sandboxName]; - if (follow) args.push("--follow"); - runOpenshell(args); + if (follow) args.push("--tail"); + const result = spawnSync(getOpenshellBinary(), args, { + cwd: ROOT, + env: process.env, + encoding: "utf-8", + stdio: follow ? ["ignore", "inherit", "pipe"] : ["ignore", "pipe", "pipe"], + }); + const stdout = String(result.stdout || ""); + const stderr = String(result.stderr || ""); + const combined = `${stdout}${stderr}`; + if (!follow && stdout) { + process.stdout.write(stdout); + } + if (result.status === 0) { + return; + } + if (stderr) { + process.stderr.write(stderr); + } + if ( + /unrecognized subcommand 'logs'|unexpected argument '--tail'|unexpected argument '--follow'/i.test(combined) || + (installedVersion && !versionGte(installedVersion, MIN_LOGS_OPENSHELL_VERSION)) + ) { + printOldLogsCompatibilityGuidance(installedVersion); + process.exit(1); + } + console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); + process.exit(result.status || 1); +} + +async function reconnect(args = []) { + if (args.length > 1) { + console.error(" Too many positional arguments for `reconnect`."); + console.error(" Usage: `nemoclaw reconnect [sandbox-name]`."); + process.exit(1); + } + const requestedName = args[0] || null; + await recoverRegistryEntries({ requestedSandboxName: requestedName }); + const sandboxName = resolveReconnectSandboxName(requestedName); + console.log(` Reconnecting to sandbox '${sandboxName}'...`); + await sandboxConnect(sandboxName); } async function sandboxPolicyAdd(sandboxName) { @@ -743,6 +967,7 @@ function help() { ${G}Sandbox Management:${R} ${B}nemoclaw list${R} List all sandboxes + nemoclaw reconnect ${D}[name]${R} Recover the default or named sandbox after a reboot nemoclaw connect Shell into a running sandbox nemoclaw status Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs @@ -800,9 +1025,10 @@ const [cmd, ...args] = process.argv.slice(2); case "start": await start(); break; case "stop": stop(); break; case "status": showStatus(); break; + case "reconnect": await reconnect(args); break; case "debug": debug(args); break; case "uninstall": uninstall(args); break; - case "list": listSandboxes(); break; + case "list": await listSandboxes(); break; case "--version": case "-v": { const pkg = require(path.join(__dirname, "..", "package.json")); diff --git a/test/cli.test.js b/test/cli.test.js index fc69ac155..2c528f4ad 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -104,7 +104,7 @@ describe("CLI dispatch", () => { expect(r.out.includes("nemoclaw debug")).toBeTruthy(); }); - it("passes --follow through to openshell logs", () => { + it("maps --follow to openshell --tail", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-follow-")); const localBin = path.join(home, "bin"); const registryDir = path.join(home, ".nemoclaw"); @@ -144,7 +144,107 @@ describe("CLI dispatch", () => { }); expect(r.code).toBe(0); - expect(fs.readFileSync(markerFile, "utf8")).toContain("logs alpha --follow"); + expect(fs.readFileSync(markerFile, "utf8")).toContain("logs alpha --tail"); + }); + + it("passes plain logs through without the tail flag", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-plain-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "logs-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "printf '%s ' \"$@\" > \"$marker_file\"", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("alpha logs", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(fs.readFileSync(markerFile, "utf8")).toContain("logs alpha"); + expect(fs.readFileSync(markerFile, "utf8")).not.toContain("--tail"); + }); + + it("prints upgrade guidance when openshell is too old for nemoclaw logs", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-old-openshell-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.4'", + " exit 0", + "fi", + "echo \"error: unrecognized subcommand 'logs'\" >&2", + "exit 2", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("alpha logs --follow", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("too old or incompatible with `nemoclaw logs`")).toBeTruthy(); + expect(r.out.includes("Upgrade OpenShell by rerunning `nemoclaw onboard`")).toBeTruthy(); + }); + + it("help mentions reconnect command", () => { + const r = run("help"); + expect(r.code).toBe(0); + expect(r.out.includes("nemoclaw reconnect")).toBeTruthy(); }); it("connect does not pre-start a duplicate port forward", () => { @@ -251,6 +351,315 @@ describe("CLI dispatch", () => { expect(saved.sandboxes.alpha).toBeUndefined(); }); + it("recovers a missing registry entry from the last onboard session during list", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-session-recover-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify({ + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, null, 2), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "if [ \"$1\" = \"status\" ]; then", + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + " echo 'No sandboxes found.'", + " exit 0", + "fi", + "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Recovered sandbox inventory from the last onboard session.")).toBeTruthy(); + expect(r.out.includes("alpha")).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + expect(saved.defaultSandbox).toBe("alpha"); + }); + + it("imports additional live sandboxes into the registry during list recovery", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-live-recover-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify({ + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, null, 2), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "if [ \"$1\" = \"status\" ]; then", + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + " echo 'NAME PHASE'", + " echo 'alpha Ready'", + " echo 'beta Ready'", + " exit 0", + "fi", + "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Recovered sandbox inventory from the last onboard session.")).toBeTruthy(); + expect(r.out.includes("Recovered 1 sandbox entry from the live OpenShell gateway.")).toBeTruthy(); + expect(r.out.includes("alpha")).toBeTruthy(); + expect(r.out.includes("beta")).toBeTruthy(); + const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + expect(saved.sandboxes.beta).toBeTruthy(); + }); + + it("reconnect uses the last onboard session when the registry is empty", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-session-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + const markerFile = path.join(home, "reconnect-args"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify({ + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, null, 2), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `marker_file=${JSON.stringify(markerFile)}`, + "printf '%s\\n' \"$*\" >> \"$marker_file\"", + "if [ \"$1\" = \"status\" ]; then", + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + " echo 'No sandboxes found.'", + " exit 0", + "fi", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"get\" ] && [ \"$3\" = \"alpha\" ]; then", + " echo 'Sandbox:'", + " echo", + " echo ' Id: abc'", + " echo ' Name: alpha'", + " echo ' Namespace: openshell'", + " echo ' Phase: Ready'", + " exit 0", + "fi", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"connect\" ] && [ \"$3\" = \"alpha\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); + const log = fs.readFileSync(markerFile, "utf8"); + expect(log.includes("sandbox get alpha")).toBeTruthy(); + expect(log.includes("sandbox connect alpha")).toBeTruthy(); + }); + + it("reconnect rejects too many sandbox arguments", () => { + const r = run("reconnect alpha beta"); + expect(r.code).toBe(1); + expect(r.out.includes("Too many positional arguments for `reconnect`.")).toBeTruthy(); + expect(r.out.includes("Usage: `nemoclaw reconnect [sandbox-name]`.")).toBeTruthy(); + }); + + it("reconnect rejects an explicit unknown sandbox name", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-unknown-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("reconnect beta", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(1); + expect(r.out.includes("Unknown sandbox 'beta'.")).toBeTruthy(); + expect(r.out.includes("Use `nemoclaw list` to view registered sandboxes.")).toBeTruthy(); + }); + it("keeps registry entries when status hits a gateway-level transport error", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-error-")); const localBin = path.join(home, "bin"); From afbd703ae882bb69704c2038e1f2a58279cfb015 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Tue, 31 Mar 2026 10:53:28 -0400 Subject: [PATCH 2/6] fix: preserve recovered sandbox metadata and log exits --- bin/nemoclaw.js | 31 +++++++++++----- test/cli.test.js | 92 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 0d7cb6f76..5603a4f41 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -120,7 +120,11 @@ function buildRecoveredSandboxEntry(name, metadata = {}) { model: metadata.model || null, provider: metadata.provider || null, gpuEnabled: metadata.gpuEnabled === true, - policies: Array.isArray(metadata.policies) ? metadata.policies : [], + policies: Array.isArray(metadata.policies) + ? metadata.policies + : Array.isArray(metadata.policyPresets) + ? metadata.policyPresets + : [], nimContainer: metadata.nimContainer || null, }; } @@ -136,13 +140,19 @@ function upsertRecoveredSandbox(name, metadata = {}) { } function shouldRecoverRegistryEntries(current, session, requestedSandboxName) { + const hasSessionSandbox = Boolean(session?.sandboxName); + const missingSessionSandbox = + hasSessionSandbox && + !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); const missingRequestedSandbox = Boolean(requestedSandboxName) && !current.sandboxes.some((sandbox) => sandbox.name === requestedSandboxName); - const hasRecoverySeed = current.sandboxes.length > 0 || Boolean(session?.sandboxName) || Boolean(requestedSandboxName); + const hasRecoverySeed = current.sandboxes.length > 0 || hasSessionSandbox || Boolean(requestedSandboxName); return { missingRequestedSandbox, - shouldRecover: hasRecoverySeed && (current.sandboxes.length === 0 || missingRequestedSandbox), + shouldRecover: + hasRecoverySeed && + (current.sandboxes.length === 0 || missingRequestedSandbox || missingSessionSandbox), }; } @@ -160,10 +170,12 @@ function seedRecoveryMetadata(current, session, requestedSandboxName) { model: session.model || null, provider: session.provider || null, nimContainer: session.nimContainer || null, + policyPresets: session.policyPresets || null, }) ); + const sessionSandboxMissing = !current.sandboxes.some((sandbox) => sandbox.name === session.sandboxName); const shouldRecoverSessionSandbox = - current.sandboxes.length === 0 || requestedSandboxName === session.sandboxName; + current.sandboxes.length === 0 || sessionSandboxMissing || requestedSandboxName === session.sandboxName; if (shouldRecoverSessionSandbox) { recoveredFromSession = upsertRecoveredSandbox(session.sandboxName, metadataByName.get(session.sandboxName)); } @@ -195,9 +207,9 @@ async function recoverRegistryFromLiveGateway(metadataByName) { return recoveredFromGateway; } -function applyRecoveredDefault(requestedSandboxName, session) { +function applyRecoveredDefault(currentDefaultSandbox, requestedSandboxName, session) { const recovered = registry.listSandboxes(); - const preferredDefault = requestedSandboxName || session?.sandboxName || null; + const preferredDefault = requestedSandboxName || (!currentDefaultSandbox ? session?.sandboxName || null : null); if (preferredDefault && recovered.sandboxes.some((sandbox) => sandbox.name === preferredDefault)) { registry.setDefault(preferredDefault); } @@ -217,7 +229,7 @@ async function recoverRegistryEntries({ requestedSandboxName = null } = {}) { const recoveredFromGateway = shouldProbeLiveGateway ? await recoverRegistryFromLiveGateway(seeded.metadataByName) : 0; - const recovered = applyRecoveredDefault(requestedSandboxName, session); + const recovered = applyRecoveredDefault(current.defaultSandbox, requestedSandboxName, session); return { ...recovered, recoveredFromSession: seeded.recoveredFromSession, @@ -876,8 +888,11 @@ function sandboxLogs(sandboxName, follow) { printOldLogsCompatibilityGuidance(installedVersion); process.exit(1); } + if (result.status === null || result.signal) { + exitWithSpawnResult(result); + } console.error(` Command failed (exit ${result.status}): openshell ${args.join(" ")}`); - process.exit(result.status || 1); + exitWithSpawnResult(result); } async function reconnect(args = []) { diff --git a/test/cli.test.js b/test/cli.test.js index 2c528f4ad..998ae3ba2 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { execSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -145,6 +145,7 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(fs.readFileSync(markerFile, "utf8")).toContain("logs alpha --tail"); + expect(fs.readFileSync(markerFile, "utf8")).not.toContain("--follow"); }); it("passes plain logs through without the tail flag", () => { @@ -357,6 +358,22 @@ describe("CLI dispatch", () => { const nemoclawDir = path.join(home, ".nemoclaw"); fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + gamma: { + name: "gamma", + model: "existing-model", + provider: "existing-provider", + gpuEnabled: false, + policies: ["npm"], + }, + }, + defaultSandbox: "gamma", + }), + { mode: 0o600 } + ); fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), JSON.stringify({ @@ -377,7 +394,7 @@ describe("CLI dispatch", () => { credentialEnv: null, preferredInferenceApi: null, nimContainer: null, - policyPresets: null, + policyPresets: ["pypi"], metadata: { gatewayName: "nemoclaw" }, steps: { preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -432,9 +449,12 @@ describe("CLI dispatch", () => { expect(r.code).toBe(0); expect(r.out.includes("Recovered sandbox inventory from the last onboard session.")).toBeTruthy(); expect(r.out.includes("alpha")).toBeTruthy(); + expect(r.out.includes("gamma")).toBeTruthy(); const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); expect(saved.sandboxes.alpha).toBeTruthy(); - expect(saved.defaultSandbox).toBe("alpha"); + expect(saved.sandboxes.alpha.policies).toEqual(["pypi"]); + expect(saved.sandboxes.gamma).toBeTruthy(); + expect(saved.defaultSandbox).toBe("gamma"); }); it("imports additional live sandboxes into the registry during list recovery", () => { @@ -443,6 +463,22 @@ describe("CLI dispatch", () => { const nemoclawDir = path.join(home, ".nemoclaw"); fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + gamma: { + name: "gamma", + model: "existing-model", + provider: "existing-provider", + gpuEnabled: false, + policies: ["npm"], + }, + }, + defaultSandbox: "gamma", + }), + { mode: 0o600 } + ); fs.writeFileSync( path.join(nemoclawDir, "onboard-session.json"), JSON.stringify({ @@ -463,7 +499,7 @@ describe("CLI dispatch", () => { credentialEnv: null, preferredInferenceApi: null, nimContainer: null, - policyPresets: null, + policyPresets: ["pypi"], metadata: { gatewayName: "nemoclaw" }, steps: { preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, @@ -522,9 +558,13 @@ describe("CLI dispatch", () => { expect(r.out.includes("Recovered 1 sandbox entry from the live OpenShell gateway.")).toBeTruthy(); expect(r.out.includes("alpha")).toBeTruthy(); expect(r.out.includes("beta")).toBeTruthy(); + expect(r.out.includes("gamma")).toBeTruthy(); const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); expect(saved.sandboxes.alpha).toBeTruthy(); + expect(saved.sandboxes.alpha.policies).toEqual(["pypi"]); expect(saved.sandboxes.beta).toBeTruthy(); + expect(saved.sandboxes.gamma).toBeTruthy(); + expect(saved.defaultSandbox).toBe("gamma"); }); it("reconnect uses the last onboard session when the registry is empty", () => { @@ -660,6 +700,50 @@ describe("CLI dispatch", () => { expect(r.out.includes("Use `nemoclaw list` to view registered sandboxes.")).toBeTruthy(); }); + it("preserves SIGINT exit semantics for logs --follow", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-logs-sigint-")); + const localBin = path.join(home, "bin"); + const registryDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + alpha: { + name: "alpha", + model: "test-model", + provider: "nvidia-prod", + gpuEnabled: false, + policies: [], + }, + }, + defaultSandbox: "alpha", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "kill -INT $$", + ].join("\n"), + { mode: 0o755 } + ); + + const result = spawnSync(process.execPath, [CLI, "alpha", "logs", "--follow"], { + cwd: path.join(import.meta.dirname, ".."), + encoding: "utf-8", + env: { ...process.env, HOME: home, PATH: `${localBin}:${process.env.PATH || ""}` }, + }); + + expect(result.status).toBe(130); + }); + it("keeps registry entries when status hits a gateway-level transport error", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-gateway-error-")); const localBin = path.join(home, "bin"); From 5191d0d5fb1f1d701a963512582a7755c47cdfde Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Tue, 31 Mar 2026 12:04:45 -0400 Subject: [PATCH 3/6] fix(cli): recover named sandbox connects without reconnect --- bin/nemoclaw.js | 48 ++++++-------------------- test/cli.test.js | 88 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 74 insertions(+), 62 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 5603a4f41..9428b5b65 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -40,7 +40,7 @@ const { parseLiveSandboxNames } = require("./lib/runtime-recovery"); const GLOBAL_COMMANDS = new Set([ "onboard", "list", "deploy", "setup", "setup-spark", - "start", "stop", "status", "reconnect", "debug", "uninstall", + "start", "stop", "status", "debug", "uninstall", "help", "--help", "-h", "--version", "-v", ]); @@ -715,7 +715,7 @@ async function listSandboxes() { const session = onboardSession.loadSession(); if (session?.sandboxName) { console.log(` No sandboxes registered locally, but the last onboarded sandbox was '${session.sandboxName}'.`); - console.log(" Retry `nemoclaw reconnect` or `nemoclaw status` once the gateway/runtime is healthy."); + console.log(" Retry `nemoclaw connect` or `nemoclaw status` once the gateway/runtime is healthy."); } else { console.log(" No sandboxes registered. Run `nemoclaw onboard` to get started."); } @@ -752,26 +752,6 @@ async function listSandboxes() { console.log(""); } -function resolveReconnectSandboxName(requestedName) { - const sandboxName = requestedName || registry.getDefault() || onboardSession.loadSession()?.sandboxName || null; - if (!sandboxName) { - console.error(" No sandbox registered. Run `nemoclaw onboard` to create one first."); - process.exit(1); - } - validateName(sandboxName, "sandbox name"); - - if (requestedName) { - const existingSandbox = registry.getSandbox(sandboxName); - const sessionSandbox = onboardSession.loadSession()?.sandboxName || null; - if (!existingSandbox && sessionSandbox !== sandboxName) { - console.error(` Unknown sandbox '${sandboxName}'.`); - console.error(" Use `nemoclaw list` to view registered sandboxes."); - process.exit(1); - } - } - return sandboxName; -} - // ── Sandbox-scoped actions ─────────────────────────────────────── async function sandboxConnect(sandboxName) { @@ -895,19 +875,6 @@ function sandboxLogs(sandboxName, follow) { exitWithSpawnResult(result); } -async function reconnect(args = []) { - if (args.length > 1) { - console.error(" Too many positional arguments for `reconnect`."); - console.error(" Usage: `nemoclaw reconnect [sandbox-name]`."); - process.exit(1); - } - const requestedName = args[0] || null; - await recoverRegistryEntries({ requestedSandboxName: requestedName }); - const sandboxName = resolveReconnectSandboxName(requestedName); - console.log(` Reconnecting to sandbox '${sandboxName}'...`); - await sandboxConnect(sandboxName); -} - async function sandboxPolicyAdd(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -982,7 +949,6 @@ function help() { ${G}Sandbox Management:${R} ${B}nemoclaw list${R} List all sandboxes - nemoclaw reconnect ${D}[name]${R} Recover the default or named sandbox after a reboot nemoclaw connect Shell into a running sandbox nemoclaw status Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs @@ -1040,7 +1006,6 @@ const [cmd, ...args] = process.argv.slice(2); case "start": await start(); break; case "stop": stop(); break; case "status": showStatus(); break; - case "reconnect": await reconnect(args); break; case "debug": debug(args); break; case "uninstall": uninstall(args); break; case "list": await listSandboxes(); break; @@ -1077,6 +1042,15 @@ const [cmd, ...args] = process.argv.slice(2); return; } + if (args[0] === "connect") { + validateName(cmd, "sandbox name"); + await recoverRegistryEntries({ requestedSandboxName: cmd }); + if (registry.getSandbox(cmd)) { + await sandboxConnect(cmd); + return; + } + } + // Unknown command — suggest console.error(` Unknown command: ${cmd}`); console.error(""); diff --git a/test/cli.test.js b/test/cli.test.js index 998ae3ba2..4b5b06aa3 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -242,12 +242,6 @@ describe("CLI dispatch", () => { expect(r.out.includes("Upgrade OpenShell by rerunning `nemoclaw onboard`")).toBeTruthy(); }); - it("help mentions reconnect command", () => { - const r = run("help"); - expect(r.code).toBe(0); - expect(r.out.includes("nemoclaw reconnect")).toBeTruthy(); - }); - it("connect does not pre-start a duplicate port forward", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-forward-")); const localBin = path.join(home, "bin"); @@ -567,11 +561,11 @@ describe("CLI dispatch", () => { expect(saved.defaultSandbox).toBe("gamma"); }); - it("reconnect uses the last onboard session when the registry is empty", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-session-")); + it("connect recovers a named sandbox from the last onboard session when the registry is empty", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-recover-session-")); const localBin = path.join(home, "bin"); const nemoclawDir = path.join(home, ".nemoclaw"); - const markerFile = path.join(home, "reconnect-args"); + const markerFile = path.join(home, "connect-args"); fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(nemoclawDir, { recursive: true }); fs.writeFileSync( @@ -652,35 +646,79 @@ describe("CLI dispatch", () => { { mode: 0o755 } ); - const r = runWithEnv("reconnect", { + const r = runWithEnv("alpha connect", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }); expect(r.code).toBe(0); - expect(r.out.includes("Reconnecting to sandbox 'alpha'")).toBeTruthy(); const log = fs.readFileSync(markerFile, "utf8"); + expect(log.includes("sandbox list")).toBeTruthy(); expect(log.includes("sandbox get alpha")).toBeTruthy(); expect(log.includes("sandbox connect alpha")).toBeTruthy(); }); - it("reconnect rejects too many sandbox arguments", () => { - const r = run("reconnect alpha beta"); - expect(r.code).toBe(1); - expect(r.out.includes("Too many positional arguments for `reconnect`.")).toBeTruthy(); - expect(r.out.includes("Usage: `nemoclaw reconnect [sandbox-name]`.")).toBeTruthy(); - }); - - it("reconnect rejects an explicit unknown sandbox name", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-reconnect-unknown-")); + it("connect keeps the unknown command path when recovery cannot find the requested sandbox", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-unknown-after-recovery-")); const localBin = path.join(home, "bin"); - const registryDir = path.join(home, ".nemoclaw"); + const nemoclawDir = path.join(home, ".nemoclaw"); fs.mkdirSync(localBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify({ + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: null, + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, null, 2), + { mode: 0o600 } + ); fs.writeFileSync( path.join(localBin, "openshell"), [ "#!/usr/bin/env bash", + "if [ \"$1\" = \"status\" ]; then", + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + " echo 'No sandboxes found.'", + " exit 0", + "fi", "if [ \"$1\" = \"--version\" ]; then", " echo 'openshell 0.0.16'", " exit 0", @@ -690,14 +728,14 @@ describe("CLI dispatch", () => { { mode: 0o755 } ); - const r = runWithEnv("reconnect beta", { + const r = runWithEnv("beta connect", { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }); expect(r.code).toBe(1); - expect(r.out.includes("Unknown sandbox 'beta'.")).toBeTruthy(); - expect(r.out.includes("Use `nemoclaw list` to view registered sandboxes.")).toBeTruthy(); + expect(r.out.includes("Unknown command: beta")).toBeTruthy(); + expect(r.out.includes("Try: nemoclaw connect")).toBeTruthy(); }); it("preserves SIGINT exit semantics for logs --follow", () => { From 76b210f5ed4aebf46fbe619f1795117f2a6d473c Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Tue, 31 Mar 2026 13:47:50 -0400 Subject: [PATCH 4/6] fix(cli): validate recovered sandbox names --- bin/nemoclaw.js | 13 ++++-- test/cli.test.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 9428b5b65..81cc18b33 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -130,9 +130,16 @@ function buildRecoveredSandboxEntry(name, metadata = {}) { } function upsertRecoveredSandbox(name, metadata = {}) { - const entry = buildRecoveredSandboxEntry(name, metadata); - if (registry.getSandbox(name)) { - registry.updateSandbox(name, entry); + let validName; + try { + validName = validateName(name, "sandbox name"); + } catch { + return false; + } + + const entry = buildRecoveredSandboxEntry(validName, metadata); + if (registry.getSandbox(validName)) { + registry.updateSandbox(validName, entry); return false; } registry.registerSandbox(entry); diff --git a/test/cli.test.js b/test/cli.test.js index 4b5b06aa3..8b74ba0a7 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -561,6 +561,112 @@ describe("CLI dispatch", () => { expect(saved.defaultSandbox).toBe("gamma"); }); + it("skips invalid recovered sandbox names during list recovery", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-invalid-recover-")); + const localBin = path.join(home, "bin"); + const nemoclawDir = path.join(home, ".nemoclaw"); + fs.mkdirSync(localBin, { recursive: true }); + fs.mkdirSync(nemoclawDir, { recursive: true }); + fs.writeFileSync( + path.join(nemoclawDir, "sandboxes.json"), + JSON.stringify({ + sandboxes: { + gamma: { + name: "gamma", + model: "existing-model", + provider: "existing-provider", + gpuEnabled: false, + policies: ["npm"], + }, + }, + defaultSandbox: "gamma", + }), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(nemoclawDir, "onboard-session.json"), + JSON.stringify({ + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + mode: "interactive", + startedAt: "2026-03-31T00:00:00.000Z", + updatedAt: "2026-03-31T00:00:00.000Z", + lastStepStarted: "policies", + lastCompletedStep: "policies", + failure: null, + sandboxName: "Alpha", + provider: "nvidia-prod", + model: "nvidia/nemotron-3-super-120b-a12b", + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + nimContainer: null, + policyPresets: ["pypi"], + metadata: { gatewayName: "nemoclaw" }, + steps: { + preflight: { status: "complete", startedAt: null, completedAt: null, error: null }, + gateway: { status: "complete", startedAt: null, completedAt: null, error: null }, + sandbox: { status: "complete", startedAt: null, completedAt: null, error: null }, + provider_selection: { status: "complete", startedAt: null, completedAt: null, error: null }, + inference: { status: "complete", startedAt: null, completedAt: null, error: null }, + openclaw: { status: "complete", startedAt: null, completedAt: null, error: null }, + policies: { status: "complete", startedAt: null, completedAt: null, error: null }, + }, + }, null, 2), + { mode: 0o600 } + ); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + "if [ \"$1\" = \"status\" ]; then", + " echo 'Server Status'", + " echo", + " echo ' Gateway: nemoclaw'", + " echo ' Status: Connected'", + " exit 0", + "fi", + "if [ \"$1\" = \"gateway\" ] && [ \"$2\" = \"info\" ]; then", + " echo 'Gateway Info'", + " echo", + " echo ' Gateway: nemoclaw'", + " exit 0", + "fi", + "if [ \"$1\" = \"sandbox\" ] && [ \"$2\" = \"list\" ]; then", + " echo 'NAME PHASE'", + " echo 'alpha Ready'", + " echo 'Bad_Name Ready'", + " exit 0", + "fi", + "if [ \"$1\" = \"inference\" ] && [ \"$2\" = \"get\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"--version\" ]; then", + " echo 'openshell 0.0.16'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 } + ); + + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); + + expect(r.code).toBe(0); + expect(r.out.includes("alpha")).toBeTruthy(); + expect(r.out.includes("Bad_Name")).toBeFalsy(); + const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); + expect(saved.sandboxes.alpha).toBeTruthy(); + expect(saved.sandboxes.Bad_Name).toBeUndefined(); + expect(saved.sandboxes.Alpha).toBeUndefined(); + expect(saved.sandboxes.gamma).toBeTruthy(); + }); + it("connect recovers a named sandbox from the last onboard session when the registry is empty", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-recover-session-")); const localBin = path.join(home, "bin"); From 16393b8a43c5ce75aa63d8ca952f19d66e1a00ae Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Tue, 31 Mar 2026 15:44:27 -0400 Subject: [PATCH 5/6] test(cli): restore coverage ratchet for recovery changes --- test/credentials.test.js | 58 ++++++++++++++++++++++++++++- test/inference-config.test.js | 13 +++++++ test/local-inference.test.js | 67 ++++++++++++++++++++++++++++++++++ test/platform.test.js | 53 +++++++++++++++++++++++++++ test/resolve-openshell.test.js | 52 ++++++++++++++++++++++++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 test/resolve-openshell.test.js diff --git a/test/credentials.test.js b/test/credentials.test.js index dba1aeae8..03dd8eaa4 100644 --- a/test/credentials.test.js +++ b/test/credentials.test.js @@ -2,11 +2,67 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; -import { describe, it, expect } from "vitest"; +import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function importCredentialsModule(home) { + vi.resetModules(); + vi.doUnmock("fs"); + vi.doUnmock("child_process"); + vi.doUnmock("readline"); + vi.stubEnv("HOME", home); + return import("../bin/lib/credentials.js"); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); describe("credential prompts", () => { + it("loads, normalizes, and saves credentials from disk", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + + expect(credentials.loadCredentials()).toEqual({}); + + credentials.saveCredential("TEST_API_KEY", " nvapi-saved-key \r\n"); + + expect(credentials.CREDS_DIR).toBe(path.join(home, ".nemoclaw")); + expect(credentials.CREDS_FILE).toBe(path.join(home, ".nemoclaw", "credentials.json")); + expect(credentials.loadCredentials()).toEqual({ TEST_API_KEY: "nvapi-saved-key" }); + expect(credentials.getCredential("TEST_API_KEY")).toBe("nvapi-saved-key"); + + const saved = JSON.parse( + fs.readFileSync(path.join(home, ".nemoclaw", "credentials.json"), "utf-8") + ); + expect(saved).toEqual({ TEST_API_KEY: "nvapi-saved-key" }); + }); + + it("prefers environment credentials and ignores malformed credential files", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + fs.mkdirSync(path.join(home, ".nemoclaw"), { recursive: true }); + fs.writeFileSync(path.join(home, ".nemoclaw", "credentials.json"), "{not-json"); + + const credentials = await importCredentialsModule(home); + expect(credentials.loadCredentials()).toEqual({}); + + vi.stubEnv("TEST_API_KEY", " nvapi-from-env \n"); + expect(credentials.getCredential("TEST_API_KEY")).toBe("nvapi-from-env"); + }); + + it("returns null for missing or blank credential values", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-creds-")); + const credentials = await importCredentialsModule(home); + + credentials.saveCredential("EMPTY_VALUE", " \r\n "); + expect(credentials.getCredential("MISSING_VALUE")).toBe(null); + expect(credentials.getCredential("EMPTY_VALUE")).toBe(null); + }); + it("exits cleanly when answers are staged through a pipe", () => { const script = ` set -euo pipefail diff --git a/test/inference-config.test.js b/test/inference-config.test.js index ad13b3088..92be68433 100644 --- a/test/inference-config.test.js +++ b/test/inference-config.test.js @@ -124,6 +124,17 @@ describe("inference selection config", () => { provider: "vllm-local", providerLabel: "Local vLLM", }); + + expect(getProviderSelectionConfig("bedrock", "anthropic.claude-3-7-sonnet")).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "anthropic.claude-3-7-sonnet", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "BEDROCK_API_KEY", + provider: "bedrock", + providerLabel: "Amazon Bedrock (OpenAI-compatible)", + }); }); it("returns null for unknown providers", () => { @@ -141,10 +152,12 @@ describe("inference selection config", () => { expect(getProviderSelectionConfig("compatible-endpoint").model).toBe("custom-model"); expect(getProviderSelectionConfig("compatible-anthropic-endpoint").model).toBe("custom-anthropic-model"); expect(getProviderSelectionConfig("vllm-local").model).toBe("vllm-local"); + expect(getProviderSelectionConfig("bedrock").model).toBe("nvidia.nemotron-super-3-120b"); }); it("builds a default OpenClaw primary model for non-ollama providers", () => { expect(getOpenClawPrimaryModel("nvidia-prod")).toBe(`${MANAGED_PROVIDER_ID}/nvidia/nemotron-3-super-120b-a12b`); + expect(getOpenClawPrimaryModel("ollama-local")).toBe(`${MANAGED_PROVIDER_ID}/${DEFAULT_OLLAMA_MODEL}`); }); }); diff --git a/test/local-inference.test.js b/test/local-inference.test.js index ec37b5f1f..6bd04f8b1 100644 --- a/test/local-inference.test.js +++ b/test/local-inference.test.js @@ -6,10 +6,13 @@ import { describe, it, expect } from "vitest"; import { CONTAINER_REACHABILITY_IMAGE, DEFAULT_OLLAMA_MODEL, + LARGE_OLLAMA_MIN_MEMORY_MB, getDefaultOllamaModel, + getBootstrapOllamaModelOptions, getLocalProviderBaseUrl, getLocalProviderContainerReachabilityCheck, getLocalProviderHealthCheck, + getLocalProviderValidationBaseUrl, getOllamaModelOptions, getOllamaProbeCommand, getOllamaWarmupCommand, @@ -28,10 +31,29 @@ describe("local inference helpers", () => { expect(getLocalProviderBaseUrl("ollama-local")).toBe("http://host.openshell.internal:11434/v1"); }); + it("returns null for unknown local provider URLs", () => { + expect(getLocalProviderBaseUrl("unknown-provider")).toBeNull(); + expect(getLocalProviderValidationBaseUrl("unknown-provider")).toBeNull(); + expect(getLocalProviderHealthCheck("unknown-provider")).toBeNull(); + expect(getLocalProviderContainerReachabilityCheck("unknown-provider")).toBeNull(); + }); + + it("returns the expected validation URL for vllm-local", () => { + expect(getLocalProviderValidationBaseUrl("vllm-local")).toBe("http://localhost:8000/v1"); + }); + it("returns the expected health check command for ollama-local", () => { expect(getLocalProviderHealthCheck("ollama-local")).toBe("curl -sf http://localhost:11434/api/tags 2>/dev/null"); }); + it("returns the expected validation and health check commands for vllm-local", () => { + expect(getLocalProviderValidationBaseUrl("ollama-local")).toBe("http://localhost:11434/v1"); + expect(getLocalProviderHealthCheck("vllm-local")).toBe("curl -sf http://localhost:8000/v1/models 2>/dev/null"); + expect(getLocalProviderContainerReachabilityCheck("vllm-local")).toBe( + `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:8000/v1/models 2>/dev/null` + ); + }); + it("returns the expected container reachability command for ollama-local", () => { expect(getLocalProviderContainerReachabilityCheck("ollama-local")).toBe( `docker run --rm --add-host host.openshell.internal:host-gateway ${CONTAINER_REACHABILITY_IMAGE} -sf http://host.openshell.internal:11434/api/tags 2>/dev/null` @@ -71,6 +93,20 @@ describe("local inference helpers", () => { expect(result.message).toMatch(/http:\/\/localhost:8000/); }); + it("returns a clear error when vllm-local is not reachable from containers", () => { + let callCount = 0; + const result = validateLocalProvider("vllm-local", () => { + callCount += 1; + return callCount === 1 ? '{"data":[]}' : ""; + }); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/host\.openshell\.internal:8000/); + }); + + it("treats unknown local providers as already valid", () => { + expect(validateLocalProvider("custom-provider", () => "")).toEqual({ ok: true }); + }); + it("parses model names from ollama list output", () => { expect(parseOllamaList( [ @@ -81,6 +117,10 @@ describe("local inference helpers", () => { )).toEqual(["nemotron-3-nano:30b", "qwen3:32b"]); }); + it("ignores headers and blank lines in ollama list output", () => { + expect(parseOllamaList("NAME ID SIZE MODIFIED\n\n")).toEqual([]); + }); + it("returns parsed ollama model options when available", () => { expect( getOllamaModelOptions(() => "nemotron-3-nano:30b abc 24 GB now\nqwen3:32b def 20 GB now") @@ -100,6 +140,12 @@ describe("local inference helpers", () => { ).toEqual(["nemotron-3-nano:30b", "qwen2.5:7b"]); }); + it("returns no tags for malformed Ollama API output", () => { + expect(parseOllamaTags("{not-json")).toEqual([]); + expect(parseOllamaTags(JSON.stringify({ models: null }))).toEqual([]); + expect(parseOllamaTags(JSON.stringify({ models: [{}, { name: "qwen2.5:7b" }] }))).toEqual(["qwen2.5:7b"]); + }); + it("prefers Ollama /api/tags over parsing the CLI list output", () => { let call = 0; expect( @@ -129,6 +175,17 @@ describe("local inference helpers", () => { ).toBe("qwen3:32b"); }); + it("falls back to bootstrap model options when no Ollama models are installed", () => { + expect(getBootstrapOllamaModelOptions(null)).toEqual(["qwen2.5:7b"]); + expect( + getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB - 1 }) + ).toEqual(["qwen2.5:7b"]); + expect( + getBootstrapOllamaModelOptions({ totalMemoryMB: LARGE_OLLAMA_MIN_MEMORY_MB }) + ).toEqual(["qwen2.5:7b", DEFAULT_OLLAMA_MODEL]); + expect(getDefaultOllamaModel(() => "", { totalMemoryMB: 16384 })).toBe("qwen2.5:7b"); + }); + it("builds a background warmup command for ollama models", () => { const command = getOllamaWarmupCommand("nemotron-3-nano:30b"); expect(command).toMatch(/^nohup curl -s http:\/\/localhost:11434\/api\/generate /); @@ -136,6 +193,12 @@ describe("local inference helpers", () => { expect(command).toMatch(/"keep_alive":"15m"/); }); + it("supports custom probe and warmup tuning", () => { + expect(getOllamaWarmupCommand("qwen2.5:7b", "30m")).toMatch(/"keep_alive":"30m"/); + expect(getOllamaProbeCommand("qwen2.5:7b", 30, "5m")).toMatch(/--max-time 30/); + expect(getOllamaProbeCommand("qwen2.5:7b", 30, "5m")).toMatch(/"keep_alive":"5m"/); + }); + it("builds a foreground probe command for ollama models", () => { const command = getOllamaProbeCommand("nemotron-3-nano:30b"); expect(command).toMatch(/^curl -sS --max-time 120 http:\/\/localhost:11434\/api\/generate /); @@ -164,4 +227,8 @@ describe("local inference helpers", () => { ); expect(result).toEqual({ ok: true }); }); + + it("treats non-JSON probe output as success once the model responds", () => { + expect(validateOllamaModel("nemotron-3-nano:30b", () => "ok")).toEqual({ ok: true }); + }); }); diff --git a/test/platform.test.js b/test/platform.test.js index d18b96c9c..8bbc14c0b 100644 --- a/test/platform.test.js +++ b/test/platform.test.js @@ -3,10 +3,13 @@ import { describe, it, expect } from "vitest"; import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; import { detectDockerHost, findColimaDockerSocket, + getColimaDockerSocketCandidates, getDockerSocketCandidates, inferContainerRuntime, isUnsupportedMacosRuntime, @@ -31,6 +34,15 @@ describe("platform helpers", () => { release: "24.6.0", })).toBe(false); }); + + it("detects WSL from /proc version text even without WSL env vars", () => { + expect(isWsl({ + platform: "linux", + env: {}, + release: "6.6.87-generic", + procVersion: "Linux version 6.6.87.2-microsoft-standard-WSL2", + })).toBe(true); + }); }); describe("getDockerSocketCandidates", () => { @@ -48,6 +60,15 @@ describe("platform helpers", () => { }); }); + describe("getColimaDockerSocketCandidates", () => { + it("returns both legacy and config-path Colima sockets", () => { + expect(getColimaDockerSocketCandidates({ home: "/tmp/test-home" })).toEqual([ + "/tmp/test-home/.colima/default/docker.sock", + "/tmp/test-home/.config/colima/default/docker.sock", + ]); + }); + }); + describe("findColimaDockerSocket", () => { it("finds the first available Colima socket", () => { const home = "/tmp/test-home"; @@ -56,6 +77,19 @@ describe("platform helpers", () => { expect(findColimaDockerSocket({ home, existsSync })).toBe(path.join(home, ".config/colima/default/docker.sock")); }); + + it("returns null when no Colima socket exists", () => { + expect(findColimaDockerSocket({ home: "/tmp/test-home", existsSync: () => false })).toBeNull(); + }); + + it("uses fs.existsSync when no custom existsSync is provided", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-colima-")); + const socketPath = path.join(home, ".config/colima/default/docker.sock"); + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, ""); + + expect(findColimaDockerSocket({ home })).toBe(socketPath); + }); }); describe("detectDockerHost", () => { @@ -107,6 +141,19 @@ describe("platform helpers", () => { existsSync: () => false, })).toBe(null); }); + + it("uses fs.existsSync when no custom existsSync is provided", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-")); + const socketPath = path.join(home, ".docker/run/docker.sock"); + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, ""); + + expect(detectDockerHost({ env: {}, platform: "darwin", home })).toEqual({ + dockerHost: `unix://${socketPath}`, + source: "socket", + socketPath, + }); + }); }); describe("inferContainerRuntime", () => { @@ -121,6 +168,12 @@ describe("platform helpers", () => { it("detects Colima", () => { expect(inferContainerRuntime("Server: Colima\n Docker Engine - Community")).toBe("colima"); }); + + it("detects plain Docker and unknown output", () => { + expect(inferContainerRuntime("Docker Engine - Community")).toBe("docker"); + expect(inferContainerRuntime("")).toBe("unknown"); + expect(inferContainerRuntime("some unrelated runtime")).toBe("unknown"); + }); }); describe("isUnsupportedMacosRuntime", () => { diff --git a/test/resolve-openshell.test.js b/test/resolve-openshell.test.js new file mode 100644 index 000000000..daaf51544 --- /dev/null +++ b/test/resolve-openshell.test.js @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { resolveOpenshell } from "../bin/lib/resolve-openshell"; + +describe("resolveOpenshell", () => { + it("returns an absolute command -v result immediately", () => { + expect(resolveOpenshell({ commandVResult: "/usr/local/bin/openshell" })).toBe("/usr/local/bin/openshell"); + }); + + it("ignores non-absolute command -v output and falls back to known locations", () => { + expect( + resolveOpenshell({ + home: "/tmp/test-home", + commandVResult: "openshell", + checkExecutable: (candidate) => candidate === "/usr/local/bin/openshell", + }) + ).toBe("/usr/local/bin/openshell"); + }); + + it("prefers the home-local fallback before system paths", () => { + expect( + resolveOpenshell({ + home: "/tmp/test-home", + commandVResult: "", + checkExecutable: (candidate) => candidate === "/tmp/test-home/.local/bin/openshell", + }) + ).toBe("/tmp/test-home/.local/bin/openshell"); + }); + + it("skips invalid home values when checking fallback candidates", () => { + expect( + resolveOpenshell({ + home: "relative-home", + commandVResult: null, + checkExecutable: (candidate) => candidate === "/usr/bin/openshell", + }) + ).toBe("/usr/bin/openshell"); + }); + + it("returns null when no resolved path is executable", () => { + expect( + resolveOpenshell({ + home: "/tmp/test-home", + commandVResult: "", + checkExecutable: () => false, + }) + ).toBe(null); + }); +}); From 124a1cab77c3851f82624527d739fae1af732286 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Tue, 31 Mar 2026 16:02:37 -0400 Subject: [PATCH 6/6] test(cli): fix CommonJS credential import typing --- test/credentials.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/credentials.test.js b/test/credentials.test.js index 03dd8eaa4..68537235b 100644 --- a/test/credentials.test.js +++ b/test/credentials.test.js @@ -13,7 +13,8 @@ async function importCredentialsModule(home) { vi.doUnmock("child_process"); vi.doUnmock("readline"); vi.stubEnv("HOME", home); - return import("../bin/lib/credentials.js"); + const module = await import("../bin/lib/credentials.js"); + return module.default ?? module; } afterEach(() => { @@ -109,7 +110,7 @@ describe("credential prompts", () => { }); it("normalizes credential values and keeps prompting on invalid NVIDIA API key prefixes", async () => { - const credentials = await import("../bin/lib/credentials.js"); + const credentials = await importCredentialsModule("/tmp"); expect(credentials.normalizeCredentialValue(" nvapi-good-key\r\n")).toBe("nvapi-good-key"); const source = fs.readFileSync(