From e72663ef624b26d91de80fa898f8c66919155a18 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 12:15:08 +0000 Subject: [PATCH] fix: headless credential prompts refuse loudly, naming their variable (#42) A bare 'read -rsp' with no tty exits non-zero and set -e ends the script with no output at all: the release drill watched 'rig runner remove' exit 1 in complete silence, and a guest bootstrap stop mid-log the same way. Every prompt now checks for a tty first and dies naming the variable that unblocks an unattended run; every read is || die-guarded so EOF at a real prompt also gets a last word. The no-bare-read test swept up runner repoint's two prompts, which the issue had not counted. Fixes #42 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++++++++++++++++ commands/bootstrap.sh | 10 ++++++++-- commands/runner-install.sh | 5 ++++- commands/runner-remove.sh | 6 +++++- commands/runner-repoint.sh | 8 ++++++-- test/cli.sh | 21 +++++++++++++++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27528f1..07367b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ on the way to cutting its first release, and this file starts there. ## Unreleased +### Fixed + +- **Headless credential prompts refuse loudly instead of dying silently** + (#42) — the interactive credential prompts (`TS_AUTHKEY` in `bootstrap`, + `RUNNER_TOKEN` in `runner install`, `RUNNER_REMOVE_TOKEN` in + `runner remove`, and both tokens in `runner repoint` — a site the new + no-bare-read test caught after the issue counted three) were bare + `read -rsp`: with stdin not a tty (CI, + `box exec`, any script), `read` fails, `set -e` ends the run, and the + log just *stops* — exit 1, no last word, measured live in the + 2026-07-19 release drill. Each prompt now checks for a tty first and + dies naming the variable that unblocks an unattended run (`runner + remove` also names `--local`), and every `read` is `|| die`-guarded so + EOF at a real prompt gets the same courtesy. `db.sh` already held the + line here; now all of rig does. + ### Added - **Tagged releases, and an installer that installs them** (#32) — the rig diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 4c47062..ab2626d 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -396,9 +396,15 @@ elif [ "$JOIN" = "login" ]; then tailscale up --hostname="$TS_HOSTNAME" verify_user_owned back-out else - # env override, else prompt; never touches disk + # env override, else prompt; never touches disk. The prompt only fires on a + # tty: with no terminal, a bare `read` exits non-zero and `set -e` would end + # the whole bootstrap with NO last word — the 2026-07-19 drill met exactly + # that, a log that stops mid-converge with exit 1 and nothing to grep. An + # unattended run gets the same refusal every other guard gives: loud, and + # naming the variable that unblocks it. if [ -z "${TS_AUTHKEY:-}" ]; then - read -rsp "tailscale pre-auth key (single-use, tagged, <=1h expiry): " TS_AUTHKEY + [ -t 0 ] || die "TS_AUTHKEY is unset and stdin is not a tty — set TS_AUTHKEY to run unattended" + read -rsp "tailscale pre-auth key (single-use, tagged, <=1h expiry): " TS_AUTHKEY || { echo; die "no pre-auth key read (EOF) — set TS_AUTHKEY to run unattended"; } echo fi [ -n "${TS_AUTHKEY:-}" ] || die "empty pre-auth key" diff --git a/commands/runner-install.sh b/commands/runner-install.sh index e9516ea..6dcd97f 100755 --- a/commands/runner-install.sh +++ b/commands/runner-install.sh @@ -120,8 +120,11 @@ fi # --- registration token — only when registration is actually pending ------- if [ "$REG_PENDING" -eq 1 ]; then RUNNER_TOKEN="${RUNNER_TOKEN:-}" + # Prompt only on a tty: headless, a bare `read` dies under set -e with no + # message at all (drill-measured). Refuse loudly, naming the variable. if [ -z "$RUNNER_TOKEN" ]; then - read -rsp "runner registration token (short-lived): " RUNNER_TOKEN + [ -t 0 ] || die "RUNNER_TOKEN is unset and stdin is not a tty — set RUNNER_TOKEN to run unattended" + read -rsp "runner registration token (short-lived): " RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set RUNNER_TOKEN to run unattended"; } echo fi [ -n "$RUNNER_TOKEN" ] || die "empty registration token" diff --git a/commands/runner-remove.sh b/commands/runner-remove.sh index 87348b9..d3f6519 100755 --- a/commands/runner-remove.sh +++ b/commands/runner-remove.sh @@ -70,8 +70,12 @@ fi REMOVE_TOKEN="" if [ -e "$RUNNER_DIR/.runner" ] && [ "$LOCAL" -eq 0 ]; then REMOVE_TOKEN="${RUNNER_REMOVE_TOKEN:-}" + # Prompt only on a tty: headless, a bare `read` dies under set -e with no + # message at all — the drill hit exactly this (wrong env var, exit 1, zero + # output). Refuse loudly, naming both the variable and the tokenless out. if [ -z "$REMOVE_TOKEN" ]; then - read -rsp "runner removal token (short-lived): " REMOVE_TOKEN + [ -t 0 ] || die "RUNNER_REMOVE_TOKEN is unset and stdin is not a tty — set RUNNER_REMOVE_TOKEN to run unattended, or use --local" + read -rsp "runner removal token (short-lived): " REMOVE_TOKEN || { echo; die "no removal token read (EOF) — set RUNNER_REMOVE_TOKEN to run unattended, or use --local"; } echo fi [ -n "$REMOVE_TOKEN" ] || die "empty removal token" diff --git a/commands/runner-repoint.sh b/commands/runner-repoint.sh index 9f5463c..a91e497 100755 --- a/commands/runner-repoint.sh +++ b/commands/runner-repoint.sh @@ -128,8 +128,11 @@ log "labels: ${LABELS}" # while the runner is still registered and working. if [ "$LOCAL" -eq 0 ]; then RUNNER_REMOVE_TOKEN="${RUNNER_REMOVE_TOKEN:-}" + # Prompt only on a tty — headless, a bare `read` dies under set -e with no + # message (issue #42; same cure as runner-install/remove). if [ -z "$RUNNER_REMOVE_TOKEN" ]; then - read -rsp "removal token for ${CURRENT_URL} (short-lived): " RUNNER_REMOVE_TOKEN + [ -t 0 ] || die "RUNNER_REMOVE_TOKEN is unset and stdin is not a tty — set RUNNER_REMOVE_TOKEN to run unattended, or use --local" + read -rsp "removal token for ${CURRENT_URL} (short-lived): " RUNNER_REMOVE_TOKEN || { echo; die "no removal token read (EOF) — set RUNNER_REMOVE_TOKEN to run unattended, or use --local"; } echo fi [ -n "$RUNNER_REMOVE_TOKEN" ] || die "empty removal token" @@ -138,7 +141,8 @@ fi RUNNER_TOKEN="${RUNNER_TOKEN:-}" if [ -z "$RUNNER_TOKEN" ]; then - read -rsp "registration token for ${TARGET_URL} (short-lived): " RUNNER_TOKEN + [ -t 0 ] || die "RUNNER_TOKEN is unset and stdin is not a tty — set RUNNER_TOKEN to run unattended" + read -rsp "registration token for ${TARGET_URL} (short-lived): " RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set RUNNER_TOKEN to run unattended"; } echo fi [ -n "$RUNNER_TOKEN" ] || die "empty registration token" diff --git a/test/cli.sh b/test/cli.sh index 105f1c9..07d18ab 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -461,6 +461,27 @@ fi check "runner: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" runner frobnicate +# --- headless prompts refuse loudly (issue #42) ------------------------------ +# The three credential prompts (TS_AUTHKEY, RUNNER_TOKEN, RUNNER_REMOVE_TOKEN) +# used to be bare `read -rsp`: with no tty, read exits non-zero and `set -e` +# ends the script with NO output at all — the drill watched a bootstrap die +# mid-converge with exit 1 and nothing to grep. Each prompt now refuses first, +# naming its variable. The prompts live behind the root check (and, for the +# runner pair, behind a real registration), so the harness cannot reach them +# non-root; grep the guards so a deleted one cannot ship green (repo +# precedent: the login-path tag refusals above). +check "bootstrap: headless TS_AUTHKEY prompt refuses loudly" 0 "" \ + grep -q 'TS_AUTHKEY is unset and stdin is not a tty' "$ROOT/commands/bootstrap.sh" +check "runner install: headless token prompt refuses loudly" 0 "" \ + grep -q 'RUNNER_TOKEN is unset and stdin is not a tty' "$ROOT/commands/runner-install.sh" +check "runner remove: headless token prompt refuses loudly" 0 "" \ + grep -q 'RUNNER_REMOVE_TOKEN is unset and stdin is not a tty' "$ROOT/commands/runner-remove.sh" +# The EOF-at-the-prompt path (Ctrl-D on a real tty) must also die with a last +# word rather than ride set -e into silence: every read is `|| die`-guarded, +# so a bare `read -rsp` (no `||` on its line) must not exist anywhere. +check "prompts: no bare read -rsp remains" 1 "" \ + grep -RE 'read -rsp[^|]*$' "$ROOT/commands/" + # --- runner install: --repo must agree with what the box is already on ------- # The bug: `install --repo B` on a box registered to repo A skipped configure, # restarted the service on A, and reported success — --repo accepted, validated,