From 1d319abf8d2d4342e12dbc88936d6d5cec05cc66 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 1 Apr 2026 14:26:17 -0700 Subject: [PATCH 1/5] fix(install): add build:cli step to compile TypeScript CLI modules PR #1240 replaced bin/lib/ CJS modules with thin shims that re-export from dist/lib/, but install.sh was never updated to run `npm run build:cli`. Every fresh install now crashes on `nemoclaw onboard` with "Cannot find module '../../dist/lib/preflight'". Add the missing build:cli step to both install paths (local source and GitHub clone). Signed-off-by: Aaron Erickson --- install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install.sh b/install.sh index 1ecc18ebd..ec869a795 100755 --- a/install.sh +++ b/install.sh @@ -699,6 +699,7 @@ install_nemoclaw() { spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" \ || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" npm install --ignore-scripts + spin "Building NemoClaw CLI modules" npm run build:cli spin "Building NemoClaw plugin" bash -c 'cd nemoclaw && npm install --ignore-scripts && npm run build' spin "Linking NemoClaw CLI" npm link else @@ -725,6 +726,7 @@ install_nemoclaw() { spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" + spin "Building NemoClaw CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run build:cli" spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" fi From 4d7f57f611a3ee2ce54593921cf26a5af72fb261 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 1 Apr 2026 14:30:13 -0700 Subject: [PATCH 2/5] fix(test): update npm stubs to accept build:cli command The install-preflight test npm stubs only matched `npm run build` but not `npm run build:cli`, causing exit code 98 when install.sh invokes the new CLI build step. Signed-off-by: Aaron Erickson --- test/install-preflight.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index 030071029..eedc71ce8 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -383,7 +383,7 @@ if [ "$1" = "pack" ]; then exit 0 fi if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && [ "$2" = "build" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ]; }; then exit 0; fi if [ "$1" = "link" ]; then cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' #!/usr/bin/env bash @@ -453,7 +453,7 @@ fi`, exit 0 fi if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && [ "$2" = "build" ]; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ]; }; then exit 0; fi if [ "$1" = "link" ]; then cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' #!/usr/bin/env bash From 62cbbc0232cef33d58ce6fa278ac68ec67d1cf5c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 1 Apr 2026 14:35:28 -0700 Subject: [PATCH 3/5] fix(install): use --if-present for build:cli to support older refs Installs targeting older tags via NEMOCLAW_INSTALL_TAG would fail if the ref's package.json lacks the build:cli script. Using npm run --if-present makes the step a no-op on those refs. Update test npm stubs to accept the --if-present flag. Signed-off-by: Aaron Erickson --- install.sh | 4 ++-- test/install-preflight.test.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index ec869a795..ef7ddfd1e 100755 --- a/install.sh +++ b/install.sh @@ -699,7 +699,7 @@ install_nemoclaw() { spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$(pwd)" \ || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" npm install --ignore-scripts - spin "Building NemoClaw CLI modules" npm run build:cli + spin "Building NemoClaw CLI modules" npm run --if-present build:cli spin "Building NemoClaw plugin" bash -c 'cd nemoclaw && npm install --ignore-scripts && npm run build' spin "Linking NemoClaw CLI" npm link else @@ -726,7 +726,7 @@ install_nemoclaw() { spin "Preparing OpenClaw package" bash -c "$(declare -f info warn pre_extract_openclaw); pre_extract_openclaw \"\$1\"" _ "$nemoclaw_src" \ || warn "Pre-extraction failed — npm install may fail if openclaw tarball is broken" spin "Installing NemoClaw dependencies" bash -c "cd \"$nemoclaw_src\" && npm install --ignore-scripts" - spin "Building NemoClaw CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run build:cli" + spin "Building NemoClaw CLI modules" bash -c "cd \"$nemoclaw_src\" && npm run --if-present build:cli" spin "Building NemoClaw plugin" bash -c "cd \"$nemoclaw_src\"/nemoclaw && npm install --ignore-scripts && npm run build" spin "Linking NemoClaw CLI" bash -c "cd \"$nemoclaw_src\" && npm link" fi diff --git a/test/install-preflight.test.js b/test/install-preflight.test.js index eedc71ce8..3b373326f 100644 --- a/test/install-preflight.test.js +++ b/test/install-preflight.test.js @@ -383,7 +383,7 @@ if [ "$1" = "pack" ]; then exit 0 fi if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ]; }; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi if [ "$1" = "link" ]; then cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' #!/usr/bin/env bash @@ -453,7 +453,7 @@ fi`, exit 0 fi if [ "$1" = "install" ]; then exit 0; fi -if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ]; }; then exit 0; fi +if [ "$1" = "run" ] && { [ "$2" = "build" ] || [ "$2" = "build:cli" ] || [ "$2" = "--if-present" ]; }; then exit 0; fi if [ "$1" = "link" ]; then cat > "$NPM_PREFIX/bin/nemoclaw" <<'EOS' #!/usr/bin/env bash From 86d638b3b7ff9285b921822f67fc91164a045936 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 1 Apr 2026 15:00:41 -0700 Subject: [PATCH 4/5] fix(cli): make nemoclaw list read-only, stop recovering gateways nemoclaw list called recoverRegistryEntries() which could restart a destroyed gateway and re-import stale sandbox entries. This caused the E2E destroy verification to fail: destroy removes the sandbox, then list resurrects it by restarting the gateway and polling openshell sandbox list. list should just read the local registry. Recovery belongs in commands that need a live gateway (connect, status). Signed-off-by: Aaron Erickson --- bin/nemoclaw.js | 15 +-- test/cli.test.js | 336 ++--------------------------------------------- 2 files changed, 17 insertions(+), 334 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index f542e58fd..64e84a600 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -850,8 +850,9 @@ function showStatus() { } async function listSandboxes() { - const recovery = await recoverRegistryEntries(); - const { sandboxes, defaultSandbox } = recovery; + // list is read-only — just show what the registry has, no recovery or + // gateway restarts. Use `nemoclaw connect` to trigger recovery. + const { sandboxes, defaultSandbox } = registry.listSandboxes(); if (sandboxes.length === 0) { console.log(""); const session = onboardSession.loadSession(); @@ -875,16 +876,6 @@ async 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 ? " *" : ""; diff --git a/test/cli.test.js b/test/cli.test.js index 4641ff7ad..378648350 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -610,12 +610,11 @@ 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"); + it("list is read-only and does not recover from session or gateway", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-no-recover-")); const nemoclawDir = path.join(home, ".nemoclaw"); - fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(nemoclawDir, { recursive: true }); + // Registry has only gamma — session references alpha, but list should NOT import it. fs.writeFileSync( path.join(nemoclawDir, "sandboxes.json"), JSON.stringify({ @@ -634,333 +633,26 @@ describe("CLI dispatch", () => { ); 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 '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(); - 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.gamma).toBeTruthy(); - expect(saved.defaultSandbox).toBe("gamma"); - }); - - 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, "sandboxes.json"), JSON.stringify({ - sandboxes: { - gamma: { - name: "gamma", - model: "existing-model", - provider: "existing-provider", - gpuEnabled: false, - policies: ["npm"], - }, - }, - defaultSandbox: "gamma", + version: 1, + sessionId: "session-1", + resumable: true, + status: "complete", + sandboxName: "alpha", }), { 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 '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 || ""}`, - }); + const r = runWithEnv("list", { HOME: home }); 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(); + // Only gamma (from registry) should appear — not alpha (from session). expect(r.out.includes("gamma")).toBeTruthy(); + expect(r.out.includes("alpha")).toBeFalsy(); + expect(r.out).not.toMatch(/Recovered/); + // Registry must not have been mutated. 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("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.alpha).toBeUndefined(); expect(saved.sandboxes.gamma).toBeTruthy(); }); From 63a2b7031368687d2a821c222d3e10ac75e7caad Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Wed, 1 Apr 2026 15:07:53 -0700 Subject: [PATCH 5/5] fix(e2e): verify destroy against registry file, not nemoclaw list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the list-is-read-only change — the recovery behavior from #1187 is intentional and valuable after reboots. The E2E destroy check used `nemoclaw list` which triggers gateway recovery, potentially restarting a destroyed gateway and re-importing stale sandbox entries. Check the registry file directly instead. The list-recovery-after-destroy interaction is a real bug but belongs in a separate PR. Signed-off-by: Aaron Erickson --- bin/nemoclaw.js | 15 +- test/cli.test.js | 336 ++++++++++++++++++++++++++++++++++++-- test/e2e/test-full-e2e.sh | 9 +- test/e2e/test-gpu-e2e.sh | 9 +- 4 files changed, 346 insertions(+), 23 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 64e84a600..f542e58fd 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -850,9 +850,8 @@ function showStatus() { } async function listSandboxes() { - // list is read-only — just show what the registry has, no recovery or - // gateway restarts. Use `nemoclaw connect` to trigger recovery. - const { sandboxes, defaultSandbox } = registry.listSandboxes(); + const recovery = await recoverRegistryEntries(); + const { sandboxes, defaultSandbox } = recovery; if (sandboxes.length === 0) { console.log(""); const session = onboardSession.loadSession(); @@ -876,6 +875,16 @@ async 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 ? " *" : ""; diff --git a/test/cli.test.js b/test/cli.test.js index 378648350..4641ff7ad 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -610,11 +610,12 @@ describe("CLI dispatch", () => { expect(saved.sandboxes.alpha).toBeUndefined(); }); - it("list is read-only and does not recover from session or gateway", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-list-no-recover-")); + 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 }); - // Registry has only gamma — session references alpha, but list should NOT import it. fs.writeFileSync( path.join(nemoclawDir, "sandboxes.json"), JSON.stringify({ @@ -633,26 +634,333 @@ describe("CLI dispatch", () => { ); 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 '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(); + 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.gamma).toBeTruthy(); + expect(saved.defaultSandbox).toBe("gamma"); + }); + + 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, "sandboxes.json"), JSON.stringify({ - version: 1, - sessionId: "session-1", - resumable: true, - status: "complete", - sandboxName: "alpha", + 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 '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 }); + const r = runWithEnv("list", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + }); expect(r.code).toBe(0); - // Only gamma (from registry) should appear — not alpha (from session). + 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(); expect(r.out.includes("gamma")).toBeTruthy(); - expect(r.out.includes("alpha")).toBeFalsy(); - expect(r.out).not.toMatch(/Recovered/); - // Registry must not have been mutated. const saved = JSON.parse(fs.readFileSync(path.join(nemoclawDir, "sandboxes.json"), "utf8")); - expect(saved.sandboxes.alpha).toBeUndefined(); + 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("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(); }); diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index b0c418558..4c2f9a46e 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -341,9 +341,12 @@ section "Phase 6: Cleanup" nemoclaw "$SANDBOX_NAME" destroy --yes 2>&1 | tail -3 || true openshell gateway destroy -g nemoclaw 2>/dev/null || true -list_after=$(nemoclaw list 2>&1) -if grep -Fq -- "$SANDBOX_NAME" <<<"$list_after"; then - fail "Sandbox ${SANDBOX_NAME} still in list after destroy" +# Verify against the registry file directly. `nemoclaw list` triggers +# gateway recovery which can restart a destroyed gateway and re-import stale +# sandbox entries — that's a separate issue (#TBD), so avoid it here. +registry_file="${HOME}/.nemoclaw/sandboxes.json" +if [ -f "$registry_file" ] && grep -Fq "\"${SANDBOX_NAME}\"" "$registry_file"; then + fail "Sandbox ${SANDBOX_NAME} still in registry after destroy" else pass "Sandbox ${SANDBOX_NAME} removed" fi diff --git a/test/e2e/test-gpu-e2e.sh b/test/e2e/test-gpu-e2e.sh index 6e71cc4af..f0b2bbc6a 100755 --- a/test/e2e/test-gpu-e2e.sh +++ b/test/e2e/test-gpu-e2e.sh @@ -382,9 +382,12 @@ section "Phase 6: Destroy and uninstall" info "Destroying sandbox ${SANDBOX_NAME}..." nemoclaw "$SANDBOX_NAME" destroy --yes 2>&1 | tail -5 || true -list_after_destroy=$(nemoclaw list 2>&1) -if echo "$list_after_destroy" | grep -Fq -- "$SANDBOX_NAME"; then - fail "Sandbox ${SANDBOX_NAME} still in list after destroy" +# Verify against the registry file directly. `nemoclaw list` triggers +# gateway recovery which can restart a destroyed gateway and re-import stale +# sandbox entries — that's a separate issue (#TBD), so avoid it here. +registry_file="${HOME}/.nemoclaw/sandboxes.json" +if [ -f "$registry_file" ] && grep -Fq "\"${SANDBOX_NAME}\"" "$registry_file"; then + fail "Sandbox ${SANDBOX_NAME} still in registry after destroy" else pass "Sandbox ${SANDBOX_NAME} removed from registry" fi