From 880f4c20eb99f7e1f731f9fa8c8bde9de3e817e3 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 28 May 2026 14:29:18 -0700 Subject: [PATCH 1/3] fix(install): stage installer to tmpfile when invoked via curl|bash (#4414) The sg(1) re-exec added in #4419 only fires when [ -f "$self" ] succeeds, which means it skips the literal `curl ... | bash` repro from #4414: BASH_SOURCE[0] is empty and $0="bash", so there's no script file to point re-exec at and the script falls through to the legacy newgrp/re-curl path. Stage the script to a tmpfile early in the entry guard by re-curling the canonical URL (overridable via NEMOCLAW_INSTALLER_URL), then exec bash on the staged file. ensure_docker now sees BASH_SOURCE[0] pointing at a real file and the sg(1) re-exec from #4419 can finish the install in a single non-interactive invocation. Guards: - only fires when BASH_SOURCE is empty (pipe mode) - NEMOCLAW_INSTALLER_STAGED=1 one-shot loop guard - mktemp/curl/empty-download failures fall through to legacy direct-main - staged tmpfile auto-cleaned on EXIT via _cleanup_files Verified on Ubuntu 22.04 brev box that #4419 alone is silent for `cat install.sh | bash -s -- ...`; together with this fix the sg(1) re-exec finds the staged file and runs. Signed-off-by: Charan Jagwani --- scripts/install.sh | 28 +++++ test/install-stage-from-stdin.test.ts | 163 ++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/install-stage-from-stdin.test.ts diff --git a/scripts/install.sh b/scripts/install.sh index 193c1399b8..0365721c02 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -12,6 +12,14 @@ set -euo pipefail # are removed on any exit path (set -e, unhandled signal, unexpected error). _cleanup_pids=() _cleanup_files=() +# #4414: When re-launched as a staged copy after a `curl | bash` invocation, +# queue the staged tmpfile for removal on EXIT so we don't leave installer +# scripts in /tmp. +if [[ "${NEMOCLAW_INSTALLER_STAGED:-}" == "1" ]] \ + && [[ -n "${BASH_SOURCE[0]:-}" ]] \ + && [[ "${BASH_SOURCE[0]}" == /tmp/nemoclaw-installer-* ]]; then + _cleanup_files+=("${BASH_SOURCE[0]}") +fi _global_cleanup() { for pid in "${_cleanup_pids[@]:-}"; do kill "$pid" 2>/dev/null || true @@ -2476,5 +2484,25 @@ main() { } if [[ "${BASH_SOURCE[0]:-}" == "$0" ]] || { [[ -z "${BASH_SOURCE[0]:-}" ]] && { [[ "$0" == "bash" ]] || [[ "$0" == "-bash" ]]; }; }; then + # #4414: When invoked via `curl ... | bash`, BASH_SOURCE is empty and + # $0="bash". ensure_docker's sg(1) re-exec (#4419) needs a real script + # file to point bash at; without one it falls back to the legacy + # newgrp/re-curl path. Stage the installer to a tmpfile by re-curling + # the canonical URL so the sg(1) re-exec has a file to execute. + # NEMOCLAW_INSTALLER_STAGED=1 stops a second staging attempt if the + # staged copy still falls through ensure_docker's re-exec branch. + if [[ -z "${BASH_SOURCE[0]:-}" ]] && [[ "${NEMOCLAW_INSTALLER_STAGED:-}" != "1" ]]; then + _installer_url="${NEMOCLAW_INSTALLER_URL:-https://www.nvidia.com/nemoclaw.sh}" + if _staged="$(mktemp /tmp/nemoclaw-installer-XXXXXX 2>/dev/null)" \ + && curl -fsSL "$_installer_url" -o "$_staged" 2>/dev/null \ + && [[ -s "$_staged" ]]; then + chmod +x "$_staged" + export NEMOCLAW_INSTALLER_STAGED=1 + exec bash "$_staged" "$@" + fi + # Staging failed (mktemp / curl / empty download) — fall through to + # direct main(). The legacy newgrp/re-curl path still applies. + rm -f "${_staged:-}" 2>/dev/null + fi main "$@" fi diff --git a/test/install-stage-from-stdin.test.ts b/test/install-stage-from-stdin.test.ts new file mode 100644 index 0000000000..4691718b71 --- /dev/null +++ b/test/install-stage-from-stdin.test.ts @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { describe, expect, it } from "vitest"; + +type StagingOutcome = { + status: number | null; + stdout: string; + stderr: string; + execIntent: string[]; // argv that would have been exec'd, captured instead of exec'd + stagedFileContent: string | null; +}; + +// Inlines the entry-guard staging block from install.sh into a bash +// subshell, replacing `exec bash "$_staged" "$@"` with a capture step so +// the test sees the intended argv without actually launching a new +// installer process. Keep the inlined block in sync with +// scripts/install.sh:2486-2505. +function runEntryGuard(opts: { + bashSourceOverride?: string; // simulate disk-file invocation + envOverrides?: Record; + curlSucceeds?: boolean; + curlOutputContent?: string; +}): StagingOutcome { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-stage-")); + const execLog = path.join(tmp, "exec-intent.txt"); + const fallthrough = path.join(tmp, "fallthrough.flag"); + + // curl stub: writes a canned installer body to the -o target on success, + // or exits 22 on failure. Real curl is never invoked. + const curlStub = path.join(tmp, "curl"); + const stagedContent = opts.curlOutputContent ?? "#!/usr/bin/env bash\necho staged\n"; + const curlBody = opts.curlSucceeds === false + ? `#!/usr/bin/env bash\nexit 22\n` + : `#!/usr/bin/env bash\n` + + `out=""\n` + + `while [ $# -gt 0 ]; do\n` + + ` if [ "$1" = "-o" ]; then out="$2"; shift 2; continue; fi\n` + + ` shift\n` + + `done\n` + + `if [ -n "$out" ]; then\n` + + ` printf '%s' ${JSON.stringify(stagedContent)} > "$out"\n` + + `fi\nexit 0\n`; + fs.writeFileSync(curlStub, curlBody, { mode: 0o755 }); + + const envInject = Object.entries(opts.envOverrides ?? {}) + .map(([k, v]) => `export ${k}=${JSON.stringify(v)}`) + .join("\n"); + + // BASH_SOURCE is read-only inside a function but at the top level of a + // sourced/exec'd script its [0] entry is empty when bash reads from a + // pipe. We can't easily fake "empty" without actually piping, so the + // snippet checks a regular variable (_test_bash_source) instead and the + // production install.sh uses BASH_SOURCE[0]. They are read at the same + // point in execution, so the substitution is faithful. + const bashSourceExpr = opts.bashSourceOverride !== undefined + ? JSON.stringify(opts.bashSourceOverride) + : ""; + + const snippet = ` + set +e + export PATH=${JSON.stringify(tmp)}:"$PATH" + ${envInject} + set -- '--non-interactive' '--yes-i-accept-third-party-software' + + # ---- begin: inlined entry-guard staging block from install.sh ---- + _test_bash_source=${bashSourceExpr} + if [[ -z "$_test_bash_source" ]] && [[ "\${NEMOCLAW_INSTALLER_STAGED:-}" != "1" ]]; then + _installer_url="\${NEMOCLAW_INSTALLER_URL:-https://www.nvidia.com/nemoclaw.sh}" + if _staged="$(mktemp /tmp/nemoclaw-installer-XXXXXX 2>/dev/null)" \\ + && curl -fsSL "$_installer_url" -o "$_staged" 2>/dev/null \\ + && [[ -s "$_staged" ]]; then + chmod +x "$_staged" + export NEMOCLAW_INSTALLER_STAGED=1 + # TEST capture point: record the intended exec argv + the staged + # file's contents instead of actually exec'ing. + printf '%s\\n' "$_staged" "$@" > ${JSON.stringify(execLog)} + cp "$_staged" ${JSON.stringify(path.join(tmp, "staged-copy.sh"))} + exit 0 + fi + rm -f "\${_staged:-}" 2>/dev/null + fi + # ---- end: inlined entry-guard staging block ---- + : > ${JSON.stringify(fallthrough)} + exit 0 + `; + + const result = spawnSync("bash", ["-c", snippet], { + encoding: "utf-8", + timeout: 10_000, + }); + + const execIntent = fs.existsSync(execLog) + ? fs.readFileSync(execLog, "utf-8").split("\n").filter((line) => line.length > 0) + : []; + const stagedCopyPath = path.join(tmp, "staged-copy.sh"); + const stagedFileContent = fs.existsSync(stagedCopyPath) + ? fs.readFileSync(stagedCopyPath, "utf-8") + : null; + // If fallthrough flag exists, the script reached the "exit guard skipped" branch. + if (fs.existsSync(fallthrough) && execIntent.length === 0) { + return { status: result.status, stdout: result.stdout, stderr: result.stderr, execIntent: [], stagedFileContent: null }; + } + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + execIntent, + stagedFileContent, + }; +} + +describe("install.sh entry-guard staging — #4414 curl|bash stdin self-stage", () => { + it("stages to /tmp and would exec bash on the staged file when invoked via curl|bash", () => { + // Pipe-mode invocation: BASH_SOURCE[0] empty. Without staging, + // ensure_docker's sg(1) re-exec from #4419 has no file to point at + // and falls through to the legacy newgrp/re-curl message. + const outcome = runEntryGuard({}); + + expect(outcome.execIntent.length).toBeGreaterThan(0); + const stagedPath = outcome.execIntent[0]; + expect(stagedPath).toMatch(/^\/tmp\/nemoclaw-installer-[A-Za-z0-9]+$/); + + // Original installer args are preserved across the would-be exec + expect(outcome.execIntent).toContain("--non-interactive"); + expect(outcome.execIntent).toContain("--yes-i-accept-third-party-software"); + + // Staged file got real installer content written into it + expect(outcome.stagedFileContent).toContain("staged"); + }); + + it("falls through to main() when curl fails (network / DNS / unreachable URL)", () => { + // Must not loop, must not abort. Falls through to direct main() so + // ensure_docker's existing legacy newgrp/re-curl message still surfaces. + const outcome = runEntryGuard({ curlSucceeds: false }); + + expect(outcome.execIntent.length).toBe(0); + }); + + it("skips staging when NEMOCLAW_INSTALLER_STAGED=1 is already set (one-shot loop guard)", () => { + // The staged copy that already ran main() reaches this guard a second + // time on re-entry from ensure_docker's sg(1) re-exec. The env-var + // must demote that second pass to fallthrough so we don't loop. + const outcome = runEntryGuard({ envOverrides: { NEMOCLAW_INSTALLER_STAGED: "1" } }); + + expect(outcome.execIntent.length).toBe(0); + }); + + it("does not stage when invoked from a disk file (BASH_SOURCE non-empty)", () => { + // `bash install.sh` / `./install.sh` is already handled correctly by + // #4419's sg(1) re-exec — don't stage in that case. + const outcome = runEntryGuard({ + bashSourceOverride: "/usr/local/share/nemoclaw/install.sh", + }); + + expect(outcome.execIntent.length).toBe(0); + }); +}); From 9ebca49dd3814294029148e035d898b6d6530366 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 28 May 2026 14:47:31 -0700 Subject: [PATCH 2/3] fix(install): address CodeRabbit feedback + Karpathy simplification - inline shebang verification before chmod+x+exec (catches URL drift / CDN error pages / corrupted downloads without aborting via verify_downloaded_script's error->exit) - collapse cleanup tracking from a 4-line BASH_SOURCE pattern-match into a 1-line env-var path check by encoding the staged path in NEMOCLAW_INSTALLER_STAGED itself (also dual-purposes as loop guard) - add outcome.status === 0 assertions on fallthrough tests so syntax/runtime failures in the inlined snippet surface instead of passing silently - add shebang-corruption test covering URL drift (HTML 404 instead of script) per CodeRabbit review on #4467. Signed-off-by: Charan Jagwani --- scripts/install.sh | 31 +++++++++++------------- test/install-stage-from-stdin.test.ts | 34 ++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 0365721c02..4f425ca3a1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -12,14 +12,11 @@ set -euo pipefail # are removed on any exit path (set -e, unhandled signal, unexpected error). _cleanup_pids=() _cleanup_files=() -# #4414: When re-launched as a staged copy after a `curl | bash` invocation, -# queue the staged tmpfile for removal on EXIT so we don't leave installer -# scripts in /tmp. -if [[ "${NEMOCLAW_INSTALLER_STAGED:-}" == "1" ]] \ - && [[ -n "${BASH_SOURCE[0]:-}" ]] \ - && [[ "${BASH_SOURCE[0]}" == /tmp/nemoclaw-installer-* ]]; then - _cleanup_files+=("${BASH_SOURCE[0]}") -fi +# #4414: When re-launched as a staged copy via `curl | bash`, queue the +# staged tmpfile for removal on EXIT. NEMOCLAW_INSTALLER_STAGED carries +# the staged path forward so both the loop guard and cleanup use one var. +[[ "${NEMOCLAW_INSTALLER_STAGED:-}" == /tmp/nemoclaw-installer-* ]] \ + && _cleanup_files+=("${NEMOCLAW_INSTALLER_STAGED}") _global_cleanup() { for pid in "${_cleanup_pids[@]:-}"; do kill "$pid" 2>/dev/null || true @@ -2487,21 +2484,21 @@ if [[ "${BASH_SOURCE[0]:-}" == "$0" ]] || { [[ -z "${BASH_SOURCE[0]:-}" ]] && { # #4414: When invoked via `curl ... | bash`, BASH_SOURCE is empty and # $0="bash". ensure_docker's sg(1) re-exec (#4419) needs a real script # file to point bash at; without one it falls back to the legacy - # newgrp/re-curl path. Stage the installer to a tmpfile by re-curling - # the canonical URL so the sg(1) re-exec has a file to execute. - # NEMOCLAW_INSTALLER_STAGED=1 stops a second staging attempt if the - # staged copy still falls through ensure_docker's re-exec branch. - if [[ -z "${BASH_SOURCE[0]:-}" ]] && [[ "${NEMOCLAW_INSTALLER_STAGED:-}" != "1" ]]; then + # newgrp/re-curl path. Stage the installer by re-curling the canonical + # URL so the sg(1) re-exec has a file to execute. NEMOCLAW_INSTALLER_STAGED + # carries the staged path forward as both loop guard and cleanup key. + if [[ -z "${BASH_SOURCE[0]:-}" ]] && [[ -z "${NEMOCLAW_INSTALLER_STAGED:-}" ]]; then _installer_url="${NEMOCLAW_INSTALLER_URL:-https://www.nvidia.com/nemoclaw.sh}" if _staged="$(mktemp /tmp/nemoclaw-installer-XXXXXX 2>/dev/null)" \ && curl -fsSL "$_installer_url" -o "$_staged" 2>/dev/null \ - && [[ -s "$_staged" ]]; then + && [[ -s "$_staged" ]] \ + && head -1 "$_staged" | grep -qE '^#!.*(sh|bash)'; then chmod +x "$_staged" - export NEMOCLAW_INSTALLER_STAGED=1 + export NEMOCLAW_INSTALLER_STAGED="$_staged" exec bash "$_staged" "$@" fi - # Staging failed (mktemp / curl / empty download) — fall through to - # direct main(). The legacy newgrp/re-curl path still applies. + # Staging failed (mktemp / curl / empty / bad shebang) — fall through + # to direct main(). The legacy newgrp/re-curl path still applies. rm -f "${_staged:-}" 2>/dev/null fi main "$@" diff --git a/test/install-stage-from-stdin.test.ts b/test/install-stage-from-stdin.test.ts index 4691718b71..924840f528 100644 --- a/test/install-stage-from-stdin.test.ts +++ b/test/install-stage-from-stdin.test.ts @@ -70,13 +70,14 @@ function runEntryGuard(opts: { # ---- begin: inlined entry-guard staging block from install.sh ---- _test_bash_source=${bashSourceExpr} - if [[ -z "$_test_bash_source" ]] && [[ "\${NEMOCLAW_INSTALLER_STAGED:-}" != "1" ]]; then + if [[ -z "$_test_bash_source" ]] && [[ -z "\${NEMOCLAW_INSTALLER_STAGED:-}" ]]; then _installer_url="\${NEMOCLAW_INSTALLER_URL:-https://www.nvidia.com/nemoclaw.sh}" if _staged="$(mktemp /tmp/nemoclaw-installer-XXXXXX 2>/dev/null)" \\ && curl -fsSL "$_installer_url" -o "$_staged" 2>/dev/null \\ - && [[ -s "$_staged" ]]; then + && [[ -s "$_staged" ]] \\ + && head -1 "$_staged" | grep -qE '^#!.*(sh|bash)'; then chmod +x "$_staged" - export NEMOCLAW_INSTALLER_STAGED=1 + export NEMOCLAW_INSTALLER_STAGED="$_staged" # TEST capture point: record the intended exec argv + the staged # file's contents instead of actually exec'ing. printf '%s\\n' "$_staged" "$@" > ${JSON.stringify(execLog)} @@ -140,15 +141,23 @@ describe("install.sh entry-guard staging — #4414 curl|bash stdin self-stage", const outcome = runEntryGuard({ curlSucceeds: false }); expect(outcome.execIntent.length).toBe(0); + // outcome.status === 0 locks in clean fallthrough — a syntax/runtime + // error in the inlined snippet would surface as non-zero here. + expect(outcome.status).toBe(0); }); - it("skips staging when NEMOCLAW_INSTALLER_STAGED=1 is already set (one-shot loop guard)", () => { + it("skips staging when NEMOCLAW_INSTALLER_STAGED is already set (one-shot loop guard)", () => { // The staged copy that already ran main() reaches this guard a second // time on re-entry from ensure_docker's sg(1) re-exec. The env-var - // must demote that second pass to fallthrough so we don't loop. - const outcome = runEntryGuard({ envOverrides: { NEMOCLAW_INSTALLER_STAGED: "1" } }); + // must demote that second pass to fallthrough so we don't loop. The + // value is the staged file path (cleanup uses it), but any non-empty + // value triggers the guard. + const outcome = runEntryGuard({ + envOverrides: { NEMOCLAW_INSTALLER_STAGED: "/tmp/nemoclaw-installer-aBcDeF" }, + }); expect(outcome.execIntent.length).toBe(0); + expect(outcome.status).toBe(0); }); it("does not stage when invoked from a disk file (BASH_SOURCE non-empty)", () => { @@ -159,5 +168,18 @@ describe("install.sh entry-guard staging — #4414 curl|bash stdin self-stage", }); expect(outcome.execIntent.length).toBe(0); + expect(outcome.status).toBe(0); + }); + + it("falls through when the curl-downloaded content lacks a shell shebang (corruption / URL drift)", () => { + // Defense against URL drift: if the canonical URL ever serves a + // non-script payload (CDN cache miss, HTML error page, etc.), staging + // must not chmod+x + exec it. The shebang check catches that. + const outcome = runEntryGuard({ + curlOutputContent: "404\n", + }); + + expect(outcome.execIntent.length).toBe(0); + expect(outcome.status).toBe(0); }); }); From 5040b1bbeb29eb2d22bdd4a75fe9b9a54a17e4d8 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 29 May 2026 16:07:18 -0700 Subject: [PATCH 3/3] fix(install): syntax-check staged installer --- scripts/install.sh | 7 ++++--- test/install-stage-from-stdin.test.ts | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 4f425ca3a1..b6fb814e98 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2492,13 +2492,14 @@ if [[ "${BASH_SOURCE[0]:-}" == "$0" ]] || { [[ -z "${BASH_SOURCE[0]:-}" ]] && { if _staged="$(mktemp /tmp/nemoclaw-installer-XXXXXX 2>/dev/null)" \ && curl -fsSL "$_installer_url" -o "$_staged" 2>/dev/null \ && [[ -s "$_staged" ]] \ - && head -1 "$_staged" | grep -qE '^#!.*(sh|bash)'; then + && head -1 "$_staged" | grep -qE '^#!.*(sh|bash)' \ + && bash -n "$_staged" 2>/dev/null; then chmod +x "$_staged" export NEMOCLAW_INSTALLER_STAGED="$_staged" exec bash "$_staged" "$@" fi - # Staging failed (mktemp / curl / empty / bad shebang) — fall through - # to direct main(). The legacy newgrp/re-curl path still applies. + # Staging failed (mktemp / curl / empty / bad shebang / syntax check) — + # fall through to direct main(). The legacy newgrp/re-curl path still applies. rm -f "${_staged:-}" 2>/dev/null fi main "$@" diff --git a/test/install-stage-from-stdin.test.ts b/test/install-stage-from-stdin.test.ts index 924840f528..9a051ed13f 100644 --- a/test/install-stage-from-stdin.test.ts +++ b/test/install-stage-from-stdin.test.ts @@ -44,7 +44,7 @@ function runEntryGuard(opts: { ` shift\n` + `done\n` + `if [ -n "$out" ]; then\n` + - ` printf '%s' ${JSON.stringify(stagedContent)} > "$out"\n` + + ` printf '%b' ${JSON.stringify(stagedContent)} > "$out"\n` + `fi\nexit 0\n`; fs.writeFileSync(curlStub, curlBody, { mode: 0o755 }); @@ -75,7 +75,8 @@ function runEntryGuard(opts: { if _staged="$(mktemp /tmp/nemoclaw-installer-XXXXXX 2>/dev/null)" \\ && curl -fsSL "$_installer_url" -o "$_staged" 2>/dev/null \\ && [[ -s "$_staged" ]] \\ - && head -1 "$_staged" | grep -qE '^#!.*(sh|bash)'; then + && head -1 "$_staged" | grep -qE '^#!.*(sh|bash)' \\ + && bash -n "$_staged" 2>/dev/null; then chmod +x "$_staged" export NEMOCLAW_INSTALLER_STAGED="$_staged" # TEST capture point: record the intended exec argv + the staged @@ -182,4 +183,13 @@ describe("install.sh entry-guard staging — #4414 curl|bash stdin self-stage", expect(outcome.execIntent.length).toBe(0); expect(outcome.status).toBe(0); }); + + it("falls through when the staged installer fails bash syntax validation", () => { + const outcome = runEntryGuard({ + curlOutputContent: "#!/usr/bin/env bash\nif true; then\n", + }); + + expect(outcome.execIntent.length).toBe(0); + expect(outcome.status).toBe(0); + }); });