Files
voculinux/scripts/install-remote-test.sh
T
e9598d64ce 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>
2026-10-11 17:37:51 -07:00

328 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# Run install.sh's remote path end to end in a distro container and check what
# it installed.
#
# Usage (a read-only mount is enough; the tag is published from git, not the
# tree):
# docker run --rm -v "$PWD:/repo:ro" debian:12 bash /repo/scripts/install-remote-test.sh
# or: just remote-install-gate debian:12
#
# The path under test is the one a `curl | bash` user takes and
# scripts/install-test.sh never reaches: bootstrap outside a checkout, --tag
# selection, clone of the selected tag, handoff to the tagged installer with
# the original arguments plus the remote venv path, then a second run's
# fetch+reset over the existing clone.
#
# The remote is a local mirror so the gate answers for *this commit*: the tag
# is published from HEAD's tree and VOCALINUX_REPO_URL points the bootstrap at
# it. A gate that cloned the public repo would install the last release, not
# the checkout under test. The tag's own install.sh is the bootstrap: the #701
# 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, --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. 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
# all come from the selected tag is only as strong as that equality.
set -euo pipefail
REPO="${REPO:-/repo}"
INSTALL_USER="${INSTALL_USER:-installer}"
INSTALL_HOME="/home/$INSTALL_USER"
# Opaque on purpose: a name the public repository cannot already carry, so a
# silent fallback to GitHub fails loudly instead of installing the wrong ref.
GATE_TAG="${GATE_TAG:-v0.0.0-remote-gate}"
MIRROR="/srv/vocalinux-remote.git"
BOOTSTRAP="/tmp/vl.sh"
CLONE_DIR="$INSTALL_HOME/.local/share/vocalinux-install"
REMOTE_VENV="$INSTALL_HOME/.local/share/vocalinux/venv"
fail() {
echo "FAIL: $*" >&2
dump_install_log
exit 1
}
# install.sh writes a full transcript under ~/.local/state/vocalinux/. It is the
# only place the per-distro package manager output survives.
dump_install_log() {
local log
log="$(ls -1t "$INSTALL_HOME"/.local/state/vocalinux/install-*.log 2>/dev/null | head -n1 || true)"
if [ -n "$log" ]; then
echo "== last 200 lines of $log ==" >&2
tail -n 200 "$log" >&2
else
echo "== no install log was written ==" >&2
fi
}
# A mirror going slow mid-transaction leaves a half-populated cache, so
# re-running the same transaction resumes rather than restarts.
retry() {
local attempt
for attempt in 1 2 3; do
"$@" && return 0
echo " '$1' failed (attempt $attempt); retrying" >&2
sleep 5
done
return 1
}
[ -f "$REPO/install.sh" ] || fail "$REPO/install.sh not found; mount the checkout at \$REPO"
. /etc/os-release
echo "== ${NAME:-unknown} ${VERSION_ID:-} =="
echo "== Bootstrap: sudo, git and an unprivileged user =="
# The minimum for install.sh to start, and nothing it should install itself.
# git is for the mirror below; the remote path ensures it again for real users.
# Same arms as scripts/install-test.sh so `just remote-install-gate <distro>`
# works everywhere the local gate does.
case "$ID" in
ubuntu | debian)
export DEBIAN_FRONTEND=noninteractive
retry apt-get update -qq
retry apt-get install -y -qq sudo git ca-certificates >/dev/null
;;
fedora | rhel | centos)
retry dnf install -y -q sudo git shadow-utils >/dev/null
;;
arch)
# -Syu, never -Sy: archlinux:latest lags the mirrors, and a partial upgrade
# would be this script's breakage rather than the installer's.
retry pacman -Syu --noconfirm --needed sudo git >/dev/null
;;
opensuse-tumbleweed | opensuse-leap | sles)
retry zypper --non-interactive --gpg-auto-import-keys refresh >/dev/null
retry zypper --non-interactive install -y sudo git >/dev/null
;;
*)
fail "no bootstrap arm for ID='$ID'; add one when adding the container"
;;
esac
id -u "$INSTALL_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$INSTALL_USER"
echo "$INSTALL_USER ALL=(ALL) NOPASSWD: ALL" >"/etc/sudoers.d/$INSTALL_USER"
chmod 0440 "/etc/sudoers.d/$INSTALL_USER"
echo "== Publish HEAD's tree as tag $GATE_TAG in a local mirror =="
# safe.directory: the mounted checkout and, for a linked worktree, its mounted
# common gitdir are owned by another uid.
git config --global --add safe.directory '*'
# CI checks out with fetch-depth 1, and a shallow boundary commit can neither
# be pushed nor fetched into a ref ("shallow update not allowed"). The tag
# therefore carries an orphan commit built from HEAD's tree: a root commit is
# not a shallow root, and tree equality below still binds the gate to this
# commit's exact contents.
SOURCE_TREE="$(git -C "$REPO" rev-parse 'HEAD^{tree}')" \
|| fail "$REPO is not a git checkout; the gate publishes the tag from git"
git clone --quiet --depth 1 "file://$REPO" /tmp/gate-source
# commit-tree needs an identity the bare container does not carry.
EXPECTED_SHA="$(GIT_AUTHOR_NAME=gate GIT_AUTHOR_EMAIL=gate@local \
GIT_COMMITTER_NAME=gate GIT_COMMITTER_EMAIL=gate@local \
git -C /tmp/gate-source commit-tree "$SOURCE_TREE" -m "remote-install gate")"
git init --bare "$MIRROR" >/dev/null
git -C /tmp/gate-source push "$MIRROR" "$EXPECTED_SHA:refs/tags/$GATE_TAG" >/dev/null
[ "$(git -C "$MIRROR" rev-parse "$GATE_TAG^{tree}")" = "$SOURCE_TREE" ] \
|| fail "the mirror's $GATE_TAG does not carry HEAD's tree"
# The installer clones as $INSTALL_USER, and git refuses a root-owned remote
# for anyone else as "dubious ownership".
chown -R "$INSTALL_USER:$INSTALL_USER" "$MIRROR"
# A remote user downloads install.sh over HTTPS; the gate takes the same file
# out of the tag instead. The tagged copy is this commit's bootstrap, and the
# handoff target is identical either way.
git -C "$MIRROR" show "$GATE_TAG:install.sh" >"$BOOTSTRAP"
chmod 0644 "$BOOTSTRAP"
run_remote_install() {
# cd to $INSTALL_HOME so the bootstrap sees no checkout: local mode is chosen
# by a pyproject.toml naming vocalinux in the working directory, and the
# remote path is the one under test.
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"
local cloned_sha cloned_tree
cloned_sha="$(git -C "$CLONE_DIR" rev-parse HEAD)" \
|| fail "the clone at $CLONE_DIR is not a git checkout"
[ "$cloned_sha" = "$EXPECTED_SHA" ] || fail \
"installed from the wrong revision: clone HEAD is $cloned_sha, $GATE_TAG is $EXPECTED_SHA"
[ "$(git -C "$CLONE_DIR" rev-parse "$GATE_TAG^{commit}" 2>/dev/null || true)" = "$EXPECTED_SHA" ] \
|| fail "the clone does not carry the selected tag ref $GATE_TAG"
# The tag's commit is an orphan; tree equality is the check that installer,
# modules and exports are this commit's files and not a neighbour revision's.
cloned_tree="$(git -C "$CLONE_DIR" rev-parse 'HEAD^{tree}')"
[ "$cloned_tree" = "$SOURCE_TREE" ] || fail \
"the clone's tree $cloned_tree is not HEAD's tree $SOURCE_TREE"
# The artifacts the #701 guardrail names: the tagged installer, its sourced
# modules, and the exports pip installs with --require-hashes.
[ -f "$CLONE_DIR/install.d/system_dependencies.sh" ] \
|| fail "the clone ships no install.d/system_dependencies.sh for the tagged installer to source"
[ -f "$CLONE_DIR/requirements/runtime.txt" ] \
|| fail "the clone ships no requirements/runtime.txt for the tagged installer to install"
# Same shape as the local gate's #736 check, now reachable through the remote
# path: --skip-models means the run itself never reads the manifest.
[ -f "$CLONE_DIR/src/vocalinux/utils/model_checksums.txt" ] \
|| fail "the tag ships no src/vocalinux/utils/model_checksums.txt; install.sh pins model downloads against it"
echo "== Assert: the handoff carried the original arguments =="
# handoff_to_tagged_installer appends --venv-dir; a venv anywhere else means
# the appended argument was dropped.
[ -x "$REMOTE_VENV/bin/python" ] \
|| fail "no remote venv at $REMOTE_VENV; the handoff's --venv-dir did not reach the tagged installer"
# The remote marker relocates the activation helper to ~/.local/bin only when
# VOCALINUX_REMOTE_INSTALL survives the exec.
[ -f "$INSTALL_HOME/.local/bin/activate-vocalinux.sh" ] \
|| fail "no ~/.local/bin/activate-vocalinux.sh; the remote marker did not survive the handoff"
# --skip-models forwarded: a model in the data dir means the tagged installer
# re-parsed arguments the handoff did not deliver.
if [ -d "$INSTALL_HOME/.local/share/vocalinux/models" ]; then
[ -z "$(find "$INSTALL_HOME/.local/share/vocalinux/models" -type f -print -quit)" ] \
|| fail "models were downloaded; --skip-models did not survive the handoff"
fi
echo "== Assert: the installed app is there and answers =="
[ -x "$INSTALL_HOME/.local/bin/vocalinux" ] || fail "no wrapper at ~/.local/bin/vocalinux"
[ -x "$INSTALL_HOME/.local/bin/vocalinux-gui" ] || fail "no wrapper at ~/.local/bin/vocalinux-gui"
[ -f "$INSTALL_HOME/.local/share/applications/vocalinux.desktop" ] \
|| fail "no desktop entry; install_desktop_entry did not run"
# The version the wrapper reports must be the tag's own, not an assertion
# about a version string composed locally.
local expected_version version_output
expected_version="$(grep -oP '__version__\s*=\s*"\K[^"]+' "$CLONE_DIR/src/vocalinux/version.py")" \
|| fail "cannot read __version__ from the clone's version.py"
version_output="$(su - "$INSTALL_USER" -c "'$INSTALL_HOME/.local/bin/vocalinux' --version")" \
|| fail "the installed wrapper cannot report a version"
case "$version_output" in
*"$expected_version"*) ;;
*) fail "wrapper reports '$version_output', expected the tag's $expected_version" ;;
esac
echo " vocalinux --version: $version_output"
# Same blind spots as the local gate: the injection binaries are reached
# through subprocess, and the IBus runtime only where the package list
# promises it.
for tool in xclip xsel wl-copy; do
command -v "$tool" >/dev/null 2>&1 || fail "$tool is not on PATH; install.sh lists it for every distro"
done
case "$ID" in
arch | opensuse-tumbleweed | opensuse-leap | sles)
command -v ibus-daemon >/dev/null 2>&1 \
|| fail "no ibus-daemon: the package list ships the IBus typelib without the runtime ibus_engine.py spawns"
;;
esac
su - "$INSTALL_USER" -c "'$REMOTE_VENV/bin/python' - " <<'PY' || fail "the installation does not import"
import vocalinux.main
import vocalinux.ui.tray_indicator
import vocalinux.speech_recognition.recognition_manager
import vocalinux.text_injection.text_injector
from vocalinux.ui.keyboard_backends import EVDEV_AVAILABLE, PYNPUT_AVAILABLE
import gi
# keyboard_backends imports each backend under `except ImportError`, so the
# import above stays clean when evdev and pynput both failed to build. Assert
# the flags rather than the import.
assert EVDEV_AVAILABLE or PYNPUT_AVAILABLE, (
"no keyboard backend is importable: evdev and pynput both failed to install"
)
# The typelibs the installer is responsible for putting on the system. Both are
# loaded lazily at runtime, so importing the modules above proves nothing about
# them: a missing one would surface on a user's first dictation instead.
gi.require_version("Gtk", "3.0")
gi.require_version("IBus", "1.0")
from gi.repository import Gtk, IBus # noqa: F401
print(
" main, tray, recognition, injection, Gtk and IBus import;"
f" keyboard backends: evdev={EVDEV_AVAILABLE} pynput={PYNPUT_AVAILABLE}"
)
PY
}
echo "== Run 1: remote install (fresh clone) as $INSTALL_USER =="
START=$(date +%s)
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), 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 —
# 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.
AWAY_SHA="$(su - "$INSTALL_USER" -c "cd '$CLONE_DIR' && \
GIT_AUTHOR_NAME=gate GIT_AUTHOR_EMAIL=gate@local \
GIT_COMMITTER_NAME=gate GIT_COMMITTER_EMAIL=gate@local \
git commit-tree 'HEAD^{tree}' -p HEAD -m moved")"
su - "$INSTALL_USER" -c "git -C '$CLONE_DIR' reset --hard '$AWAY_SHA' \
&& git -C '$CLONE_DIR' update-ref -d 'refs/tags/$GATE_TAG'" \
|| fail "could not move the clone off $GATE_TAG for the re-run arm"
[ "$(git -C "$CLONE_DIR" rev-parse HEAD)" = "$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_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
echo "PASS: the remote install path (bootstrap -> tag -> clone/fetch -> handoff) installs ${NAME:-this distro}'s app"