diff --git a/scripts/install.sh b/scripts/install.sh index b838a605463..e5fbada3276 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1658,9 +1658,37 @@ main() { NON_INTERACTIVE="${NON_INTERACTIVE:-${NEMOCLAW_NON_INTERACTIVE:-}}" ACCEPT_THIRD_PARTY_SOFTWARE="${ACCEPT_THIRD_PARTY_SOFTWARE:-${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}}" FRESH="${FRESH:-${NEMOCLAW_FRESH:-}}" + + # If the user explicitly accepted the third-party-software notice, treat + # that as non-interactive intent for the rest of the run too — show_usage_notice + # is only one of several phase-3 steps that need a TTY or --non-interactive + # (run_onboard has the same gate). Without this, ACCEPT_THIRD_PARTY_SOFTWARE=1 + # alone clears the preflight below but the install can still partial-fail at + # run_onboard with the same TTY error, leaving phases 1/2 on disk anyway. + if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ] && [ "${NON_INTERACTIVE:-}" != "1" ]; then + NON_INTERACTIVE=1 + fi + export NEMOCLAW_NON_INTERACTIVE="${NON_INTERACTIVE}" export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE="${ACCEPT_THIRD_PARTY_SOFTWARE}" + # Fail-fast license-acceptance check (#2671). If we already know phase 3 + # (show_usage_notice + run_onboard) will hit the "requires a TTY" branch, + # surface that error NOW — before phases 1/2 install Node.js and put the + # nemoclaw CLI on PATH. Otherwise the user is left in a partial install + # that they have to manually `rm -rf` before retry, while their license + # has not actually been accepted. + # + # Skipped (and the install proceeds) when any of: + # - NON_INTERACTIVE=1 (also implied by ACCEPT_THIRD_PARTY_SOFTWARE=1 above) + # - stdin is a TTY — license helper prompts the user directly + # - /dev/tty is openable — show_usage_notice falls back to /dev/tty input + if [ "${NON_INTERACTIVE:-}" != "1" ] \ + && [ ! -t 0 ] \ + && ! (: /dev/null; then + error "Interactive third-party software acceptance requires a TTY. Re-run in a terminal or pass --yes-i-accept-third-party-software (or set NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1)." + fi + _INSTALL_START=$SECONDS print_banner bash "${SCRIPT_DIR}/setup-jetson.sh" diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 602eff2bc23..73abab16cb6 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -157,6 +157,9 @@ exit 1 ...process.env, HOME: tmp, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + // Bypass the #2671 fail-fast license gate — this test exercises the + // Node-version-detection / nvm-upgrade path, not the license path. + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", }, }); @@ -2905,3 +2908,97 @@ exit 0`, expect(`${result.stdout}${result.stderr}`).not.toMatch(/Cannot find module .*usage-notice\.js/); }); }); + +describe("installer atomicity (#2671)", () => { + /** + * Run scripts/install.sh main() with stubbed phase-1 and phase-2 binaries + * that record invocation to a marker file. Tests assert whether install + * reaches phase 1/2 or short-circuits at the fail-fast license gate. + */ + function runInstaller(env: Record, options: { stdinIsTty?: boolean } = {}) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-2671-")); + const fakeBin = path.join(tmp, "bin"); + const phaseLog = path.join(tmp, "phases.log"); + fs.mkdirSync(fakeBin); + + // Stub node + npm — both record their own invocation so we can detect + // whether phase 1 (install_nodejs) or phase 2 (install_nemoclaw) ran. + writeExecutable( + path.join(fakeBin, "node"), + `#!/usr/bin/env bash +echo "node $*" >> ${JSON.stringify(phaseLog)} +if [ "$1" = "-v" ] || [ "$1" = "--version" ]; then echo "v22.16.0"; exit 0; fi +if [ -n "\${1:-}" ] && [ -f "$1" ]; then exit 0; fi +exit 0`, + ); + writeExecutable( + path.join(fakeBin, "npm"), + `#!/usr/bin/env bash +echo "npm $*" >> ${JSON.stringify(phaseLog)} +if [ "$1" = "--version" ]; then echo "10.9.2"; exit 0; fi +if [ "$1" = "config" ] && [ "$2" = "get" ] && [ "$3" = "prefix" ]; then echo "${path.join(tmp, "prefix")}"; exit 0; fi +exit 0`, + ); + writeExecutable( + path.join(fakeBin, "docker"), + `#!/usr/bin/env bash +echo "docker $*" >> ${JSON.stringify(phaseLog)} +exit 0`, + ); + + // Run main() directly via the bash entrypoint check. We force stdin to + // /dev/null when stdinIsTty is false (default — simulates curl|bash). + const result = spawnSync( + "bash", + [INSTALLER_PAYLOAD], + { + cwd: tmp, + encoding: "utf-8", + // input: "" makes spawnSync attach a non-TTY stdin pipe — equivalent + // to curl|bash for the purposes of [ -t 0 ] and /dev/tty in CI. + input: options.stdinIsTty ? undefined : "", + env: { + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + ...env, + }, + }, + ); + const phases = fs.existsSync(phaseLog) ? fs.readFileSync(phaseLog, "utf-8") : ""; + return { result, phases, tmp }; + } + + it("#2671: curl|bash with no flags exits 1 BEFORE phase 1 (atomic — no Node/CLI install)", () => { + const { result, phases } = runInstaller({}); + expect(result.status).not.toBe(0); + const output = `${result.stdout}${result.stderr}`; + expect(output).toMatch(/Interactive third-party software acceptance requires a TTY/); + expect(output).toMatch(/--yes-i-accept-third-party-software/); + // Phase 1 (Node.js install) and phase 2 (CLI install) must NOT have run — + // the whole point of the fix is that a license-fail leaves no half-install behind. + expect(output).not.toMatch(/\[1\/3\] Node\.js/); + expect(output).not.toMatch(/\[2\/3\] NemoClaw CLI/); + // Stub binaries record every invocation; if phase 1 or 2 ran, node and/or + // npm would have been called. The fail-fast check runs before either. + expect(phases).toBe(""); + }); + + it("--yes-i-accept-third-party-software alone is sufficient to clear the fail-fast gate", () => { + // The flag implies non-interactive intent (set by main() before the + // preflight check), so it must clear the gate AND let the install + // progress past preflight into phase 1 — assert phases is non-empty + // so the test doesn't false-pass if the install bailed for some other + // reason while the TTY error happened to be absent from output. + const { result, phases } = runInstaller({ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" }); + const output = `${result.stdout}${result.stderr}`; + expect(output).not.toMatch(/Interactive third-party software acceptance requires a TTY/); + expect(phases).not.toBe(""); + }); + + it("--non-interactive alone is sufficient to clear the fail-fast gate", () => { + const { result, phases } = runInstaller({ NEMOCLAW_NON_INTERACTIVE: "1" }); + const output = `${result.stdout}${result.stderr}`; + expect(output).not.toMatch(/Interactive third-party software acceptance requires a TTY/); + expect(phases).not.toBe(""); + }); +});