From 768791b405167e9c430d93773c4df431e513d7e0 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 21 Jul 2026 17:54:38 +0800 Subject: [PATCH 01/12] fix(installer): fail closed when a present OpenShell cannot report its version (#7300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `openshell` exists on PATH but exits non-zero (cannot report a version), `maybe_install_openshell_during_install if-missing` (source-checkout branch) returned 0 on `command_exists openshell` alone — an existence check with no health check. So the installer silently skipped the `Installing OpenShell CLI` step, and the preinstall version gate that would catch it (preinstall_backup_and_retire_legacy_gateway, added in #7078) only runs when a sandbox is registered — on a host with no sandbox it early-returns. The result was onboarding proceeding to exit 0 against an undeterminable OpenShell version. Health-check the if-missing guard: when openshell is present but `installed_openshell_version` is empty, `error` out (non-zero exit) with a clear message, leaving gateway and sandbox state unchanged, instead of keeping a broken binary. A binary that reports a version is still preserved (the #3989 developer-autonomy intent); an absent binary still triggers the install. Verified in real bash on Ubuntu 24.04: sourcing scripts/install.sh with a stub `openshell` (exit 1) makes `maybe_install_openshell_during_install if-missing` abort with the new error; a stub that reports 0.0.85 is kept with exit 0. Two regression tests pin both. Signed-off-by: Yanyun Liao --- scripts/install.sh | 8 +++ test/install-openshell-upgrade-prompt.test.ts | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 4269fbf6dc1..6fe03c00a72 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1274,6 +1274,14 @@ maybe_install_openshell_during_install() { return 0 fi if [[ "$mode" == "if-missing" ]] && command_exists openshell; then + # A present binary that cannot report a version is not a usable managed + # OpenShell. Fail closed instead of silently keeping it: otherwise the + # install is skipped here AND the preinstall version gate is bypassed (its + # gate only runs when a sandbox is registered, install.sh:2276), so + # onboarding proceeds against an undeterminable OpenShell version (#7300). + if [ -z "$(installed_openshell_version 2>/dev/null || true)" ]; then + error "OpenShell is present on PATH but could not report its version. Refusing to continue with an undeterminable OpenShell version — reinstall a supported OpenShell (run scripts/install-openshell.sh) or remove the broken binary, then rerun the installer." + fi return 0 fi spin "Installing OpenShell CLI" bash "${NEMOCLAW_SOURCE_ROOT}/scripts/install-openshell.sh" diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 6a2956a0968..861c678ca72 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -213,6 +213,60 @@ test ! -e "${pidFile}"`, }, ); + it.skipIf(process.platform !== "linux")( + "fails closed when a present openshell cannot report its version in if-missing mode (#7300)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-unreportable-")); + const bin = path.join(tmp, "bin"); + fs.mkdirSync(bin, { recursive: true }); + // Present binary that exits non-zero and prints no version. + writeExecutable(path.join(bin, "openshell"), "#!/usr/bin/env bash\nexit 1\n"); + + const result = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +NEMOCLAW_SOURCE_ROOT="${tmp}" +unset NEMOCLAW_DEFER_OPENSHELL_INSTALL +maybe_install_openshell_during_install if-missing`, + ], + { encoding: "utf-8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }, + ); + + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stderr + result.stdout).toContain("could not report its version"); + }, + ); + + it.skipIf(process.platform !== "linux")( + "keeps an existing openshell that reports a version in if-missing mode (#7300)", + () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-healthy-")); + const bin = path.join(tmp, "bin"); + fs.mkdirSync(bin, { recursive: true }); + writeExecutable( + path.join(bin, "openshell"), + '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n', + ); + + const result = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +NEMOCLAW_SOURCE_ROOT="${tmp}" +unset NEMOCLAW_DEFER_OPENSHELL_INSTALL +maybe_install_openshell_during_install if-missing`, + ], + { encoding: "utf-8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }, + ); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stderr + result.stdout).not.toContain("could not report its version"); + }, + ); + it("aborts non-interactive legacy gateway upgrades without explicit opt-in", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard({ NON_INTERACTIVE: "1", From 0e4f3bc1f7dc23f412a2e28ed194fe3f65bab025 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 21 Jul 2026 18:37:27 +0800 Subject: [PATCH 02/12] fix(installer): enforce reportable OpenShell version at an unbypassable gate (#7300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR Review Advisor PRA-1 (blocker): the previous check lived inside the if-missing branch, after the NEMOCLAW_DEFER_OPENSHELL_INSTALL early return, so a deferred install (env set) combined with a no-sandbox run (preinstall version gate skipped at install.sh:2276) could still proceed against an unreportable OpenShell version. Move it to require_reportable_openshell_version(), called unconditionally in main() after install_nemoclaw/verify_nemoclaw and before onboarding — independent of defer, sandbox count, and if-missing/force mode. The force path has already reinstalled openshell (healthy) by then; a present-but-unreportable binary on the source-checkout path fails closed; an absent binary or one that reports a version passes. Tests now drive the gate directly, including a defer-set case proving it is not bypassable. Verified live on Ubuntu 24.04: sourcing scripts/install.sh and calling the gate with a stub openshell (exit 1) aborts with the diagnostic even under NEMOCLAW_DEFER_OPENSHELL_INSTALL=1; a stub reporting 0.0.85 passes. Signed-off-by: Yanyun Liao --- scripts/install.sh | 23 +++--- test/install-openshell-upgrade-prompt.test.ts | 72 +++++++++---------- 2 files changed, 51 insertions(+), 44 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 6fe03c00a72..60c021c5dd7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1274,14 +1274,6 @@ maybe_install_openshell_during_install() { return 0 fi if [[ "$mode" == "if-missing" ]] && command_exists openshell; then - # A present binary that cannot report a version is not a usable managed - # OpenShell. Fail closed instead of silently keeping it: otherwise the - # install is skipped here AND the preinstall version gate is bypassed (its - # gate only runs when a sandbox is registered, install.sh:2276), so - # onboarding proceeds against an undeterminable OpenShell version (#7300). - if [ -z "$(installed_openshell_version 2>/dev/null || true)" ]; then - error "OpenShell is present on PATH but could not report its version. Refusing to continue with an undeterminable OpenShell version — reinstall a supported OpenShell (run scripts/install-openshell.sh) or remove the broken binary, then rerun the installer." - fi return 0 fi spin "Installing OpenShell CLI" bash "${NEMOCLAW_SOURCE_ROOT}/scripts/install-openshell.sh" @@ -2011,6 +2003,20 @@ installed_openshell_version() { openshell --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 } +# Fail closed before onboarding when an openshell binary is present on PATH but +# cannot report a version. Run at an unconditional point (after all install +# steps, before onboarding): the source-checkout `if-missing` branch keeps a +# present binary, and the preinstall version gate only runs when a sandbox is +# registered (install.sh:2276) — so a no-sandbox run or a deferred install would +# otherwise proceed against an undeterminable OpenShell version (#7300). An +# absent binary is fine (it was installed on the force path); a binary that +# reports a version is fine (the #3989 developer-autonomy case). +require_reportable_openshell_version() { + command_exists openshell || return 0 + [ -n "$(installed_openshell_version 2>/dev/null || true)" ] && return 0 + error "OpenShell is present on PATH but could not report its version. Refusing to start onboarding with an undeterminable OpenShell version — reinstall a supported OpenShell (run scripts/install-openshell.sh) or remove the broken binary, then rerun the installer." +} + truthy_env() { case "${1:-}" in 1 | true | TRUE | yes | YES | y | Y) return 0 ;; @@ -4003,6 +4009,7 @@ main() { preinstall_backup_and_retire_legacy_gateway install_nemoclaw verify_nemoclaw + require_reportable_openshell_version # Gate the onboarding-adjacent steps on the absolute CLI path so a stale # shell PATH cache no longer suppresses auto-onboarding (#3276). Falls diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 861c678ca72..8d318979b9a 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -213,26 +213,31 @@ test ! -e "${pidFile}"`, }, ); + function runOpenshellVersionGate(openshellBody: string, extraSetup = "") { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-version-gate-")); + const bin = path.join(tmp, "bin"); + fs.mkdirSync(bin, { recursive: true }); + writeExecutable(path.join(bin, "openshell"), openshellBody); + return spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +${extraSetup} +require_reportable_openshell_version`, + ], + { encoding: "utf-8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }, + ); + } + + const brokenOpenshell = "#!/usr/bin/env bash\nexit 1\n"; + const healthyOpenshell = + '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n'; + it.skipIf(process.platform !== "linux")( - "fails closed when a present openshell cannot report its version in if-missing mode (#7300)", + "fails closed before onboarding when a present openshell cannot report its version (#7300)", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-unreportable-")); - const bin = path.join(tmp, "bin"); - fs.mkdirSync(bin, { recursive: true }); - // Present binary that exits non-zero and prints no version. - writeExecutable(path.join(bin, "openshell"), "#!/usr/bin/env bash\nexit 1\n"); - - const result = spawnSync( - "bash", - [ - "-c", - `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 -NEMOCLAW_SOURCE_ROOT="${tmp}" -unset NEMOCLAW_DEFER_OPENSHELL_INSTALL -maybe_install_openshell_during_install if-missing`, - ], - { encoding: "utf-8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }, - ); + const result = runOpenshellVersionGate(brokenOpenshell); expect(result.status, result.stdout + result.stderr).not.toBe(0); expect(result.stderr + result.stdout).toContain("could not report its version"); @@ -240,27 +245,22 @@ maybe_install_openshell_during_install if-missing`, ); it.skipIf(process.platform !== "linux")( - "keeps an existing openshell that reports a version in if-missing mode (#7300)", + "cannot be bypassed by NEMOCLAW_DEFER_OPENSHELL_INSTALL (#7300)", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-healthy-")); - const bin = path.join(tmp, "bin"); - fs.mkdirSync(bin, { recursive: true }); - writeExecutable( - path.join(bin, "openshell"), - '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n', + const result = runOpenshellVersionGate( + brokenOpenshell, + "export NEMOCLAW_DEFER_OPENSHELL_INSTALL=1", ); - const result = spawnSync( - "bash", - [ - "-c", - `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 -NEMOCLAW_SOURCE_ROOT="${tmp}" -unset NEMOCLAW_DEFER_OPENSHELL_INSTALL -maybe_install_openshell_during_install if-missing`, - ], - { encoding: "utf-8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }, - ); + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stderr + result.stdout).toContain("could not report its version"); + }, + ); + + it.skipIf(process.platform !== "linux")( + "passes when the present openshell reports a version (#7300)", + () => { + const result = runOpenshellVersionGate(healthyOpenshell); expect(result.status, result.stdout + result.stderr).toBe(0); expect(result.stderr + result.stdout).not.toContain("could not report its version"); From 48960a133c9d7e40fe55346b0aadf5cb767b13ad Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 24 Jul 2026 16:04:23 -0400 Subject: [PATCH 03/12] fix(installer): validate OpenShell before gateway mutation Run the version gate before legacy gateway backup and retirement. Keep the post-install gate to verify the installed binary. Test the mutation boundary and absent-binary path through the installer main flow. Signed-off-by: Julie Yaunches --- scripts/install.sh | 11 +-- test/install-openshell-upgrade-prompt.test.ts | 89 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 786b83c669e..6dcf002e54b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2003,14 +2003,8 @@ installed_openshell_version() { openshell --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 } -# Fail closed before onboarding when an openshell binary is present on PATH but -# cannot report a version. Run at an unconditional point (after all install -# steps, before onboarding): the source-checkout `if-missing` branch keeps a -# present binary, and the preinstall version gate only runs when a sandbox is -# registered (install.sh:2276) — so a no-sandbox run or a deferred install would -# otherwise proceed against an undeterminable OpenShell version (#7300). An -# absent binary is fine (it was installed on the force path); a binary that -# reports a version is fine (the #3989 developer-autonomy case). +# Fail closed when OpenShell is present but cannot report its version. An absent +# binary is valid before install_nemoclaw; its caller validates again afterward. require_reportable_openshell_version() { command_exists openshell || return 0 [ -n "$(installed_openshell_version 2>/dev/null || true)" ] && return 0 @@ -4006,6 +4000,7 @@ main() { # `nemoclaw onboard` (the install-ollama / install-vllm branches). # install.sh stays focused on dependency setup. fix_npm_permissions + require_reportable_openshell_version preinstall_backup_and_retire_legacy_gateway install_nemoclaw verify_nemoclaw diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 8d318979b9a..47187ff83c3 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -14,6 +14,77 @@ function writeExecutable(target: string, contents: string): void { fs.writeFileSync(target, contents, { mode: 0o755 }); } +function runInstallerOpenshellVersionFlow(openshellBody?: string) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-version-flow-")); + const home = path.join(tmp, "home"); + const bin = path.join(tmp, "bin"); + const setupDir = path.join(tmp, "setup"); + const registry = path.join(home, ".nemoclaw", "sandboxes.json"); + const gatewayState = path.join(tmp, "gateway.state"); + const installLog = path.join(tmp, "install.log"); + const healthyOpenshell = path.join(tmp, "healthy-openshell"); + + fs.mkdirSync(path.dirname(registry), { recursive: true }); + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(setupDir, { recursive: true }); + fs.writeFileSync(registry, '{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); + fs.writeFileSync(gatewayState, "gateway-original\n"); + writeExecutable(path.join(setupDir, "setup-jetson.sh"), "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable( + healthyOpenshell, + '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n', + ); + if (openshellBody !== undefined) { + writeExecutable(path.join(bin, "openshell"), openshellBody); + } + + const result = spawnSync( + "bash", + [ + "-c", + `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 +SCRIPT_DIR="${setupDir}" +resolve_nemoclaw_gateway_port() { printf '8080\\n'; } +preflight_explicit_express_flags() { :; } +print_banner() { :; } +preflight_usage_notice_prompt() { :; } +prepare_installer_host() { :; } +step() { :; } +install_nodejs() { :; } +ensure_supported_runtime() { :; } +fix_npm_permissions() { :; } +preinstall_backup_and_retire_legacy_gateway() { + printf 'gateway-retired\\n' >"${gatewayState}" + printf '{"sandboxes":{}}\\n' >"${registry}" +} +install_nemoclaw() { + printf 'install\\n' >>"${installLog}" + if ! command_exists openshell; then + cp "${healthyOpenshell}" "${bin}/openshell" + fi +} +verify_nemoclaw() { :; } +print_done() { :; } +main --non-interactive --yes-i-accept-third-party-software`, + ], + { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + }, + }, + ); + + return { + result, + gatewayState: fs.readFileSync(gatewayState, "utf-8"), + registry: fs.readFileSync(registry, "utf-8"), + installLog: fs.existsSync(installLog) ? fs.readFileSync(installLog, "utf-8") : "", + }; +} + function runPreinstallUpgradeGuard( env: Record = {}, options: { @@ -234,6 +305,24 @@ require_reportable_openshell_version`, const healthyOpenshell = '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n'; + it("rejects a broken OpenShell before gateway or sandbox mutation (#7300)", () => { + const { result, gatewayState, registry, installLog } = + runInstallerOpenshellVersionFlow(brokenOpenshell); + + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stderr + result.stdout).toContain("could not report its version"); + expect(gatewayState).toBe("gateway-original\n"); + expect(registry).toBe('{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); + expect(installLog).toBe(""); + }); + + it("installs OpenShell when no binary is present (#7300)", () => { + const { result, installLog } = runInstallerOpenshellVersionFlow(); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(installLog).toBe("install\n"); + }); + it.skipIf(process.platform !== "linux")( "fails closed before onboarding when a present openshell cannot report its version (#7300)", () => { From 7e869a046be2259a2e51397f32b584a8101dee0e Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 24 Jul 2026 16:14:05 -0400 Subject: [PATCH 04/12] test(installer): keep OpenShell flow setup linear Pass fixture setup as a callback so the changed test file adds no if statement. Preserve the broken-binary and absent-binary installer flow cases. Signed-off-by: Julie Yaunches --- test/install-openshell-upgrade-prompt.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 47187ff83c3..c6bbec9c355 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -14,7 +14,7 @@ function writeExecutable(target: string, contents: string): void { fs.writeFileSync(target, contents, { mode: 0o755 }); } -function runInstallerOpenshellVersionFlow(openshellBody?: string) { +function runInstallerOpenshellVersionFlow(setupOpenshell: (bin: string) => void) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-version-flow-")); const home = path.join(tmp, "home"); const bin = path.join(tmp, "bin"); @@ -34,9 +34,7 @@ function runInstallerOpenshellVersionFlow(openshellBody?: string) { healthyOpenshell, '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n', ); - if (openshellBody !== undefined) { - writeExecutable(path.join(bin, "openshell"), openshellBody); - } + setupOpenshell(bin); const result = spawnSync( "bash", @@ -306,8 +304,11 @@ require_reportable_openshell_version`, '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n'; it("rejects a broken OpenShell before gateway or sandbox mutation (#7300)", () => { - const { result, gatewayState, registry, installLog } = - runInstallerOpenshellVersionFlow(brokenOpenshell); + const { result, gatewayState, registry, installLog } = runInstallerOpenshellVersionFlow( + (bin) => { + writeExecutable(path.join(bin, "openshell"), brokenOpenshell); + }, + ); expect(result.status, result.stdout + result.stderr).not.toBe(0); expect(result.stderr + result.stdout).toContain("could not report its version"); @@ -317,7 +318,7 @@ require_reportable_openshell_version`, }); it("installs OpenShell when no binary is present (#7300)", () => { - const { result, installLog } = runInstallerOpenshellVersionFlow(); + const { result, installLog } = runInstallerOpenshellVersionFlow(() => undefined); expect(result.status, result.stdout + result.stderr).toBe(0); expect(installLog).toBe("install\n"); From 98e12c868241af5314bad58df69c1cb16907df9b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 24 Jul 2026 16:26:06 -0400 Subject: [PATCH 05/12] fix(installer): require successful OpenShell version command Reject version text when the OpenShell command exits unsuccessfully. Stop before gateway or sandbox mutation and cover both failed and healthy commands. Signed-off-by: Julie Yaunches --- scripts/install.sh | 4 ++- test/install-openshell-upgrade-prompt.test.ts | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 6dcf002e54b..c67d656b3da 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2000,7 +2000,9 @@ run_preupgrade_backup() { installed_openshell_version() { command_exists openshell || return 1 - openshell --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 + local version_output + version_output="$(openshell --version 2>/dev/null)" || return 1 + printf "%s\n" "$version_output" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 } # Fail closed when OpenShell is present but cannot report its version. An absent diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index c6bbec9c355..8106c0029ae 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -80,6 +80,7 @@ main --non-interactive --yes-i-accept-third-party-software`, gatewayState: fs.readFileSync(gatewayState, "utf-8"), registry: fs.readFileSync(registry, "utf-8"), installLog: fs.existsSync(installLog) ? fs.readFileSync(installLog, "utf-8") : "", + openshellBody: fs.readFileSync(path.join(bin, "openshell"), "utf-8"), }; } @@ -302,6 +303,8 @@ require_reportable_openshell_version`, const brokenOpenshell = "#!/usr/bin/env bash\nexit 1\n"; const healthyOpenshell = '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n'; + const versionPrintingBrokenOpenshell = + '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 1\n'; it("rejects a broken OpenShell before gateway or sandbox mutation (#7300)", () => { const { result, gatewayState, registry, installLog } = runInstallerOpenshellVersionFlow( @@ -317,6 +320,29 @@ require_reportable_openshell_version`, expect(installLog).toBe(""); }); + it("rejects a failed OpenShell version command before gateway or sandbox mutation (#7300)", () => { + const { result, gatewayState, registry, installLog } = runInstallerOpenshellVersionFlow( + (bin) => { + writeExecutable(path.join(bin, "openshell"), versionPrintingBrokenOpenshell); + }, + ); + + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stderr + result.stdout).toContain("could not report its version"); + expect(gatewayState).toBe("gateway-original\n"); + expect(registry).toBe('{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); + expect(installLog).toBe(""); + }); + + it("preserves a reportable OpenShell through the installer flow (#7300)", () => { + const { result, openshellBody } = runInstallerOpenshellVersionFlow((bin) => { + writeExecutable(path.join(bin, "openshell"), healthyOpenshell); + }); + + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(openshellBody).toBe(healthyOpenshell); + }); + it("installs OpenShell when no binary is present (#7300)", () => { const { result, installLog } = runInstallerOpenshellVersionFlow(() => undefined); From 6743dbc9ee87bda5ecd9f629754deb997bf9ab8b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 24 Jul 2026 16:37:26 -0400 Subject: [PATCH 06/12] test(installer): cover post-install OpenShell version failure Prove the second gate rejects a failed version command produced by installation before onboarding. Document the version helper's nonzero return contract. Signed-off-by: Julie Yaunches --- scripts/install.sh | 1 + test/install-openshell-upgrade-prompt.test.ts | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index c67d656b3da..ef34effc3d5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1998,6 +1998,7 @@ run_preupgrade_backup() { NEMOCLAW_REQUIRE_ALL_SANDBOX_BACKUPS=1 "$current_cli_runner" backup-all 2>&1 } +# Return nonzero when OpenShell is absent or its version command fails. installed_openshell_version() { command_exists openshell || return 1 local version_output diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 8106c0029ae..dfc41633dfe 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -14,7 +14,10 @@ function writeExecutable(target: string, contents: string): void { fs.writeFileSync(target, contents, { mode: 0o755 }); } -function runInstallerOpenshellVersionFlow(setupOpenshell: (bin: string) => void) { +function runInstallerOpenshellVersionFlow( + setupOpenshell: (bin: string) => void, + installedOpenshellBody = '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n', +) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-version-flow-")); const home = path.join(tmp, "home"); const bin = path.join(tmp, "bin"); @@ -30,10 +33,7 @@ function runInstallerOpenshellVersionFlow(setupOpenshell: (bin: string) => void) fs.writeFileSync(registry, '{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); fs.writeFileSync(gatewayState, "gateway-original\n"); writeExecutable(path.join(setupDir, "setup-jetson.sh"), "#!/usr/bin/env bash\nexit 0\n"); - writeExecutable( - healthyOpenshell, - '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n', - ); + writeExecutable(healthyOpenshell, installedOpenshellBody); setupOpenshell(bin); const result = spawnSync( @@ -350,6 +350,18 @@ require_reportable_openshell_version`, expect(installLog).toBe("install\n"); }); + it("rejects an installed OpenShell whose version command fails before onboarding (#7300)", () => { + const { result, installLog, openshellBody } = runInstallerOpenshellVersionFlow( + () => undefined, + versionPrintingBrokenOpenshell, + ); + + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stderr + result.stdout).toContain("could not report its version"); + expect(installLog).toBe("install\n"); + expect(openshellBody).toBe(versionPrintingBrokenOpenshell); + }); + it.skipIf(process.platform !== "linux")( "fails closed before onboarding when a present openshell cannot report its version (#7300)", () => { From e1fe95094b72f3eddce8b363f12f329cf7b923c5 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 24 Jul 2026 16:52:30 -0400 Subject: [PATCH 07/12] test(installer): include current setup helper fixture Provide the Station vLLM helper expected after SCRIPT_DIR is redirected in the installer-flow test. Signed-off-by: Julie Yaunches --- test/install-openshell-upgrade-prompt.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index dfc41633dfe..fc793f52ba0 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -29,10 +29,11 @@ function runInstallerOpenshellVersionFlow( fs.mkdirSync(path.dirname(registry), { recursive: true }); fs.mkdirSync(bin, { recursive: true }); - fs.mkdirSync(setupDir, { recursive: true }); + fs.mkdirSync(path.join(setupDir, "lib"), { recursive: true }); fs.writeFileSync(registry, '{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); fs.writeFileSync(gatewayState, "gateway-original\n"); writeExecutable(path.join(setupDir, "setup-jetson.sh"), "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable(path.join(setupDir, "lib", "station-vllm-conflict.sh"), "#!/usr/bin/env bash\n"); writeExecutable(healthyOpenshell, installedOpenshellBody); setupOpenshell(bin); From 732faeb67285aaf56c488e712da65e6b7619b83b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 24 Jul 2026 17:16:53 -0400 Subject: [PATCH 08/12] test(installer): reject invalid OpenShell version output Signed-off-by: Julie Yaunches --- test/install-openshell-upgrade-prompt.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index fc793f52ba0..9cc29893194 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -304,6 +304,8 @@ require_reportable_openshell_version`, const brokenOpenshell = "#!/usr/bin/env bash\nexit 1\n"; const healthyOpenshell = '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 0\n'; + const invalidVersionOpenshell = + '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell unknown"\nexit 0\n'; const versionPrintingBrokenOpenshell = '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 1\n'; @@ -335,6 +337,20 @@ require_reportable_openshell_version`, expect(installLog).toBe(""); }); + it("rejects invalid OpenShell version output before gateway or sandbox mutation (#7300)", () => { + const { result, gatewayState, registry, installLog } = runInstallerOpenshellVersionFlow( + (bin) => { + writeExecutable(path.join(bin, "openshell"), invalidVersionOpenshell); + }, + ); + + expect(result.status, result.stdout + result.stderr).not.toBe(0); + expect(result.stderr + result.stdout).toContain("could not report its version"); + expect(gatewayState).toBe("gateway-original\n"); + expect(registry).toBe('{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); + expect(installLog).toBe(""); + }); + it("preserves a reportable OpenShell through the installer flow (#7300)", () => { const { result, openshellBody } = runInstallerOpenshellVersionFlow((bin) => { writeExecutable(path.join(bin, "openshell"), healthyOpenshell); From c8a9e9716aa48597f20308bb66d47beb23c2f336 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 24 Jul 2026 21:08:44 -0700 Subject: [PATCH 09/12] test(installer): isolate OpenShell version flow fixture Signed-off-by: Aaron Erickson --- test/install-openshell-upgrade-prompt.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 9cc29893194..ffb459ea0fd 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -33,7 +33,13 @@ function runInstallerOpenshellVersionFlow( fs.writeFileSync(registry, '{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); fs.writeFileSync(gatewayState, "gateway-original\n"); writeExecutable(path.join(setupDir, "setup-jetson.sh"), "#!/usr/bin/env bash\nexit 0\n"); - writeExecutable(path.join(setupDir, "lib", "station-vllm-conflict.sh"), "#!/usr/bin/env bash\n"); + writeExecutable( + path.join(setupDir, "lib", "station-vllm-conflict.sh"), + `#!/usr/bin/env bash +handle_station_vllm_conflict() { :; } +consume_station_local_vllm_resume() { return 1; } +`, + ); writeExecutable(healthyOpenshell, installedOpenshellBody); setupOpenshell(bin); @@ -43,6 +49,8 @@ function runInstallerOpenshellVersionFlow( "-c", `source "${INSTALLER_PAYLOAD}" >/dev/null 2>&1 SCRIPT_DIR="${setupDir}" +_CLI_PATH="" +_CLI_BIN="nemoclaw-test-missing" resolve_nemoclaw_gateway_port() { printf '8080\\n'; } preflight_explicit_express_flags() { :; } print_banner() { :; } From 2aecc0ee692dde2417eb20db337b2a96c88eaa07 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 24 Jul 2026 21:37:35 -0700 Subject: [PATCH 10/12] fix(installer): validate user-local OpenShell before recovery Signed-off-by: Aaron Erickson --- scripts/install.sh | 1 + test/install-openshell-upgrade-prompt.test.ts | 47 +++++++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 9b8aed40576..aef61cf8d07 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2296,6 +2296,7 @@ preinstall_backup_and_retire_legacy_gateway() { prefer_user_local_openshell fi command_exists openshell || return 0 + require_reportable_openshell_version if [[ "${NEMOCLAW_SINGLE_SESSION:-}" == "1" ]]; then error "Aborting — NEMOCLAW_SINGLE_SESSION is set. Destroy existing sessions with '${_CLI_BIN} destroy' before reinstalling." diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index ffb459ea0fd..5809e812864 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -105,6 +105,7 @@ function runPreinstallUpgradeGuard( hasOldCli?: boolean; openshellOnPath?: boolean; openshellVersion?: string; + openshellVersionCommandFails?: boolean; registryJson?: string; userLocalOpenshell?: boolean; } = {}, @@ -118,6 +119,7 @@ function runPreinstallUpgradeGuard( const currentCli = path.join(bin, "nemoclaw-current"); const preparedFlag = path.join(tmp, "prepared-current-cli"); const currentSource = path.join(tmp, "current-source"); + const registry = path.join(home, ".nemoclaw", "sandboxes.json"); fs.mkdirSync(path.join(home, ".nemoclaw"), { recursive: true }); fs.mkdirSync(path.join(currentSource, "nemoclaw-blueprint"), { recursive: true }); @@ -126,16 +128,18 @@ function runPreinstallUpgradeGuard( path.join(currentSource, "nemoclaw-blueprint", "blueprint.yaml"), `min_openshell_version: "${options.currentMinOpenshellVersion ?? "0.0.85"}"\nmax_openshell_version: "0.0.85"\n`, ); - fs.writeFileSync( - path.join(home, ".nemoclaw", "sandboxes.json"), - options.registryJson ?? '{"sandboxes":{"alpha":{"name":"alpha"}}}', - ); + fs.writeFileSync(registry, options.registryJson ?? '{"sandboxes":{"alpha":{"name":"alpha"}}}'); const currentCliAvailable = options.currentCliAvailable === false ? "0" : "1"; const currentBackupSucceeds = options.currentBackupSucceeds === false ? "0" : "1"; const openshellVersion = options.openshellVersion ?? "0.0.36"; const gatewayDestroySucceeds = options.gatewayDestroySucceeds === true ? "1" : "0"; const gatewayProcessStopSucceeds = options.gatewayProcessStopSucceeds === false ? "0" : "1"; const gatewayRemoveSucceeds = options.gatewayRemoveSucceeds === false ? "0" : "1"; + const openshellVersionCommandFails = options.openshellVersionCommandFails === true ? "1" : "0"; + const installedOpenshellVersionOverride = + options.openshellVersionCommandFails === true + ? "" + : `installed_openshell_version() { printf '${openshellVersion}'; }`; writeExecutable( oldCli, @@ -162,6 +166,9 @@ exit 0 ); const openshellScript = `#!/usr/bin/env bash printf '%s\\n' "$*" >> "${openshellLog}" +if [ "\${1:-}" = "--version" ] && [ "${openshellVersionCommandFails}" = "1" ]; then + exit 7 +fi if [ "\${1:-} \${2:-}" = "gateway remove" ] && [ "${gatewayRemoveSucceeds}" != "1" ]; then exit 4 fi @@ -191,7 +198,7 @@ exit 0 _CLI_BIN=nemoclaw HOME="${home}" NEMOCLAW_SOURCE_ROOT="${currentSource}" - installed_openshell_version() { printf '${openshellVersion}'; } + ${installedOpenshellVersionOverride} stop_legacy_openshell_gateway_process() { printf 'gateway process-stop\n' >> "${openshellLog}" [ "${gatewayProcessStopSucceeds}" = "1" ] @@ -234,6 +241,7 @@ exit 0 result, cliLog: fs.existsSync(cliLog) ? fs.readFileSync(cliLog, "utf-8") : "", openshellLog: fs.existsSync(openshellLog) ? fs.readFileSync(openshellLog, "utf-8") : "", + registry: fs.readFileSync(registry, "utf-8"), }; } @@ -598,6 +606,27 @@ require_reportable_openshell_version`, expect(openshellLog).toContain("gateway remove nemoclaw"); }); + it("rejects a broken user-local OpenShell before preparing recovery (#7300)", () => { + const registryJson = '{"sandboxes":{"alpha":{"name":"alpha"}}}'; + const { result, cliLog, openshellLog, registry } = runPreinstallUpgradeGuard( + { NON_INTERACTIVE: "1" }, + { + hasOldCli: false, + openshellOnPath: false, + openshellVersionCommandFails: true, + registryJson, + userLocalOpenshell: true, + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stdout + result.stderr).toContain("could not report its version"); + expect(cliLog).toBe(""); + expect(openshellLog.split(/\r?\n/)).toContain("--version"); + expect(openshellLog).not.toContain("gateway"); + expect(registry).toBe(registryJson); + }); + it("leaves recovery preparation untouched when OpenShell is not installed (#6114)", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( { NON_INTERACTIVE: "1" }, @@ -684,7 +713,7 @@ require_reportable_openshell_version`, expect(openshellLog).toBe(""); }); - it("fails closed after backup when the installed OpenShell version is unknown", () => { + it("fails closed before backup when the installed OpenShell version is unknown", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( { NON_INTERACTIVE: "1" }, { @@ -696,10 +725,8 @@ require_reportable_openshell_version`, ); expect(result.status).not.toBe(0); - expect(result.stdout + result.stderr).toContain( - "Could not determine the installed OpenShell version", - ); - expect(cliLog.split(/\r?\n/)).toContain("current:backup-all"); + expect(result.stdout + result.stderr).toContain("could not report its version"); + expect(cliLog).toBe(""); expect(openshellLog).toBe(""); }); From 3947c6698d5530db3eddf42ad6cb12046f1e8d91 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 24 Jul 2026 21:43:19 -0700 Subject: [PATCH 11/12] docs(installer): clarify OpenShell recovery ordering Signed-off-by: Aaron Erickson --- docs/get-started/quickstart.mdx | 5 +++-- docs/manage-sandboxes/update-sandboxes.mdx | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 4aabb2e6412..4307dacd4eb 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -217,9 +217,10 @@ Use these details when your first-run path needs more control. This build-time setting is baked into the image and setting it after onboarding does not affect an existing sandbox. - If registered sandboxes already exist, the installer prepares the current NemoClaw CLI without replacing OpenShell, requires a fresh backup of every registered sandbox before it changes the gateway, and runs `nemoclaw upgrade-sandboxes --auto` after the host upgrade. + If registered sandboxes already exist, the installer first requires any existing OpenShell executable it will use to report a version. + Only after that validation does it prepare the current NemoClaw CLI without replacing OpenShell and require a fresh backup of every registered sandbox before it changes the gateway. After backup, it retires the running gateway before replacing OpenShell only when the installed OpenShell version is outside the current release's supported range; an unknown installed version or an invalid or missing range stops the update without retiring the gateway, while any retirement failure stops the update with the sandbox backups preserved. - Successful recovery rebuilds stale sandboxes, restores validated backups for registered sandboxes that are not Ready, and skips generic onboarding rather than creating an additional sandbox or requesting a new provider credential. + After the host upgrade, it runs `nemoclaw upgrade-sandboxes --auto`; successful recovery rebuilds stale sandboxes, restores validated backups for registered sandboxes that are not Ready, and skips generic onboarding rather than creating an additional sandbox or requesting a new provider credential. If the recovery pass exits 0 but a recorded sandbox is not found on its own recorded gateway, such as after `nemoclaw uninstall` removed the gateway and Docker image while preserving `sandboxes.json`, the installer finishes with `Installation completed with warnings` and remediation guidance instead of claiming the sandbox was recovered. For pre-fingerprint OpenClaw and Hermes registry entries, confirm that every listed sandbox used a NemoClaw-managed image before recovery onto the current managed image. In non-interactive runs, set `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` to the exact JSON array of printed names only after you verify every named sandbox used a managed image. diff --git a/docs/manage-sandboxes/update-sandboxes.mdx b/docs/manage-sandboxes/update-sandboxes.mdx index 209da17758d..dd3e14a5a6a 100644 --- a/docs/manage-sandboxes/update-sandboxes.mdx +++ b/docs/manage-sandboxes/update-sandboxes.mdx @@ -49,7 +49,8 @@ If a support workflow asks you to pass the maintained tag explicitly, clear any curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSTALL_REF= NEMOCLAW_INSTALL_TAG=lkg bash ``` -Before upgrade work, the installer prepares the current NemoClaw CLI without replacing OpenShell and requires a fresh backup of every registered sandbox. +Before it prepares recovery or creates backups, the installer requires any existing OpenShell executable it will use to report a version. +Only after that validation does it prepare the current NemoClaw CLI without replacing OpenShell and require a fresh backup of every registered sandbox. If any sandbox is skipped or fails, the installer exits before it changes the gateway. After backup, the installer compares the installed OpenShell version with the supported range declared by the prepared current source. It retires the running gateway before replacing an out-of-range OpenShell installation, keeps the gateway when the installed version is supported, and stops without retiring it when the installed version or supported range cannot be validated. From 4c74a77b33ea3b960c8c4c855a8a35a603ea890d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 24 Jul 2026 21:52:06 -0700 Subject: [PATCH 12/12] fix(installer): preserve backup before version rejection Signed-off-by: Aaron Erickson --- docs/get-started/quickstart.mdx | 6 +- docs/manage-sandboxes/update-sandboxes.mdx | 5 +- scripts/install.sh | 4 +- test/install-openshell-upgrade-prompt.test.ts | 67 +++++++++++-------- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 4307dacd4eb..1e8490e0b69 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -217,9 +217,9 @@ Use these details when your first-run path needs more control. This build-time setting is baked into the image and setting it after onboarding does not affect an existing sandbox. - If registered sandboxes already exist, the installer first requires any existing OpenShell executable it will use to report a version. - Only after that validation does it prepare the current NemoClaw CLI without replacing OpenShell and require a fresh backup of every registered sandbox before it changes the gateway. - After backup, it retires the running gateway before replacing OpenShell only when the installed OpenShell version is outside the current release's supported range; an unknown installed version or an invalid or missing range stops the update without retiring the gateway, while any retirement failure stops the update with the sandbox backups preserved. + If registered sandboxes already exist, the installer prepares the current NemoClaw CLI without replacing OpenShell and requires a fresh backup of every registered sandbox before it changes the gateway. + After backup, it requires any existing OpenShell executable it will use to report a version and compares that version with the current release's supported range. + It retires the running gateway before replacing OpenShell only when that version is outside the supported range; an unknown installed version or an invalid or missing range stops the update without retiring the gateway, while any retirement failure stops the update with the sandbox backups preserved. After the host upgrade, it runs `nemoclaw upgrade-sandboxes --auto`; successful recovery rebuilds stale sandboxes, restores validated backups for registered sandboxes that are not Ready, and skips generic onboarding rather than creating an additional sandbox or requesting a new provider credential. If the recovery pass exits 0 but a recorded sandbox is not found on its own recorded gateway, such as after `nemoclaw uninstall` removed the gateway and Docker image while preserving `sandboxes.json`, the installer finishes with `Installation completed with warnings` and remediation guidance instead of claiming the sandbox was recovered. For pre-fingerprint OpenClaw and Hermes registry entries, confirm that every listed sandbox used a NemoClaw-managed image before recovery onto the current managed image. diff --git a/docs/manage-sandboxes/update-sandboxes.mdx b/docs/manage-sandboxes/update-sandboxes.mdx index dd3e14a5a6a..bfb6800b545 100644 --- a/docs/manage-sandboxes/update-sandboxes.mdx +++ b/docs/manage-sandboxes/update-sandboxes.mdx @@ -49,10 +49,9 @@ If a support workflow asks you to pass the maintained tag explicitly, clear any curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSTALL_REF= NEMOCLAW_INSTALL_TAG=lkg bash ``` -Before it prepares recovery or creates backups, the installer requires any existing OpenShell executable it will use to report a version. -Only after that validation does it prepare the current NemoClaw CLI without replacing OpenShell and require a fresh backup of every registered sandbox. +Before upgrade work, the installer prepares the current NemoClaw CLI without replacing OpenShell and requires a fresh backup of every registered sandbox. If any sandbox is skipped or fails, the installer exits before it changes the gateway. -After backup, the installer compares the installed OpenShell version with the supported range declared by the prepared current source. +After backup, the installer requires any existing OpenShell executable it will use to report a version, then compares that version with the supported range declared by the prepared current source. It retires the running gateway before replacing an out-of-range OpenShell installation, keeps the gateway when the installed version is supported, and stops without retiring it when the installed version or supported range cannot be validated. If the installed OpenShell release cannot retire its gateway through a supported lifecycle command or the verified NemoClaw-owned gateway PID, the installer also stops after backup with the sandbox backups preserved. diff --git a/scripts/install.sh b/scripts/install.sh index aef61cf8d07..1d53f298388 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2010,7 +2010,7 @@ installed_openshell_version() { } # Fail closed when OpenShell is present but cannot report its version. An absent -# binary is valid before install_nemoclaw; its caller validates again afterward. +# binary is valid because OpenShell installation can be deferred. require_reportable_openshell_version() { command_exists openshell || return 0 [ -n "$(installed_openshell_version 2>/dev/null || true)" ] && return 0 @@ -2296,7 +2296,6 @@ preinstall_backup_and_retire_legacy_gateway() { prefer_user_local_openshell fi command_exists openshell || return 0 - require_reportable_openshell_version if [[ "${NEMOCLAW_SINGLE_SESSION:-}" == "1" ]]; then error "Aborting — NEMOCLAW_SINGLE_SESSION is set. Destroy existing sessions with '${_CLI_BIN} destroy' before reinstalling." @@ -4055,7 +4054,6 @@ main() { # `nemoclaw onboard` (the install-ollama / install-vllm branches). # install.sh stays focused on dependency setup. fix_npm_permissions - require_reportable_openshell_version preinstall_backup_and_retire_legacy_gateway install_nemoclaw verify_nemoclaw diff --git a/test/install-openshell-upgrade-prompt.test.ts b/test/install-openshell-upgrade-prompt.test.ts index 5809e812864..af0e30e3c62 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -24,6 +24,7 @@ function runInstallerOpenshellVersionFlow( const setupDir = path.join(tmp, "setup"); const registry = path.join(home, ".nemoclaw", "sandboxes.json"); const gatewayState = path.join(tmp, "gateway.state"); + const backupLog = path.join(tmp, "backup.log"); const installLog = path.join(tmp, "install.log"); const healthyOpenshell = path.join(tmp, "healthy-openshell"); @@ -61,6 +62,10 @@ install_nodejs() { :; } ensure_supported_runtime() { :; } fix_npm_permissions() { :; } preinstall_backup_and_retire_legacy_gateway() { + command_exists openshell || return 0 + printf 'backup\\n' >>"${backupLog}" + [ -n "$(installed_openshell_version 2>/dev/null || true)" ] || + error "Could not determine the installed OpenShell version. The installer stopped after backup without retiring the gateway." printf 'gateway-retired\\n' >"${gatewayState}" printf '{"sandboxes":{}}\\n' >"${registry}" } @@ -86,6 +91,7 @@ main --non-interactive --yes-i-accept-third-party-software`, return { result, + backupLog: fs.existsSync(backupLog) ? fs.readFileSync(backupLog, "utf-8") : "", gatewayState: fs.readFileSync(gatewayState, "utf-8"), registry: fs.readFileSync(registry, "utf-8"), installLog: fs.existsSync(installLog) ? fs.readFileSync(installLog, "utf-8") : "", @@ -325,43 +331,43 @@ require_reportable_openshell_version`, const versionPrintingBrokenOpenshell = '#!/usr/bin/env bash\n[ "$1" = "--version" ] && echo "openshell 0.0.85"\nexit 1\n'; - it("rejects a broken OpenShell before gateway or sandbox mutation (#7300)", () => { - const { result, gatewayState, registry, installLog } = runInstallerOpenshellVersionFlow( - (bin) => { + it("backs up before rejecting a broken OpenShell without gateway or sandbox mutation (#7300)", () => { + const { result, backupLog, gatewayState, registry, installLog } = + runInstallerOpenshellVersionFlow((bin) => { writeExecutable(path.join(bin, "openshell"), brokenOpenshell); - }, - ); + }); expect(result.status, result.stdout + result.stderr).not.toBe(0); - expect(result.stderr + result.stdout).toContain("could not report its version"); + expect(result.stderr + result.stdout).toContain("stopped after backup"); + expect(backupLog).toBe("backup\n"); expect(gatewayState).toBe("gateway-original\n"); expect(registry).toBe('{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); expect(installLog).toBe(""); }); - it("rejects a failed OpenShell version command before gateway or sandbox mutation (#7300)", () => { - const { result, gatewayState, registry, installLog } = runInstallerOpenshellVersionFlow( - (bin) => { + it("backs up before rejecting a failed OpenShell version command without mutation (#7300)", () => { + const { result, backupLog, gatewayState, registry, installLog } = + runInstallerOpenshellVersionFlow((bin) => { writeExecutable(path.join(bin, "openshell"), versionPrintingBrokenOpenshell); - }, - ); + }); expect(result.status, result.stdout + result.stderr).not.toBe(0); - expect(result.stderr + result.stdout).toContain("could not report its version"); + expect(result.stderr + result.stdout).toContain("stopped after backup"); + expect(backupLog).toBe("backup\n"); expect(gatewayState).toBe("gateway-original\n"); expect(registry).toBe('{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); expect(installLog).toBe(""); }); - it("rejects invalid OpenShell version output before gateway or sandbox mutation (#7300)", () => { - const { result, gatewayState, registry, installLog } = runInstallerOpenshellVersionFlow( - (bin) => { + it("backs up before rejecting invalid OpenShell version output without mutation (#7300)", () => { + const { result, backupLog, gatewayState, registry, installLog } = + runInstallerOpenshellVersionFlow((bin) => { writeExecutable(path.join(bin, "openshell"), invalidVersionOpenshell); - }, - ); + }); expect(result.status, result.stdout + result.stderr).not.toBe(0); - expect(result.stderr + result.stdout).toContain("could not report its version"); + expect(result.stderr + result.stdout).toContain("stopped after backup"); + expect(backupLog).toBe("backup\n"); expect(gatewayState).toBe("gateway-original\n"); expect(registry).toBe('{"sandboxes":{"alpha":{"name":"alpha"}}}\n'); expect(installLog).toBe(""); @@ -606,8 +612,9 @@ require_reportable_openshell_version`, expect(openshellLog).toContain("gateway remove nemoclaw"); }); - it("rejects a broken user-local OpenShell before preparing recovery (#7300)", () => { - const registryJson = '{"sandboxes":{"alpha":{"name":"alpha"}}}'; + it("backs up before rejecting a broken user-local OpenShell without retiring the gateway (#7300)", () => { + const registryJson = + '{"sandboxes":{"alpha":{"name":"alpha","nemoclawVersion":"0.0.85","fromDockerfile":false}}}'; const { result, cliLog, openshellLog, registry } = runPreinstallUpgradeGuard( { NON_INTERACTIVE: "1" }, { @@ -620,8 +627,8 @@ require_reportable_openshell_version`, ); expect(result.status).not.toBe(0); - expect(result.stdout + result.stderr).toContain("could not report its version"); - expect(cliLog).toBe(""); + expect(result.stdout + result.stderr).toContain("stopped after backup"); + expect(cliLog.split(/\r?\n/)).toContain("current:backup-all"); expect(openshellLog.split(/\r?\n/)).toContain("--version"); expect(openshellLog).not.toContain("gateway"); expect(registry).toBe(registryJson); @@ -713,21 +720,25 @@ require_reportable_openshell_version`, expect(openshellLog).toBe(""); }); - it("fails closed before backup when the installed OpenShell version is unknown", () => { - const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( + it("fails closed after backup when the installed OpenShell version is unknown", () => { + const registryJson = + '{"sandboxes":{"alpha":{"name":"alpha","nemoclawVersion":"0.0.85","fromDockerfile":false}}}'; + const { result, cliLog, openshellLog, registry } = runPreinstallUpgradeGuard( { NON_INTERACTIVE: "1" }, { hasOldCli: false, openshellVersion: "", - registryJson: - '{"sandboxes":{"alpha":{"name":"alpha","nemoclawVersion":"0.0.85","fromDockerfile":false}}}', + registryJson, }, ); expect(result.status).not.toBe(0); - expect(result.stdout + result.stderr).toContain("could not report its version"); - expect(cliLog).toBe(""); + expect(result.stdout + result.stderr).toContain( + "Could not determine the installed OpenShell version", + ); + expect(cliLog.split(/\r?\n/)).toContain("current:backup-all"); expect(openshellLog).toBe(""); + expect(registry).toBe(registryJson); }); it("uses a supported legacy destroy verb without stopping a recorded host process", () => {