diff --git a/scripts/install.sh b/scripts/install.sh index 99d901d76a06..12e7651a5692 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -140,6 +140,14 @@ is_non_interactive_shell() { return 1 } +# Returns true when stdin should be isolated from the script stream. +# Checks stdin directly (not stdout) and respects NO_PROMPT so that +# stdout redirection (e.g. install.sh > log.txt) does not suppress +# interactive prompts. +needs_stdin_isolation() { + [[ ! -t 0 ]] || [[ "${NO_PROMPT:-0}" == "1" ]] +} + has_controlling_tty() { if [[ ! -r /dev/tty || ! -w /dev/tty ]]; then return 1 @@ -150,6 +158,39 @@ has_controlling_tty() { return 0 } +has_visible_prompt_output() { + [[ -t 1 ]] +} + +resolve_subprocess_stdin_path() { + local prompt_output_visible="${1:-0}" + if [[ "${NO_PROMPT:-0}" == "1" ]]; then + echo "/dev/null" + return 0 + fi + if ! needs_stdin_isolation; then + return 1 + fi + if has_controlling_tty && [[ "$prompt_output_visible" == "1" ]]; then + echo "/dev/tty" + else + echo "/dev/null" + fi +} + +run_with_safe_stdin() { + local stdin_path="" + local prompt_output_visible=0 + if has_visible_prompt_output; then + prompt_output_visible=1 + fi + if stdin_path="$(resolve_subprocess_stdin_path "$prompt_output_visible")"; then + "$@" < "$stdin_path" + else + "$@" + fi +} + gum_is_tty() { if [[ -n "${NO_COLOR:-}" ]]; then return 1 @@ -483,13 +524,23 @@ run_with_spinner() { local gum_err gum_out mktempfile gum_err mktempfile gum_out - if "$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err"; then + local gum_status=0 + if needs_stdin_isolation; then + "$GUM" spin --spinner dot --title "$title" -- "$@" < /dev/null >"$gum_out" 2>"$gum_err" || gum_status=$? + else + "$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err" || gum_status=$? + fi + if [[ "$gum_status" -eq 0 ]]; then if is_gum_raw_mode_failure "$gum_out" || is_gum_raw_mode_failure "$gum_err"; then GUM="" GUM_STATUS="skipped" GUM_REASON="gum raw mode unavailable" ui_warn "Spinner unavailable in this terminal; continuing without spinner" - "$@" + if needs_stdin_isolation; then + "$@" < /dev/null + else + "$@" + fi return $? fi if [[ -s "$gum_out" ]]; then @@ -497,13 +548,16 @@ run_with_spinner() { fi return 0 fi - local gum_status=$? if is_gum_raw_mode_failure "$gum_err" || is_gum_raw_mode_failure "$gum_out"; then GUM="" GUM_STATUS="skipped" GUM_REASON="gum raw mode unavailable" ui_warn "Spinner unavailable in this terminal; continuing without spinner" - "$@" + if needs_stdin_isolation; then + "$@" < /dev/null + else + "$@" + fi return $? fi if [[ -s "$gum_err" ]]; then @@ -512,7 +566,11 @@ run_with_spinner() { return "$gum_status" fi - "$@" + if needs_stdin_isolation; then + "$@" < /dev/null + else + "$@" + fi } run_quiet_step() { @@ -544,7 +602,11 @@ run_quiet_step() { # Keep users informed even when gum spinner cannot run (for example shell functions). ui_info "${title}" showed_progress=true - "$@" >"$log" 2>&1 || cmd_exit=$? + if needs_stdin_isolation; then + "$@" < /dev/null >"$log" 2>&1 || cmd_exit=$? + else + "$@" >"$log" 2>&1 || cmd_exit=$? + fi if (( cmd_exit == 0 )); then return 0 fi @@ -915,7 +977,7 @@ run_npm_global_install() { LAST_NPM_INSTALL_CMD="${cmd_display% }" if [[ "$VERBOSE" == "1" ]]; then - "${cmd[@]}" 2>&1 | tee "$log" + "${cmd[@]}" < /dev/null 2>&1 | tee "$log" return $? fi @@ -929,7 +991,7 @@ run_npm_global_install() { fi ui_info "Installing OpenClaw package" - "${cmd[@]}" >"$log" 2>&1 + "${cmd[@]}" < /dev/null >"$log" 2>&1 } extract_npm_debug_log_path() { @@ -1997,7 +2059,7 @@ fix_npm_permissions() { ui_warn "The installer will switch npm's user prefix to ${HOME}/.npm-global; npm normally writes that setting to ~/.npmrc." ui_info "Configuring npm for user-local installs" mkdir -p "$HOME/.npm-global" - npm config set prefix "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" < /dev/null ui_warn "Avoid sudo npm i -g for future OpenClaw updates; use npm i -g openclaw@latest so npm keeps using this user prefix instead of a different global prefix." persist_shell_path_prepend "$HOME/.npm-global/bin" "\$HOME/.npm-global/bin" || true @@ -2913,7 +2975,7 @@ maybe_open_dashboard() { if ! "$claw" dashboard --help >/dev/null 2>&1; then return 0 fi - "$claw" dashboard || true + run_with_safe_stdin "$claw" dashboard || true } has_openclaw_config() { @@ -3364,7 +3426,7 @@ main() { if (( doctor_ok )); then should_open_dashboard=true ui_info "Updating plugins" - OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" plugins update --all || true + OPENCLAW_UPDATE_IN_PROGRESS=1 run_with_safe_stdin "$claw" plugins update --all || true else ui_warn "Doctor failed; skipping plugin updates" fi @@ -3396,7 +3458,7 @@ main() { ui_info "Gateway daemon detected; would restart (${user_claw} daemon restart)" else ui_info "Gateway daemon detected; restarting" - if OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" daemon restart >/dev/null 2>&1; then + if OPENCLAW_UPDATE_IN_PROGRESS=1 "$claw" daemon restart < /dev/null >/dev/null 2>&1; then ui_success "Gateway restarted" else ui_warn "Gateway restart failed; try: ${user_claw} daemon restart" diff --git a/test/scripts/install-sh.test.ts b/test/scripts/install-sh.test.ts index e463b74448eb..356ad77a4bc3 100644 --- a/test/scripts/install-sh.test.ts +++ b/test/scripts/install-sh.test.ts @@ -1972,7 +1972,10 @@ describe("install.sh macOS Homebrew Node behavior", () => { it("falls back when gum reports raw-mode ioctl failures", () => { expect(script).toContain("setrawmode|inappropriate ioctl"); expect(script).toContain( - 'if "$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err"; then', + '"$GUM" spin --spinner dot --title "$title" -- "$@" < /dev/null >"$gum_out" 2>"$gum_err" || gum_status=$?', + ); + expect(script).toContain( + '"$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err" || gum_status=$?', ); expect(script).toContain( 'if is_gum_raw_mode_failure "$gum_out" || is_gum_raw_mode_failure "$gum_err"; then', @@ -1980,7 +1983,9 @@ describe("install.sh macOS Homebrew Node behavior", () => { expect(script).toContain( 'ui_warn "Spinner unavailable in this terminal; continuing without spinner"', ); - expect(script).toContain('"$@"\n return $?'); + expect(script).toContain( + 'if needs_stdin_isolation; then\n "$@" < /dev/null\n else\n "$@"\n fi\n return $?', + ); }); it("reruns spinner-wrapped commands when gum reports ioctl failure", () => { @@ -2016,6 +2021,65 @@ describe("install.sh macOS Homebrew Node behavior", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("gum spin preserves terminal stdin for direct interactive installs", () => { + // When needs_stdin_isolation returns false (direct interactive run), + // gum spin should NOT redirect stdin from /dev/null so that wrapped + // commands like Homebrew can still prompt the user via stdin. + const dir = mkdtempSync(join(tmpdir(), "openclaw-install-sh-gum-stdin-")); + try { + const gumPath = join(dir, "gum"); + const commandPath = join(dir, "command"); + const stdinLog = join(dir, "stdin-source"); + // Gum stub: skip args up to and including "--", then run the rest + writeFileSync( + gumPath, + '#!/usr/bin/env bash\nwhile [[ "$#" -gt 0 && "$1" != "--" ]]; do shift; done\nshift\n"$@"\n', + { mode: 0o755 }, + ); + // Command: detects whether stdin is literally /dev/null by comparing + // device:inode of fd 0 against /dev/null (reliable across macOS/Linux) + writeFileSync( + commandPath, + `#!/usr/bin/env bash +stdin_dev=$(stat -f '%d:%i' /dev/fd/0 2>/dev/null || stat -c '%d:%i' /dev/fd/0 2>/dev/null) +null_dev=$(stat -f '%d:%i' /dev/null 2>/dev/null || stat -c '%d:%i' /dev/null 2>/dev/null) +if [ "$stdin_dev" = "$null_dev" ]; then echo "devnull" > "${stdinLog}"; else echo "other" > "${stdinLog}"; fi +exit 0 +`, + { mode: 0o755 }, + ); + + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + # Override needs_stdin_isolation to return false (direct interactive) + needs_stdin_isolation() { return 1; } + gum_is_tty() { return 0; } + GUM="${gumPath}" + run_with_spinner "Installing node" "${commandPath}" + `); + + // The gum spin command should NOT have redirected stdin from /dev/null + expect(result.status).toBe(0); + // Assert the child command's stdin was NOT /dev/null + const observed = readFileSync(stdinLog, "utf8").trim(); + expect(observed).toBe("other"); + expect(script).toContain("needs_stdin_isolation; then"); + expect(script).toContain( + '"$GUM" spin --spinner dot --title "$title" -- "$@" >"$gum_out" 2>"$gum_err" || gum_status=$?', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("gum spin redirects stdin from /dev/null for piped installs", () => { + expect(script).toContain("needs_stdin_isolation; then"); + expect(script).toContain( + '"$GUM" spin --spinner dot --title "$title" -- "$@" < /dev/null >"$gum_out" 2>"$gum_err" || gum_status=$?', + ); + }); }); describe("install.sh duplicate OpenClaw install detection", () => { @@ -2060,11 +2124,214 @@ describe("install.sh duplicate OpenClaw install detection", () => { expect(result.status).toBe(0); expect(result.stdout).not.toContain("Multiple OpenClaw global installs detected"); }); + + it("needs_stdin_isolation returns true when stdin is piped", () => { + const result = spawnSync( + "bash", + [ + "-c", + `source "${SCRIPT_PATH}" && needs_stdin_isolation && echo "ISOLATED" || echo "INTERACTIVE"`, + ], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + HOME: tmpdir(), + OPENCLAW_INSTALL_SH_NO_RUN: "1", + BASH_ENV: "", + ENV: "", + }, + input: "", + }, + ); + expect(result.stdout.trim()).toBe("ISOLATED"); + }); + + it("needs_stdin_isolation returns true when NO_PROMPT is set", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + NO_PROMPT=1 + needs_stdin_isolation && echo "ISOLATED" || echo "INTERACTIVE" + `); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("ISOLATED"); + }); + + it("routes piped interactive subprocesses through the controlling TTY", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + NO_PROMPT=0 + needs_stdin_isolation() { return 0; } + has_controlling_tty() { return 0; } + resolve_subprocess_stdin_path 1 + `); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/dev/tty"); + }); + + it("keeps piped subprocesses nonblocking when prompt output is redirected", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + NO_PROMPT=0 + needs_stdin_isolation() { return 0; } + has_controlling_tty() { return 0; } + resolve_subprocess_stdin_path 0 + `); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/dev/null"); + }); + + it("captures visible prompt output before resolving subprocess stdin", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + marker="$(mktemp)" + trap 'rm -f "$marker"' EXIT + has_visible_prompt_output() { return 0; } + resolve_subprocess_stdin_path() { + echo "visible=$1" > "$marker" + return 1 + } + run_with_safe_stdin true + cat "$marker" + `); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("visible=1"); + }); + + it("routes non-promptable subprocesses through /dev/null", () => { + const result = runInstallShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + NO_PROMPT=1 + has_controlling_tty() { return 0; } + resolve_subprocess_stdin_path + `); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("/dev/null"); + }); + + it("run_quiet_step redirects stdin to /dev/null in piped context", () => { + const dir = mkdtempSync(join(tmpdir(), "openclaw-stdin-test-")); + const marker = join(dir, "stdin-state"); + try { + const result = spawnSync( + "bash", + [ + "-c", + `source "${SCRIPT_PATH}" && GUM="" && run_quiet_step "test-step" bash -c 'if read -t 1 line 2>/dev/null && [ -n "$line" ]; then echo "LEAKED:$line" > ${JSON.stringify(marker)}; else echo ISOLATED > ${JSON.stringify(marker)}; fi'`, + ], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + HOME: tmpdir(), + NO_PROMPT: "1", + OPENCLAW_INSTALL_SH_NO_RUN: "1", + BASH_ENV: "", + ENV: "", + }, + input: "SENTINEL_DATA_SHOULD_NOT_LEAK\n", + }, + ); + expect(result.status).toBe(0); + const stdinState = readFileSync(marker, "utf8").trim(); + expect(stdinState).toBe("ISOLATED"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it("pipe data leaks to child when stdin is not isolated (counterproof)", () => { + // This test proves the fix is necessary: without /dev/null redirect, + // pipe data from the installer invocation reaches the child process. + // If this test ever fails, the isolation in run_quiet_step is no longer + // the only barrier protecting child processes from pipe consumption. + const dir = mkdtempSync(join(tmpdir(), "openclaw-stdin-leak-")); + const marker = join(dir, "stdin-state"); + try { + const result = spawnSync( + "bash", + [ + "-c", + // Bypass run_quiet_step: call the child directly with inherited stdin + `source "${SCRIPT_PATH}" && bash -c 'output=$(cat); if [ -n "$output" ]; then echo "LEAKED" > ${JSON.stringify(marker)}; else echo "EMPTY" > ${JSON.stringify(marker)}; fi'`, + ], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + HOME: tmpdir(), + OPENCLAW_INSTALL_SH_NO_RUN: "1", + BASH_ENV: "", + ENV: "", + }, + input: "SENTINEL_DATA_SHOULD_LEAK\n", + }, + ); + expect(result.status).toBe(0); + const stdinState = readFileSync(marker, "utf8").trim(); + // Without /dev/null redirect, cat reads the sentinel from the pipe. + expect(stdinState).toBe("LEAKED"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); + + it("run_quiet_step blocks cat from reading pipe data", () => { + // Stronger version of the isolation test: uses cat to consume all of + // stdin and verifies it reads nothing (empty output from /dev/null). + const dir = mkdtempSync(join(tmpdir(), "openclaw-stdin-cat-")); + const marker = join(dir, "stdin-state"); + try { + const result = spawnSync( + "bash", + [ + "-c", + `source "${SCRIPT_PATH}" && GUM="" && run_quiet_step "test-step" bash -c 'output=$(cat); if [ -n "$output" ]; then echo "LEAKED" > ${JSON.stringify(marker)}; else echo "ISOLATED" > ${JSON.stringify(marker)}; fi'`, + ], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + HOME: tmpdir(), + NO_PROMPT: "1", + OPENCLAW_INSTALL_SH_NO_RUN: "1", + BASH_ENV: "", + ENV: "", + }, + input: "SENTINEL_DATA_SHOULD_NOT_LEAK\n", + }, + ); + expect(result.status).toBe(0); + const stdinState = readFileSync(marker, "utf8").trim(); + expect(stdinState).toBe("ISOLATED"); + } finally { + rmSync(dir, { force: true, recursive: true }); + } + }); }); describe("install.sh doctor cancellation and dashboard guard", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); + it("preserves dashboard stdin for direct interactive installs", () => { + expect(script).toContain('run_with_safe_stdin "$claw" dashboard || true'); + }); + + it("preserves plugin update stdin for direct interactive upgrades", () => { + expect(script).toContain( + 'OPENCLAW_UPDATE_IN_PROGRESS=1 run_with_safe_stdin "$claw" plugins update --all || true', + ); + }); + it("guards every run_doctor caller against failure", () => { // A failed or cancelled doctor must not launch the dashboard. expect(script).toContain("if run_doctor; then");