fix(installer): keep piped script on stdin and prompt via /dev/tty (#935)

* fix(installer): keep piped script on stdin and prompt via /dev/tty

`curl | bash` left bash reading the installer itself from stdin, and the
detection block's `exec < /dev/tty` (from #315) closed that pipe about 300
lines into a 3000-line script. curl died with error 23 writing to the
closed pipe, and bash read the rest of the installer from the terminal as
commands instead of finishing the script.

stdin now stays on the pipe and a reachable terminal is recorded in
TERMINAL_PROMPTS. Every interactive prompt goes through read_prompt, which
reads /dev/tty explicitly only for that read, so user input still works in
a terminal while the script body keeps flowing through the pipe. The
interactive-mode gate now checks TERMINAL_PROMPTS rather than `-t 0`,
which also lets `--interactive` work under a pipe.

Regression coverage: the remote-install gate's second run now pipes the
bootstrap in under a `script` pty (the literal `cat | bash` shape a
curl user takes), and new tests run install.sh in a pty with the script on
a pipe to assert the mode and engine prompts still answer on the terminal.

Fixes #933

* fix(installer): drain the piped bootstrap before the handoff exec

A curl|bash bootstrap leaves its unread tail on stdin; the tagged installer
reads its commands from a file, so nothing drained the pipe and a still-
streaming curl reported error 23 when the installer exited. Drain stdin to
EOF before exec (Greptile P2 on #935).

Gate: drip-feed the piped run so the producer is mid-write at the handoff
like real curl, and run it under pipefail so a producer killed early fails
the run. Tests: pass --tag so a slow release lookup cannot eat the window,
wait for the engine prompt before Ctrl+C, and quote the installer path.

* test(installer): propagate a killed producer's status in the piped gate

A cat dying mid-drip (SIGPIPE when the consumer abandons the pipe) was
swallowed by the loop and the final rm, so drip returned success and
pipefail could not see the download failure the arm exists to catch.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-11 17:37:51 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent c3c390f19d
commit e9598d64ce
6 changed files with 265 additions and 36 deletions
+51 -5
View File
@@ -20,10 +20,11 @@
# guardrail allows the bootstrap to come from main, and this commit's copy is
# what a merged PR would serve anyway.
#
# Flags the installer sees: --auto (no TTY), --skip-models (a model is 40-75 MB
# Flags the installer sees: --auto, --skip-models (a model is 40-75 MB
# per run and the download path is checksum-verified elsewhere), --tag (the
# selection under test). Not --skip-system-deps: the per-distro package
# installation is the point.
# installation is the point. Run 1 is headless; run 2 pipes the bootstrap in
# under a `script` pty so the real `curl | bash` fd-0 shape stays covered.
#
# Fail closed: after the install, the clone's tree must equal HEAD's tree —
# the #701 guardrail that installer, sourced modules and requirement exports
@@ -146,6 +147,49 @@ run_remote_install() {
su - "$INSTALL_USER" -c "cd '$INSTALL_HOME' && VOCALINUX_REPO_URL='file://$MIRROR' bash '$BOOTSTRAP' --auto --skip-models '--tag=$GATE_TAG'"
}
# The user's literal `curl | bash`: installer code reaches bash on a pipe while
# a controlling terminal exists. `script` allocates that pty — a saved file on
# stdin or a headless pipe takes the headless --auto branch instead, which is
# exactly how the fd-0 redirect regression in #933 slipped through. bsdutils /
# util-linux `script` is in the base package set of every gated distro.
command -v script >/dev/null 2>&1 || fail "script(1) not found; needed to give the piped run a terminal"
#
# The producer half matters too: a `cat` of the whole file finishes inside the
# pipe buffer long before bash reaches the tagged-installer handoff, so it can
# never be killed by a consumer that abandons the pipe the way a still-
# streaming curl can (the error 23 the #933 fix still had to drain for). The
# helper below trickles the bootstrap so it is mid-write at the handoff, and
# pipefail makes its exit status count — a consumer that closes the pipe early
# kills it with SIGPIPE and fails the run instead of hiding behind bash's own
# exit status.
PIPED_FEED=/tmp/vl-piped-feed.sh
cat >"$PIPED_FEED" <<'EOF'
#!/usr/bin/env bash
# Usage: vl-piped-feed.sh BOOTSTRAP [installer args...]
set -o pipefail
bootstrap=$1; shift
# ~16 KiB/s: still writing (and soon blocked on the full pipe buffer) when the
# bootstrap reaches the tagged-installer handoff, the way curl is mid-download.
drip() {
local file=$1 chunk rc=0
split -b 8192 "$file" /tmp/vl-feed-
for chunk in /tmp/vl-feed-*; do
# A cat killed by SIGPIPE means the consumer closed the pipe early — the
# download failure this arm exists to catch. Keep the status through the
# cleanup so pipefail above sees it.
cat "$chunk" || { rc=$?; break; }
sleep 0.5
done
rm -f /tmp/vl-feed-*
return "$rc"
}
drip "$bootstrap" | bash -s -- "$@"
EOF
chmod 0755 "$PIPED_FEED"
run_remote_install_piped() {
su - "$INSTALL_USER" -c "cd '$INSTALL_HOME' && VOCALINUX_REPO_URL='file://$MIRROR' script -qec \"'$PIPED_FEED' '$BOOTSTRAP' --auto --skip-models '--tag=$GATE_TAG'\" /dev/null"
}
assert_remote_install() {
echo "== Assert: the clone is exactly the selected tag =="
[ -d "$CLONE_DIR/.git" ] || fail "remote install left no clone at $CLONE_DIR"
@@ -256,9 +300,11 @@ run_remote_install || fail "the remote install exited non-zero on a fresh clone"
echo " install took $(( $(date +%s) - START ))s"
assert_remote_install
echo "== Run 2: re-run over the existing clone (fetch + reset) =="
echo "== Run 2: re-run over the existing clone (fetch + reset), piped under a terminal =="
# The second bootstrap sees $CLONE_DIR/.git and updates it instead of cloning:
# the arm a user re-running curl|bash takes on every release after the first.
# the arm a user re-running curl|bash takes on every release after the first —
# served here literally through `cat | bash` on a pty so the terminal-backed
# pipe keeps regression coverage (#933).
# Move the clone off the tag and drop its local tag ref first: a run that only
# had to notice the right commit was already checked out would pass even if
# the fetch and reset were both no-ops.
@@ -273,7 +319,7 @@ su - "$INSTALL_USER" -c "git -C '$CLONE_DIR' reset --hard '$AWAY_SHA' \
|| fail "the clone did not move off $GATE_TAG; the re-run proves nothing"
[ -z "$(git -C "$CLONE_DIR" tag -l "$GATE_TAG")" ] \
|| fail "the clone kept refs/tags/$GATE_TAG; the re-run proves nothing"
run_remote_install || fail "the remote install exited non-zero over the existing clone"
run_remote_install_piped || fail "the terminal-backed piped install exited non-zero over the existing clone"
grep -l "Updating existing clone" "$INSTALL_HOME"/.local/state/vocalinux/install-*.log >/dev/null 2>&1 \
|| fail "no install log shows 'Updating existing clone'; the fetch arm did not run"
assert_remote_install