eleven / messenger Public

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>
Paul Campbell fe505298854d73a0e18064eb3090376c9d6278dd parent 7a36948
4 files changed, +86 −41
  • deploy/bw-lib.sh +58 −0
  • deploy/doctor.sh +8 −8
  • deploy/ops-dev.sh +13 −13
  • deploy/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; }