deploy: bw is a non-interactive backend — our prompt, never bw's (bw-lib.sh) (#1339)
Paul's call: internal tools own the password prompt and hand it to the bw CLI, so we never depend on bw behaving interactively. bw's inquirer prompt writes to stdout — inside $(...) it's invisible and reads as a hang (bit doctor, then ops-dev, then release-server, then tonight's promote). One sourced library now owns the policy: bw_unlock_session (our read -rs on /dev/tty, label on stderr, password via --passwordenv — never argv or disk, unset immediately) and bw_run (every bw call under perl's alarm — get, list, sync, status included, not just the two that already had it). All three scripts converted; the advice strings stop teaching the invisible-prompt pattern too. bw login (2FA, rare) stays the one genuinely interactive command, run by the human directly.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+86
−41
deploy/bw-lib.sh+58 −0deploy/doctor.sh+8 −8deploy/ops-dev.sh+13 −13deploy/release-server.sh+7 −20
diff --git a/deploy/bw-lib.sh b/deploy/bw-lib.sh| new file mode 100644 |
| index 0000000..dbc10a7 |
| --- /dev/null |
| +++ b/deploy/bw-lib.sh |
| @@ -0,0 +1,58 @@ |
| +# 🤖 The one way scripts talk to the Bitwarden CLI. Sourced (not run) by |
| +# release-server.sh, doctor.sh, ops-dev.sh — POSIX sh. |
| +# |
| +# Two rules, learned the hard way: |
| +# |
| +# 1. **bw never prompts — we do.** bw's inquirer prompt writes to stdout, so |
| +# inside `$(...)` it sits invisible and the script "hangs" until a stray |
| +# Enter redraws it (the doctor lesson, then the release-server lesson). |
| +# Instead, OUR `read -rs` prompts on /dev/tty with the label on stderr — |
| +# always visible, capture-proof — and the password reaches bw through |
| +# --passwordenv: never argv (ps would show it), never disk, unset the |
| +# moment the unlock returns. bw is a non-interactive backend, full stop. |
| +# (The one exception is `bw login` — 2FA is genuinely interactive and |
| +# rare; scripts detect "unauthenticated" and hand the human that ONE |
| +# command to run themselves.) |
| +# |
| +# 2. **Every bw call gets a watchdog.** bw's API calls carry no timeout and |
| +# can stall for good on a bad connection or stale session (a promote sat |
| +# 14 silent minutes in `bw get` 2026-07-21; another hung indefinitely in |
| +# `bw unlock` 2026-07-24). perl's alarm is the never-a-hang promise — |
| +# the timer survives exec, and macOS ships perl. |
| + |
| +# bw_run <seconds> <bw-args...> — run one bw command under a watchdog. |
| +# Exit status is bw's, or SIGALRM's (142) on a stall. |
| +bw_run() { |
| + _bw_t="$1"; shift |
| + perl -e "alarm $_bw_t; exec @ARGV" bw "$@" |
| +} |
| + |
| +# bw_unlock_session — unlock the vault with OUR prompt and print the session |
| +# token on stdout (nothing else touches stdout). Callers: |
| +# |
| +# BW_SESSION="$(bw_unlock_session)" && export BW_SESSION || <degrade> |
| +# |
| +# Fails (rather than blocking) without a tty: headless contexts export |
| +# BW_SESSION themselves or use the explicit env-key overrides. |
| +bw_unlock_session() { |
| + if [ ! -e /dev/tty ]; then |
| + echo "bw: no tty to prompt on — export BW_SESSION (bw unlock --raw) first" >&2 |
| + return 1 |
| + fi |
| + printf "Bitwarden master password (typing is hidden): " >&2 |
| + IFS= read -rs LCHAT_BW_PASSWORD </dev/tty || { |
| + echo >&2 |
| + unset LCHAT_BW_PASSWORD |
| + return 1 |
| + } |
| + echo >&2 |
| + # 90s: the KDF is seconds, but bw's first server round-trip gets headroom. |
| + _bw_sess="$(LCHAT_BW_PASSWORD="$LCHAT_BW_PASSWORD" perl -e 'alarm 90; exec @ARGV' \ |
| + bw unlock --raw --passwordenv LCHAT_BW_PASSWORD)" || _bw_sess="" |
| + unset LCHAT_BW_PASSWORD |
| + if [ -z "$_bw_sess" ]; then |
| + echo "bw: unlock failed or stalled — run 'bw sync' and retry, or export BW_SESSION yourself" >&2 |
| + return 1 |
| + fi |
| + printf '%s\n' "$_bw_sess" |
| +} |
diff --git a/deploy/doctor.sh b/deploy/doctor.sh| index caf7c87..899f4db 100755 |
| --- a/deploy/doctor.sh |
| +++ b/deploy/doctor.sh |
| @@ -43,6 +43,8 @@ root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" |
| # The Bitwarden item names checked below — the vault contract. |
| # shellcheck source=deploy/vault-items.sh |
| . "$root/deploy/vault-items.sh" |
| +# shellcheck source=deploy/bw-lib.sh |
| +. "$root/deploy/bw-lib.sh" |
| # Only used to point at gen-fleet-conf.sh when a tunnel is down. |
| VAULT="${LCHAT_VAULT_DIR:-$HOME/elevenmessenger/secrets-vault}" |
| # Box aliases from ~/.ssh/config; `ssh -G` resolves user+hostname, so the ssh |
| @@ -91,7 +93,7 @@ check_tools() { |
| # Confirm the item is reachable WITHOUT printing it: we only look at whether |
| # the fetch succeeded, never at the value. |
| check_signing_item() { |
| - if bw get item "$BW_ITEM_SIGNING_KEY" \ |
| + if bw_run 30 get item "$BW_ITEM_SIGNING_KEY" \ |
| >/dev/null 2>&1; then |
| ok "signing key item is present" |
| else |
| @@ -107,7 +109,7 @@ check_release() { |
| bad "bw not installed — cannot reach the signing key" |
| fix "brew install bitwarden-cli" |
| else |
| - st="$(bw status 2>/dev/null | tr ',' '\n' | sed -n 's/.*"status":"\([a-z]*\)".*/\1/p')" |
| + st="$(bw_run 20 status 2>/dev/null | tr ',' '\n' | sed -n 's/.*"status":"\([a-z]*\)".*/\1/p')" |
| case "$st" in |
| unlocked) |
| ok "bitwarden unlocked" |
| @@ -121,11 +123,9 @@ check_release() { |
| # hanging; --no-unlock forces that path deliberately. |
| if [ "$unlock" = yes ] && [ -t 0 ]; then |
| printf ' unlocking bitwarden to verify the signing key\n' |
| - # Do NOT redirect stderr here: `bw unlock` writes its master-password |
| - # prompt to stderr and the session token to stdout. Swallowing stderr |
| - # hides the prompt, and the script looks hung while it silently waits |
| - # for input. Only stdout is captured. |
| - BW_SESSION="$(bw unlock --raw)" |
| + # Our prompt, never bw's; watchdogged (bw-lib.sh) — bw's own |
| + # prompt inside $(...) is invisible and reads as a hang. |
| + BW_SESSION="$(bw_unlock_session)" || BW_SESSION="" |
| export BW_SESSION |
| if [ -n "${BW_SESSION:-}" ]; then |
| ok "bitwarden unlocked (this run only)" |
| @@ -136,7 +136,7 @@ check_release() { |
| fi |
| else |
| warn "bitwarden is locked — signing key not verified" |
| - fix 'export BW_SESSION=$(bw unlock --raw) # then re-run' |
| + fix "bw unlock # then paste the 'export BW_SESSION=...' line it prints, and re-run" |
| fi ;; |
| *) |
| bad "bitwarden not logged in" |
diff --git a/deploy/ops-dev.sh b/deploy/ops-dev.sh| index 6f4650b..b6f6094 100755 |
| --- a/deploy/ops-dev.sh |
| +++ b/deploy/ops-dev.sh |
| @@ -58,6 +58,8 @@ root="$(CDPATH= cd -- "$here/.." && pwd)" |
| # not a search (deploy/vault-items.sh). |
| # shellcheck source=deploy/vault-items.sh |
| . "$here/vault-items.sh" |
| +# shellcheck source=deploy/bw-lib.sh |
| +. "$here/bw-lib.sh" |
| # The boxes, as committed in docs/multi-edge-dev-cutover.md. The Dublin key is |
| # the SHARED box key (comment `eleven-dev-box` — box-named, not personal), |
| @@ -159,12 +161,12 @@ find_item() { |
| if [ -n "$pinned" ]; then printf '%s\n' "$pinned"; return 0; fi |
| synced=no |
| while :; do |
| - names="$(bw list items --search "$search" 2>/dev/null | jq -r '.[].name' || true)" |
| + names="$(bw_run 30 list items --search "$search" 2>/dev/null | jq -r '.[].name' || true)" |
| n="$(printf '%s\n' "$names" | grep -c . || true)" |
| if [ "$n" = 1 ]; then printf '%s\n' "$names"; return 0; fi |
| if [ "$synced" = no ]; then |
| echo "ops dev: $n items match '$search' — syncing bw's local cache and retrying" >&2 |
| - bw sync >/dev/null 2>&1 || true |
| + bw_run 60 sync >/dev/null 2>&1 || true |
| synced=yes |
| continue |
| fi |
| @@ -187,22 +189,20 @@ load_dev_creds() { |
| echo "ops dev: bw is not installed, and the credentials aren't in the environment" >&2 |
| return 1; } |
| command -v jq >/dev/null 2>&1 || { echo "ops dev: jq is required" >&2; return 1; } |
| - st="$(bw status 2>/dev/null | jq -r '.status' 2>/dev/null || true)" |
| + st="$(bw_run 20 status 2>/dev/null | jq -r '.status' 2>/dev/null || true)" |
| case "$st" in |
| unlocked) ;; |
| locked) |
| if [ -t 0 ]; then |
| echo "ops dev: unlocking bitwarden to read the dev agent creds" >&2 |
| - # Do NOT redirect stderr: `bw unlock` writes its master-password prompt |
| - # to stderr and the session token to stdout — swallowing stderr hides |
| - # the prompt and the script looks hung (the doctor lesson). Only |
| - # stdout is captured, and the token is never displayed. |
| - BW_SESSION="$(bw unlock --raw)" || true |
| + # Our prompt, never bw's; watchdogged (bw-lib.sh) — bw's own prompt |
| + # inside $(...) is invisible and reads as a hang. |
| + BW_SESSION="$(bw_unlock_session)" || true |
| [ -n "${BW_SESSION:-}" ] || { echo "ops dev: unlock failed" >&2; return 1; } |
| export BW_SESSION |
| else |
| echo "ops dev: bitwarden is locked and there is no tty to prompt on" >&2 |
| - echo ' export BW_SESSION=$(bw unlock --raw) # then re-run' >&2 |
| + echo " bw unlock # then paste the 'export BW_SESSION=...' line it prints, and re-run" >&2 |
| return 1 |
| fi ;; |
| *) |
| @@ -220,7 +220,7 @@ load_dev_creds() { |
| # The item is the sealed env file: KEY=VALUE lines in its secure note (or |
| # custom fields). Pull only the allowlisted names; never echo a value. |
| - lines="$(bw get item "$item" 2>/dev/null | jq -r --arg re "^($DEV_CRED_VARS)=" ' |
| + lines="$(bw_run 30 get item "$item" 2>/dev/null | jq -r --arg re "^($DEV_CRED_VARS)=" ' |
| ((.notes // "" | split("\n")) + [.fields[]? | "\(.name)=\(.value)"])[] |
| | select(test($re))' || true)" |
| if [ -n "$lines" ]; then |
| @@ -248,11 +248,11 @@ EOF |
| # only the token is a secret. |
| if [ -z "${DNSIMPLE_TOKEN:-}" ]; then |
| ditem="${LCHAT_DNSIMPLE_ITEM:-$BW_ITEM_DNSIMPLE}" |
| - json="$(bw get item "$ditem" 2>/dev/null || true)" |
| + json="$(bw_run 30 get item "$ditem" 2>/dev/null || true)" |
| if [ -z "$json" ]; then |
| echo "ops dev: item '$ditem' not in bw's local cache — syncing and retrying" >&2 |
| - bw sync >/dev/null 2>&1 || true |
| - json="$(bw get item "$ditem" 2>/dev/null || true)" |
| + bw_run 60 sync >/dev/null 2>&1 || true |
| + json="$(bw_run 30 get item "$ditem" 2>/dev/null || true)" |
| fi |
| if [ -z "$json" ]; then |
| echo "ops dev: no bitwarden item named exactly '$ditem'" >&2 |
diff --git a/deploy/release-server.sh b/deploy/release-server.sh| index 72fe3ee..7ef7721 100755 |
| --- a/deploy/release-server.sh |
| +++ b/deploy/release-server.sh |
| @@ -55,6 +55,8 @@ set -eu |
| # The Bitwarden item names this script reads — the vault contract. |
| # shellcheck source=deploy/vault-items.sh |
| . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/vault-items.sh" |
| +# shellcheck source=deploy/bw-lib.sh |
| +. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/bw-lib.sh" |
| REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}" |
| # Per-commit server-<sha> build artifacts live in their own public repo so the |
| @@ -97,7 +99,7 @@ case "${1:-}" in publish|promote) |
| # never-a-hang promise, enforced. The seed stays in the pipe, never on |
| # disk. bw_stalled distinguishes a killed 30s read from a fast empty one |
| # (vault locked), which still falls through to the unlock prompt below. |
| - bw_key() { perl -e 'alarm 30; exec @ARGV' bw get item "$BW_ITEM_SIGNING_KEY" 2>/dev/null \ |
| + bw_key() { bw_run 30 get item "$BW_ITEM_SIGNING_KEY" 2>/dev/null \ |
| | jq -r '.fields[]? | select((.value|length)>40).value' | head -1; } |
| bw_stalled() { # $1 = start epoch; empty result + ≥30s elapsed = the watchdog fired |
| [ $(( $(date +%s) - $1 )) -ge 30 ] || return 1 |
| @@ -112,25 +114,10 @@ case "${1:-}" in publish|promote) |
| # session): unlock — prompts for the master password on the tty — then |
| # read again. |
| echo "release-server: unlocking Bitwarden to read the signing key…" >&2 |
| - # We prompt, not bw: inside $(…) bw's inquirer prompt goes to the |
| - # captured stdout, so it sits invisible until a stray Enter forces a |
| - # redraw (the "I had to press Enter" papercut — the earlier /dev/tty |
| - # stdin pin didn't fix it because the prompt is an OUTPUT problem). |
| - # A shell read on stderr is always visible; --passwordenv skips |
| - # inquirer entirely and the password never touches disk or argv. |
| - printf "release-server: Bitwarden master password (typing is hidden): " >&2 |
| - IFS= read -rs LCHAT_BW_PASSWORD </dev/tty || { # set -e: die with a hint, not silently |
| - echo >&2; echo "release-server: no tty to prompt on — run 'bw unlock' first or set LCHAT_RELEASE_KEY" >&2; exit 1; } |
| - echo >&2 |
| - # The unlock gets the same never-a-hang watchdog as the reads — bw can |
| - # stall indefinitely here too (observed 2026-07-24: a promote hung in |
| - # `bw unlock` after publish's unlock had just worked). 90s: the KDF is |
| - # seconds, but a slow first server round-trip deserves headroom. |
| - BW_SESSION="$(LCHAT_BW_PASSWORD="$LCHAT_BW_PASSWORD" perl -e 'alarm 90; exec @ARGV' bw unlock --raw --passwordenv LCHAT_BW_PASSWORD)" \ |
| - && [ -n "$BW_SESSION" ] && export BW_SESSION || { |
| - unset LCHAT_BW_PASSWORD |
| - echo "release-server: Bitwarden unlock failed or stalled — run 'bw sync', then 'bw unlock' and export the BW_SESSION line it prints (or set LCHAT_RELEASE_KEY), and retry" >&2; exit 1; } |
| - unset LCHAT_BW_PASSWORD |
| + # Our prompt, never bw's; watchdogged — the whole policy lives in |
| + # bw-lib.sh (set -e: die with a hint, not silently). |
| + BW_SESSION="$(bw_unlock_session)" && export BW_SESSION || { |
| + echo "release-server: set LCHAT_RELEASE_KEY (or export BW_SESSION) and retry" >&2; exit 1; } |
| _t0="$(date +%s)" |
| LCHAT_RELEASE_KEY="$(bw_key)" |
| [ -n "$LCHAT_RELEASE_KEY" ] || { bw_stalled "$_t0" && exit 1 || true; } |