fix(install): harden stdin consumers to prevent pipe corruption in curl | bash (#87799)

* fix(install): harden stdin consumers to prevent pipe corruption in curl | bash

Redirect stdin from /dev/null for non-interactive subprocesses (npm install,
openclaw daemon restart, openclaw plugins update, openclaw dashboard) and
from /dev/tty for interactive ones (openclaw onboard in bootstrap). Also
protect the fallback paths in run_with_spinner and run_quiet_step.

This prevents subprocesses from consuming the script stream when the
installer is piped via curl | bash, which causes truncated function names
and hangs (reported in #73814).

Unlike the global pipe guard in #82918 (closed as too broad), this approach
has no detection heuristics and no re-execution. Each subprocess simply gets
the correct stdin for its purpose.

Fixes #73814

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* chore: retrigger proof evaluation

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix: redirect stdin in run_with_spinner raw-mode fallback

The success-path raw-mode fallback in run_with_spinner invoked the
command with inherited stdin. In a curl | bash scenario, this allowed
the child process to consume bytes from the script stream, causing
truncation. Add < /dev/null to match the other two fallback paths.

* retrigger proof check

* fix(test): update gum fallback assertion for stdin redirect

* fix: redirect gum-wrapped stdin and preserve TTY for interactive commands

Address ClawSweeper P1 and P2 findings:

- P1: Redirect stdin from /dev/null on the normal gum spin path so child
  commands cannot consume the piped script stream (gum v0.17.0 passes
  os.Stdin to wrapped commands).

- P2: Use is_non_interactive_shell to conditionally redirect stdin in
  fallback paths. When running interactively (bash install.sh), commands
  that need user input (e.g. Homebrew prompts) keep terminal stdin.
  When piped (curl | bash), stdin is redirected from /dev/null.

- P2: Revert labeler.yml changes to keep the PR focused on installer
  stdin safety. Size-label best-effort handling belongs in a separate PR.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* chore: retrigger proof evaluation

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* retrigger proof check

* fix: base stdin isolation on stdin TTY check, not stdout

Replace is_non_interactive_shell with needs_stdin_isolation for stdin
redirection decisions. The new function checks stdin directly (! -t 0)
and NO_PROMPT, without checking stdout (-t 1). This ensures that
stdout redirection (e.g. install.sh > log.txt) does not suppress
interactive prompts when stdin is a terminal.

* test(install): add stdin isolation and NO_PROMPT coverage

Three focused tests for the needs_stdin_isolation function:

1. Verify needs_stdin_isolation returns true when stdin is piped
   (the core curl|bash scenario).
2. Verify needs_stdin_isolation returns true when NO_PROMPT=1 is set
   (explicit non-interactive override).
3. Verify run_quiet_step redirects subprocess stdin to /dev/null
   when running in a piped context, preventing script consumption.

* test(install): strengthen stdin isolation test with sentinel data

Replace the weak TTY check (which passes even without the fix since
the test pipe is also non-TTY) with a sentinel-based test: pipe
SENTINEL_DATA on stdin and verify the child process reads nothing,
proving run_quiet_step actually redirects stdin to /dev/null.

* test(install): add counterproof and cat-based stdin isolation tests

Add two new tests to prove the stdin isolation fix is necessary and
works correctly:

- counterproof: demonstrates that pipe data DOES leak to the child
  when stdin is not redirected through run_quiet_step, proving the
  /dev/null redirect is the isolation barrier
- cat-based test: uses cat (reads all of stdin) instead of read -t 1
  (timeout-based) for a deterministic assertion that run_quiet_step
  produces empty stdin

* fix: gate gum spin stdin redirect on needs_stdin_isolation

The gum spin command unconditionally redirected stdin from /dev/null,
which also killed interactive prompts for direct installs since gum
v0.17 passes os.Stdin to the wrapped command. Now only redirect
stdin when needs_stdin_isolation returns true (piped install context).

Adds focused tests verifying both paths: piped installs get /dev/null
redirect, direct interactive installs preserve terminal stdin.

* test: assert gum stdin is not /dev/null for direct interactive installs

The direct gum runtime test now reads the child command's stdin-source
log file and asserts stdin was NOT /dev/null, using device:inode
comparison (stat -f on macOS, stat -c on Linux) for reliable detection
across both platforms.

* retrigger proof check

* retrigger proof check with real installer evidence

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>

* fix(install): preserve interactive post-install stdin

* fix(install): route piped prompts through controlling tty

* fix(install): keep quiet piped steps noninteractive

* fix(install): avoid hidden prompts with redirected output

* fix(install): preserve visible prompt output state

---------

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
This commit is contained in:
Sebastien Tardif
2026-07-11 02:00:53 -04:00
committed by GitHub
parent 919b4fdc2c
commit 1fa1507fc4
2 changed files with 343 additions and 14 deletions
+74 -12
View File
@@ -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"