From 4a745441e18cfa5590aeb305e30023de049292ad Mon Sep 17 00:00:00 2001 From: latenighthackathon Date: Wed, 29 Apr 2026 21:03:38 +0000 Subject: [PATCH 1/2] fix(install): fail fast on license gate so partial install can't land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain `curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash` (no flags) in non-TTY mode runs phases [1/3] Node.js + [2/3] NemoClaw CLI to completion before failing at the third-party-software acceptance step in phase [3/3]. The user is left in a half-installed state — the nemoclaw binary is on PATH and `--version` works, but ~/.nemoclaw/usage-notice.json was never created (license never accepted), and onboard cannot proceed. A retry hits the same failure because the license helper still has nothing to read from. The license gate already knew it could not succeed: in show_usage_notice() the failure is deterministic when stdin is not a TTY, /dev/tty is unopenable, NON_INTERACTIVE != 1, and ACCEPT_THIRD_PARTY_SOFTWARE != 1. Hoist that same check to the top of main(), right after flag parsing, so the install errors out BEFORE phases 1/2 leave artifacts on disk. Same friendly error message as before — just emitted before any state changes. Effect on the user-visible flow: - `curl|bash` (no flags) on a server with no /dev/tty: fail-fast, exit 1 with the existing TTY hint, no Node.js install attempted, no nemoclaw on PATH. Retry-after-fixing-the-flag is now clean. - `curl|bash --yes-i-accept-third-party-software` (post #2670): clear, install proceeds. - `curl|bash --non-interactive`: clear, install proceeds. - Interactive run on a desktop terminal: clear, prompt as before. - curl|bash piped from a desktop where /dev/tty IS openable: clear, show_usage_notice falls back to /dev/tty input as before. - `npx vitest run --project installer-integration -t "installer atomicity"` — 3 new sourced tests: - #2671: curl|bash with no flags exits 1 BEFORE phase 1 — stub node/npm/docker record every invocation; assertion that the log stays empty is the atomicity proof. - --yes-i-accept-third-party-software alone clears the gate (regression-guard against an over-aggressive check). - --non-interactive alone clears the gate (same). - `attempts nvm upgrade when system Node.js is below minimum version` needed `NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1` added to its env — the test exercises the Node-version path which is unrelated to license acceptance, and now (correctly) cannot reach Node detection without bypassing the license gate first. - Full installer-integration suite: 71/71 pass (prev. 70 + 3 new − 1 test re-bypassed = 71). Fixes #2671. Signed-off-by: latenighthackathon --- scripts/install.sh | 19 +++++++ test/install-preflight.test.ts | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index b838a605463..e2201146e53 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1661,6 +1661,25 @@ main() { 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 + # show_usage_notice() will hit the "requires a TTY" branch later in + # phase 3, 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 — license helper runs non-interactively in phase 3 + # - ACCEPT_THIRD_PARTY_SOFTWARE=1 — license is auto-accepted in phase 3 (#2670) + # - 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" ] \ + && [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "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..ce3db44bedc 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,93 @@ 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", () => { + // Sanity: with the #2670 fix, this flag alone should NOT trigger the new + // fail-fast gate. (Whether the install ultimately succeeds depends on + // unrelated phases — we only assert that the gate doesn't preempt it.) + const { result } = 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/); + }); + + it("--non-interactive alone is sufficient to clear the fail-fast gate", () => { + const { result } = runInstaller({ NEMOCLAW_NON_INTERACTIVE: "1" }); + const output = `${result.stdout}${result.stderr}`; + expect(output).not.toMatch(/Interactive third-party software acceptance requires a TTY/); + }); +}); From 046476943c2fa713f2046bf731211a1911764463 Mon Sep 17 00:00:00 2001 From: latenighthackathon Date: Wed, 29 Apr 2026 23:51:58 +0000 Subject: [PATCH 2/2] fix(install): treat acceptance flag as non-interactive intent CodeRabbit flagged that ACCEPT_THIRD_PARTY_SOFTWARE=1 alone bypassed the fail-fast preflight, but show_usage_notice is only one of two phase-3 steps that need a TTY or --non-interactive. run_onboard has the same gate, so a curl|bash run with --yes-i-accept-third-party-software but no --non-interactive could still partial-fail at run_onboard, leaving phases 1/2 on disk anyway. Treat the acceptance flag as non-interactive intent: when ACCEPT_THIRD_PARTY_SOFTWARE=1, set NON_INTERACTIVE=1 early in main() before the preflight check runs. The user already said "yes I accept" non-interactively; assuming they also want the rest of the install non-interactive matches the curl|bash use case the flag was named for. Tighten the preflight to no longer skip on the acceptance flag alone (it's now redundant since acceptance implies NON_INTERACTIVE=1). Also harden the bypass tests: assert phases !== "" in addition to absence of the TTY error message, so the tests can't false-pass if the install bailed for some other reason. - Re-ran installer-integration: 71/71 pass (4.5s atomicity tests). Signed-off-by: latenighthackathon --- scripts/install.sh | 27 ++++++++++++++++++--------- test/install-preflight.test.ts | 14 +++++++++----- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index e2201146e53..e5fbada3276 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1658,23 +1658,32 @@ 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 - # show_usage_notice() will hit the "requires a TTY" branch later in - # phase 3, 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. + # 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 — license helper runs non-interactively in phase 3 - # - ACCEPT_THIRD_PARTY_SOFTWARE=1 — license is auto-accepted in phase 3 (#2670) + # - 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" ] \ - && [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "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)." diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index ce3db44bedc..73abab16cb6 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -2984,17 +2984,21 @@ exit 0`, }); it("--yes-i-accept-third-party-software alone is sufficient to clear the fail-fast gate", () => { - // Sanity: with the #2670 fix, this flag alone should NOT trigger the new - // fail-fast gate. (Whether the install ultimately succeeds depends on - // unrelated phases — we only assert that the gate doesn't preempt it.) - const { result } = runInstaller({ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" }); + // 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 } = runInstaller({ NEMOCLAW_NON_INTERACTIVE: "1" }); + 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(""); }); });