diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 4aabb2e6412..1e8490e0b69 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. - 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. + 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. 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..bfb6800b545 100644 --- a/docs/manage-sandboxes/update-sandboxes.mdx +++ b/docs/manage-sandboxes/update-sandboxes.mdx @@ -51,7 +51,7 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSTALL_REF= NEMOCLAW_I 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 0987e3b430b..1d53f298388 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2001,9 +2001,20 @@ 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 - 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 +# 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 + 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() { @@ -4046,6 +4057,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 6a2956a0968..af0e30e3c62 100644 --- a/test/install-openshell-upgrade-prompt.test.ts +++ b/test/install-openshell-upgrade-prompt.test.ts @@ -14,6 +14,91 @@ function writeExecutable(target: string, contents: string): void { fs.writeFileSync(target, contents, { mode: 0o755 }); } +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"); + 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"); + + fs.mkdirSync(path.dirname(registry), { recursive: true }); + fs.mkdirSync(bin, { 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 +handle_station_vllm_conflict() { :; } +consume_station_local_vllm_resume() { return 1; } +`, + ); + writeExecutable(healthyOpenshell, installedOpenshellBody); + setupOpenshell(bin); + + const result = spawnSync( + "bash", + [ + "-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() { :; } +preflight_usage_notice_prompt() { :; } +prepare_installer_host() { :; } +step() { :; } +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}" +} +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, + 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") : "", + openshellBody: fs.readFileSync(path.join(bin, "openshell"), "utf-8"), + }; +} + function runPreinstallUpgradeGuard( env: Record = {}, options: { @@ -26,6 +111,7 @@ function runPreinstallUpgradeGuard( hasOldCli?: boolean; openshellOnPath?: boolean; openshellVersion?: string; + openshellVersionCommandFails?: boolean; registryJson?: string; userLocalOpenshell?: boolean; } = {}, @@ -39,6 +125,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 }); @@ -47,16 +134,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, @@ -83,6 +172,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 @@ -112,7 +204,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" ] @@ -155,6 +247,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"), }; } @@ -213,6 +306,134 @@ 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'; + 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'; + + 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("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("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("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("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("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("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); + + expect(result.status, result.stdout + result.stderr).toBe(0); + 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)", + () => { + 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"); + }, + ); + + it.skipIf(process.platform !== "linux")( + "cannot be bypassed by NEMOCLAW_DEFER_OPENSHELL_INSTALL (#7300)", + () => { + const result = runOpenshellVersionGate( + brokenOpenshell, + "export NEMOCLAW_DEFER_OPENSHELL_INSTALL=1", + ); + + 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"); + }, + ); + it("aborts non-interactive legacy gateway upgrades without explicit opt-in", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard({ NON_INTERACTIVE: "1", @@ -391,6 +612,28 @@ test ! -e "${pidFile}"`, expect(openshellLog).toContain("gateway remove nemoclaw"); }); + 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" }, + { + hasOldCli: false, + openshellOnPath: false, + openshellVersionCommandFails: true, + registryJson, + userLocalOpenshell: true, + }, + ); + + expect(result.status).not.toBe(0); + 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); + }); + it("leaves recovery preparation untouched when OpenShell is not installed (#6114)", () => { const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( { NON_INTERACTIVE: "1" }, @@ -478,13 +721,14 @@ test ! -e "${pidFile}"`, }); it("fails closed after backup when the installed OpenShell version is unknown", () => { - const { result, cliLog, openshellLog } = runPreinstallUpgradeGuard( + 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, }, ); @@ -494,6 +738,7 @@ test ! -e "${pidFile}"`, ); 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", () => {