diff --git a/README.md b/README.md index 62f4c3ed1..70873de7c 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,13 @@ The script installs Node.js if it is not already present, then runs the guided o curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` +The piped installer prompts through your terminal. In headless scripts or CI, +pass explicit acceptance to the `bash` side of the pipe: + +```bash +curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash +``` + If you use nvm or fnm to manage Node.js, the installer may not update your current shell's PATH. If `nemoclaw` is not found after install, run `source ~/.bashrc` (or `source ~/.zshrc` for zsh) or open a new terminal. diff --git a/docs/get-started/quickstart.md b/docs/get-started/quickstart.md index 5c3397b3a..893faf521 100644 --- a/docs/get-started/quickstart.md +++ b/docs/get-started/quickstart.md @@ -43,6 +43,13 @@ NemoClaw creates a fresh OpenClaw instance inside the sandbox during the onboard curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` +The piped installer prompts through your terminal. In headless scripts or CI, +pass explicit acceptance to the `bash` side of the pipe: + +```console +$ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash +``` + If you use nvm or fnm to manage Node.js, the installer might not update your current shell's PATH. If `nemoclaw` is not found after install, run `source ~/.bashrc` (or `source ~/.zshrc` for zsh) or open a new terminal. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 11b45d490..3b9f5b175 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -133,7 +133,12 @@ or: $ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 nemoclaw onboard --non-interactive ``` -For scripted installer runs, set `NEMOCLAW_NON_INTERACTIVE=1` and `NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1` before invoking `curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash`. +For scripted installer runs, pass explicit acceptance to the `bash` side of the installer pipe: + +```console +$ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 bash +``` + If the installer cannot prompt for the notice in a terminal and no explicit acceptance is set, it exits before installing Node.js or the NemoClaw CLI. To enable Brave Search in non-interactive mode, set: diff --git a/scripts/install.sh b/scripts/install.sh index 0793a844f..f53221fc7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -579,6 +579,140 @@ show_usage_notice() { fi } +usage_notice_config_path() { + local repo_root source_root notice_json + repo_root="$(resolve_repo_root)" + source_root="${NEMOCLAW_SOURCE_ROOT:-$repo_root}" + notice_json="${source_root}/bin/lib/usage-notice.json" + if [[ ! -f "$notice_json" ]]; then + notice_json="${repo_root}/bin/lib/usage-notice.json" + fi + printf "%s" "$notice_json" +} + +json_string_field() { + local file="$1" field="$2" + sed -nE "s/^[[:space:]]*\"${field}\"[[:space:]]*:[[:space:]]*\"(.*)\"[,]?[[:space:]]*$/\\1/p" "$file" \ + | head -n 1 \ + | sed 's/\\"/"/g; s/\\\\/\\/g' +} + +usage_notice_state_file() { + printf "%s/.nemoclaw/usage-notice.json" "${HOME}" +} + +usage_notice_accepted_shell() { + local version="$1" state_file saved_version + state_file="$(usage_notice_state_file)" + [[ -n "$version" && -f "$state_file" ]] || return 1 + saved_version="$(sed -nE 's/.*"acceptedVersion"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$state_file" | head -n 1)" + [[ "$saved_version" == "$version" ]] +} + +save_usage_notice_acceptance_shell() { + local version="$1" state_file state_dir accepted_at + state_file="$(usage_notice_state_file)" + state_dir="$(dirname "$state_file")" + accepted_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date)" + mkdir -p "$state_dir" + chmod 700 "$state_dir" 2>/dev/null || true + printf '{\n "acceptedVersion": "%s",\n "acceptedAt": "%s"\n}\n' "$version" "$accepted_at" >"$state_file" + chmod 600 "$state_file" 2>/dev/null || true +} + +print_usage_notice_body_shell() { + local file="$1" + awk ' + /"body"[[:space:]]*:/ { in_body = 1; next } + in_body && /^[[:space:]]*]/ { exit } + in_body { + line = $0 + sub(/^[[:space:]]*"/, "", line) + sub(/",[[:space:]]*$/, "", line) + sub(/"[[:space:]]*$/, "", line) + gsub(/\\"/, "\"", line) + gsub(/\\\\/, "\\", line) + printf " %s\n", line + } + ' "$file" +} + +show_usage_notice_shell() { + local notice_json version title prompt notice_body answer answer_lc + notice_json="$(usage_notice_config_path)" + if [[ ! -f "$notice_json" ]]; then + error "Third-party software notice configuration not found." + fi + + version="$(json_string_field "$notice_json" "version")" + title="$(json_string_field "$notice_json" "title")" + prompt="$(json_string_field "$notice_json" "interactivePrompt")" + if [[ -z "$version" ]]; then + error "Third-party software notice version not found." + fi + notice_body="$(print_usage_notice_body_shell "$notice_json")" + if [[ -z "$(printf "%s" "$notice_body" | tr -d '[:space:]')" ]]; then + error "Third-party software notice body not found." + fi + + if usage_notice_accepted_shell "$version"; then + return 0 + fi + + printf "\n" + printf " %s\n" "${title:-Third-Party Software Notice - NemoClaw Installer}" + printf " ──────────────────────────────────────────────────\n" + printf "%s\n" "$notice_body" + printf "\n" + printf " %s" "${prompt:-Type 'yes' to accept the NemoClaw license and third-party software notice and continue [no]: }" + if ! IFS= read -r answer; then + printf "\n Installation cancelled\n" >&2 + return 1 + fi + answer_lc="$(printf "%s" "$answer" | tr '[:upper:]' '[:lower:]')" + if [[ "$answer_lc" != "yes" ]]; then + printf " Installation cancelled\n" >&2 + return 1 + fi + + save_usage_notice_acceptance_shell "$version" + return 0 +} + +preflight_usage_notice_prompt() { + if [ "${ACCEPT_THIRD_PARTY_SOFTWARE:-}" = "1" ]; then + return 0 + fi + + local notice_json version + notice_json="$(usage_notice_config_path)" + if [[ -f "$notice_json" ]]; then + version="$(json_string_field "$notice_json" "version")" + if [[ -n "$version" ]] && usage_notice_accepted_shell "$version"; then + return 0 + fi + fi + + if [ "${NON_INTERACTIVE:-}" = "1" ]; then + error "Non-interactive installation requires explicit third-party software acceptance. Re-run with --yes-i-accept-third-party-software or set NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1." + fi + + if [ -t 0 ]; then + show_usage_notice_shell + return "$?" + fi + + if { exec 3/dev/null; then + info "Installer stdin is piped; prompting for the third-party software notice on /dev/tty before install." + local status=0 + show_usage_notice_shell <&3 || status=$? + exec 3<&- + return "$status" + fi + + 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)." +} + # spin "label" cmd [args...] # Runs a command in the background, showing a braille spinner until it exits. # Stdout/stderr are captured; dumped only on failure. @@ -1564,24 +1698,11 @@ 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 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) only when either: - # - NON_INTERACTIVE=1 (also implied by ACCEPT_THIRD_PARTY_SOFTWARE=1 above) - # - stdin is a TTY — license helper prompts the user directly before install - # - # Do not treat an openable /dev/tty as sufficient here. In curl|bash mode, - # stdin is a pipe even though /dev/tty may still be available; falling back to - # /dev/tty later would run phases 1/2 before the license prompt and could leave - # a partial install behind if the user declines or no terminal is attached. - if [ "${NON_INTERACTIVE:-}" != "1" ] && [ ! -t 0 ]; 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 + # Fail-fast license-acceptance check (#2671). Headless curl|bash still exits + # before phase 1 so it cannot leave a half-install behind. Piped installs from + # a real terminal are different: stdin is the script pipe, but /dev/tty can + # still collect acceptance before Node.js or the CLI are installed. + preflight_usage_notice_prompt _INSTALL_START=$SECONDS print_banner diff --git a/src/lib/commands/onboard.test.ts b/src/lib/commands/onboard.test.ts index 29eda7960..0f98840e0 100644 --- a/src/lib/commands/onboard.test.ts +++ b/src/lib/commands/onboard.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { runOnboardAction } from "../global-cli-actions"; import OnboardCliCommand from "./onboard"; @@ -15,6 +15,10 @@ vi.mock("../global-cli-actions", () => ({ const rootDir = process.cwd(); describe("onboard oclif command", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("rejects mutually exclusive resume and fresh flags before dispatch", async () => { await expect(OnboardCliCommand.run(["--resume", "--fresh"], rootDir)).rejects.toThrow( /resume|fresh/, @@ -22,4 +26,23 @@ describe("onboard oclif command", () => { expect(runOnboardAction).not.toHaveBeenCalled(); }); + + it("accepts --yes and forwards it to the legacy onboard action", async () => { + await OnboardCliCommand.run( + ["--non-interactive", "--yes", "--yes-i-accept-third-party-software"], + rootDir, + ); + + expect(runOnboardAction).toHaveBeenCalledWith([ + "--non-interactive", + "--yes", + "--yes-i-accept-third-party-software", + ]); + }); + + it("accepts -y as the short form for --yes", async () => { + await OnboardCliCommand.run(["--non-interactive", "-y"], rootDir); + + expect(runOnboardAction).toHaveBeenCalledWith(["--non-interactive", "--yes"]); + }); }); diff --git a/src/lib/commands/onboard/common.ts b/src/lib/commands/onboard/common.ts index 0f065431d..f1dc9dcd8 100644 --- a/src/lib/commands/onboard/common.ts +++ b/src/lib/commands/onboard/common.ts @@ -17,7 +17,7 @@ export const onboardExamples = [ "<%= config.bin %> onboard --resume", "<%= config.bin %> onboard --fresh", "<%= config.bin %> onboard --from ./Dockerfile --name alpha", - `<%= config.bin %> onboard --non-interactive --name alpha ${NOTICE_ACCEPT_FLAG}`, + `<%= config.bin %> onboard --non-interactive --yes --name alpha ${NOTICE_ACCEPT_FLAG}`, ]; export type OnboardFlags = { @@ -56,7 +56,7 @@ export function buildOnboardFlags(): Record { }), yes: Flags.boolean({ char: "y", - description: "Auto-accept the Ollama model-download size confirmation", + description: "Auto-confirm prompts that are safe for unattended onboarding", }), [acceptFlagName]: Flags.boolean({ description: "Accept the third-party software notice" }), } as Record; diff --git a/test/install-preflight.test.ts b/test/install-preflight.test.ts index 1c2056d4f..5f80739c2 100644 --- a/test/install-preflight.test.ts +++ b/test/install-preflight.test.ts @@ -1529,6 +1529,7 @@ exit 0 PATH: `${fakeBin}:${prefixBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_SANDBOX_NAME: "my-assistant", NPM_PREFIX: prefix, NVM_DIR: nvmDir, }, @@ -1608,6 +1609,7 @@ fi`, PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, NEMOCLAW_NON_INTERACTIVE: "1", NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_SANDBOX_NAME: "my-assistant", NPM_PREFIX: prefix, NVM_DIR: nvmDir, SHELL: "/bin/bash", @@ -3072,7 +3074,171 @@ exit 0`, return { result, phases, tmp }; } - it("#2671: curl|bash with no flags exits 1 BEFORE phase 1 (atomic — no Node/CLI install)", () => { + function runInstallerWithTty( + answer: string, + stdinMode: "pipe" | "tty" = "pipe", + env: Record = {}, + ) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-install-tty-pipe-")); + const fakeBin = path.join(tmp, "bin"); + const phaseLog = path.join(tmp, "phases.log"); + fs.mkdirSync(fakeBin); + + 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`, + ); + writeExecutable( + path.join(fakeBin, "openshell"), + `#!/usr/bin/env bash +echo "openshell $*" >> ${JSON.stringify(phaseLog)} +if [ "$1" = "--version" ] || [ "$1" = "version" ]; then echo "openshell 0.0.36"; fi +exit 0`, + ); + writeExecutable( + path.join(fakeBin, "nemoclaw"), + `#!/usr/bin/env bash +echo "nemoclaw $*" >> ${JSON.stringify(phaseLog)} +if [ "$1" = "--version" ] || [ "$1" = "version" ]; then echo "nemoclaw v0.5.0"; fi +exit 0`, + ); + + const python = + spawnSync("bash", ["-lc", "command -v python3"], { encoding: "utf-8" }).stdout.trim() || + "python3"; + const ptyRunner = ` +import os +import pty +import select +import signal +import sys +import time + +installer = sys.argv[1] +answer = sys.argv[2].encode() +stdin_mode = sys.argv[3] +pid, fd = pty.fork() +if pid == 0: + if stdin_mode == "pipe": + devnull = os.open(os.devnull, os.O_RDONLY) + os.dup2(devnull, 0) + os.close(devnull) + os.execvpe("bash", ["bash", installer], os.environ) + +output = bytearray() +os.set_blocking(fd, False) +deadline = time.time() + 20 +sent = False +exit_code = 124 +timed_out = False +while True: + if not sent: + os.write(fd, answer) + sent = True + ready, _, _ = select.select([fd], [], [], 0.1) + if ready: + try: + chunk = os.read(fd, 4096) + except BlockingIOError: + chunk = b"" + except OSError: + chunk = b"" + if chunk: + output.extend(chunk) + waited = os.waitpid(pid, os.WNOHANG) + if waited[0] == pid: + status = waited[1] + exit_code = os.waitstatus_to_exitcode(status) + break + if time.time() > deadline: + timed_out = True + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + break + +try: + if timed_out: + for _ in range(20): + waited = os.waitpid(pid, os.WNOHANG) + if waited[0] == pid: + exit_code = os.waitstatus_to_exitcode(waited[1]) + break + time.sleep(0.05) + else: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + try: + os.waitpid(pid, 0) + except ChildProcessError: + pass + exit_code = 124 + + for _ in range(100): + chunk = os.read(fd, 4096) + if not chunk: + break + output.extend(chunk) +except BlockingIOError: + pass +except OSError: + pass +finally: + try: + os.close(fd) + except OSError: + pass + +sys.stdout.buffer.write(output) +sys.exit(exit_code) +`; + const result = spawnSync(python, ["-c", ptyRunner, INSTALLER_PAYLOAD, answer, stdinMode], { + cwd: tmp, + encoding: "utf-8", + timeout: 30_000, + killSignal: "SIGKILL", + env: { + HOME: tmp, + PATH: `${fakeBin}:${TEST_SYSTEM_PATH}`, + ...env, + }, + }); + const phases = fs.existsSync(phaseLog) ? fs.readFileSync(phaseLog, "utf-8") : ""; + const stateFile = path.join(tmp, ".nemoclaw", "usage-notice.json"); + const state = fs.existsSync(stateFile) ? fs.readFileSync(stateFile, "utf-8") : ""; + return { result, phases, state }; + } + + function runInstallerWithPipedStdinAndTty(answer: string) { + return runInstallerWithTty(answer, "pipe"); + } + + function runInstallerWithInteractiveStdin(answer: string) { + return runInstallerWithTty(answer, "tty"); + } + + it("#2671: headless 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}`; @@ -3087,6 +3253,80 @@ exit 0`, expect(phases).toBe(""); }); + it("piped installs with a controlling TTY prompt before phase 1 and continue after acceptance", () => { + const { result, phases, state } = runInstallerWithPipedStdinAndTty("yes\n"); + const output = `${result.stdout}${result.stderr}`; + const noticeVersion = JSON.parse( + fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "lib", "usage-notice.json"), "utf-8"), + ).version; + expect(result.status, output).toBe(0); + expect(output).toMatch(/prompting for the third-party software notice on \/dev\/tty/); + expect(output).toMatch(/Third-Party Software Notice - NemoClaw Installer/); + expect(output).not.toMatch(/Interactive third-party software acceptance requires a TTY/); + expect(output.indexOf("Third-Party Software Notice - NemoClaw Installer")).toBeGreaterThanOrEqual(0); + expect(output.indexOf("Node.js")).toBeGreaterThan( + output.indexOf("Third-Party Software Notice - NemoClaw Installer"), + ); + expect(phases).not.toBe(""); + expect(state).toContain(`"acceptedVersion": "${noticeVersion}"`); + }, 15_000); + + it("interactive installs with stdin on a TTY prompt before phase 1 and continue after acceptance", () => { + const { result, phases, state } = runInstallerWithInteractiveStdin("yes\n"); + const output = `${result.stdout}${result.stderr}`; + const noticeVersion = JSON.parse( + fs.readFileSync(path.join(import.meta.dirname, "..", "bin", "lib", "usage-notice.json"), "utf-8"), + ).version; + expect(result.status, output).toBe(0); + expect(output).toMatch(/Third-Party Software Notice - NemoClaw Installer/); + expect(output).toMatch(/Type 'yes'/); + expect(output).not.toMatch(/Interactive third-party software acceptance requires a TTY/); + expect(output.indexOf("Third-Party Software Notice - NemoClaw Installer")).toBeGreaterThanOrEqual(0); + expect(output.indexOf("Node.js")).toBeGreaterThan( + output.indexOf("Third-Party Software Notice - NemoClaw Installer"), + ); + expect(phases).not.toBe(""); + expect(state).toContain(`"acceptedVersion": "${noticeVersion}"`); + }, 15_000); + + it("piped installs with a controlling TTY still stop before phase 1 when acceptance is declined", () => { + const { result, phases, state } = runInstallerWithPipedStdinAndTty("\n"); + const output = `${result.stdout}${result.stderr}`; + expect(result.status).not.toBe(0); + expect(output).toMatch(/Third-Party Software Notice - NemoClaw Installer/); + expect(output).toMatch(/Installation cancelled/); + expect(output).not.toMatch(/\[1\/3\] Node\.js/); + expect(phases).toBe(""); + expect(state).toBe(""); + }); + + it("interactive installs with stdin on a TTY still stop before phase 1 when acceptance is declined", () => { + const { result, phases, state } = runInstallerWithInteractiveStdin("\n"); + const output = `${result.stdout}${result.stderr}`; + expect(result.status).not.toBe(0); + expect(output).toMatch(/Third-Party Software Notice - NemoClaw Installer/); + expect(output).toMatch(/Installation cancelled/); + expect(output).not.toMatch(/\[1\/3\] Node\.js/); + expect(phases).toBe(""); + expect(state).toBe(""); + }); + + it("--non-interactive alone with a controlling TTY still stops before phase 1", () => { + const { result, phases, state } = runInstallerWithTty("yes\n", "pipe", { + NEMOCLAW_NON_INTERACTIVE: "1", + }); + const output = `${result.stdout}${result.stderr}`; + expect(result.status).not.toBe(0); + expect(output).toMatch( + /Non-interactive installation requires explicit third-party software acceptance/, + ); + expect(output).toMatch(/--yes-i-accept-third-party-software/); + expect(output).not.toMatch(/Third-Party Software Notice - NemoClaw Installer/); + expect(output).not.toMatch(/\[1\/3\] Node\.js/); + expect(phases).toBe(""); + expect(state).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 @@ -3099,10 +3339,14 @@ exit 0`, expect(phases).not.toBe(""); }); - it("--non-interactive alone is sufficient to clear the fail-fast gate", () => { + it("--non-interactive alone does not 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(""); + expect(result.status).not.toBe(0); + expect(output).toMatch( + /Non-interactive installation requires explicit third-party software acceptance/, + ); + expect(output).toMatch(/--yes-i-accept-third-party-software/); + expect(phases).toBe(""); }); });