| 1 | # π€ The one way scripts talk to the Bitwarden CLI. Sourced (not run) by |
| 2 | # release-server.sh, doctor.sh, ops-dev.sh β POSIX sh. |
| 3 | # |
| 4 | # Two rules, learned the hard way: |
| 5 | # |
| 6 | # 1. **bw never prompts β we do.** bw's inquirer prompt writes to stdout, so |
| 7 | # inside `$(...)` it sits invisible and the script "hangs" until a stray |
| 8 | # Enter redraws it (the doctor lesson, then the release-server lesson). |
| 9 | # Instead, OUR prompt (stty -echo + a plain `read -r`, POSIX β dash has |
| 10 | # no `read -s`) reads /dev/tty with the label on stderr β |
| 11 | # always visible, capture-proof β and the password reaches bw through |
| 12 | # --passwordenv: never argv (ps would show it), never disk, unset the |
| 13 | # moment the unlock returns. bw is a non-interactive backend, full stop. |
| 14 | # (The one exception is `bw login` β 2FA is genuinely interactive and |
| 15 | # rare; scripts detect "unauthenticated" and hand the human that ONE |
| 16 | # command to run themselves.) |
| 17 | # |
| 18 | # 2. **Every bw call gets a watchdog.** bw's API calls carry no timeout and |
| 19 | # can stall for good on a bad connection or stale session (a promote sat |
| 20 | # 14 silent minutes in `bw get` 2026-07-21; another hung indefinitely in |
| 21 | # `bw unlock` 2026-07-24). perl's alarm is the never-a-hang promise β |
| 22 | # the timer survives exec, and macOS ships perl. |
| 23 | |
| 24 | # Rule 1, enforced globally: with BW_NOINTERACTION set, a locked-vault |
| 25 | # `bw get`/`bw list` fails immediately instead of prompting β without it, bw's |
| 26 | # invisible in-pipe prompt eats a stray Enter before falling through to OUR |
| 27 | # unlock prompt (the "I still had to press Enter" papercut, 2026-07-25). |
| 28 | # Exported at source time so every bw call in the sourcing script is covered. |
| 29 | BW_NOINTERACTION=true |
| 30 | export BW_NOINTERACTION |
| 31 | |
| 32 | # bw_run <seconds> <bw-args...> β run one bw command under a watchdog. |
| 33 | # Exit status is bw's, or SIGALRM's (142) on a stall. stdin is pinned to |
| 34 | # /dev/null β none of our calls (get/list/sync/status) legitimately read it, |
| 35 | # so nothing bw does can ever wait on the operator's keyboard. |
| 36 | bw_run() { |
| 37 | _bw_t="$1"; shift |
| 38 | perl -e "alarm $_bw_t; exec @ARGV" bw "$@" </dev/null |
| 39 | } |
| 40 | |
| 41 | # bw_unlock_session β unlock the vault with OUR prompt and print the session |
| 42 | # token on stdout (nothing else touches stdout). Callers: |
| 43 | # |
| 44 | # BW_SESSION="$(bw_unlock_session)" && export BW_SESSION || <degrade> |
| 45 | # |
| 46 | # Fails (rather than blocking) without a tty: headless contexts export |
| 47 | # BW_SESSION themselves or use the explicit env-key overrides. |
| 48 | bw_unlock_session() { |
| 49 | if [ ! -e /dev/tty ]; then |
| 50 | echo "bw: no tty to prompt on β export BW_SESSION (bw unlock --raw) first" >&2 |
| 51 | return 1 |
| 52 | fi |
| 53 | printf "Bitwarden master password (typing is hidden): " >&2 |
| 54 | # `read -s` is a bashism β Debian's /bin/sh (dash) rejects it with |
| 55 | # "Illegal option -s" (bit an operator mid-publish, 2026-08-06). Hide the |
| 56 | # typing the POSIX way: drop tty echo around a plain `read -r`. Restore on |
| 57 | # every exit from the read, including ^C at the prompt (restore, then |
| 58 | # re-raise INT so the script still dies) β a password prompt that leaves |
| 59 | # the terminal echo-less is its own papercut. The `|| true` tails keep |
| 60 | # `set -e` sourcing scripts alive when there's no stty to save (odd ttys). |
| 61 | _bw_stty="$(stty -g </dev/tty 2>/dev/null || true)" |
| 62 | _bw_restore_tty() { [ -n "$_bw_stty" ] && stty "$_bw_stty" </dev/tty 2>/dev/null || true; } |
| 63 | trap '_bw_restore_tty; trap - INT; kill -INT $$' INT |
| 64 | [ -n "$_bw_stty" ] && stty -echo </dev/tty 2>/dev/null || true |
| 65 | IFS= read -r LCHAT_BW_PASSWORD </dev/tty || { |
| 66 | _bw_restore_tty |
| 67 | trap - INT |
| 68 | echo >&2 |
| 69 | unset LCHAT_BW_PASSWORD |
| 70 | return 1 |
| 71 | } |
| 72 | _bw_restore_tty |
| 73 | trap - INT |
| 74 | echo >&2 |
| 75 | # 90s: the KDF is seconds, but bw's first server round-trip gets headroom. |
| 76 | _bw_sess="$(LCHAT_BW_PASSWORD="$LCHAT_BW_PASSWORD" perl -e 'alarm 90; exec @ARGV' \ |
| 77 | bw unlock --raw --passwordenv LCHAT_BW_PASSWORD </dev/null)" || _bw_sess="" |
| 78 | unset LCHAT_BW_PASSWORD |
| 79 | if [ -z "$_bw_sess" ]; then |
| 80 | echo "bw: unlock failed or stalled β run 'bw sync' and retry, or export BW_SESSION yourself" >&2 |
| 81 | return 1 |
| 82 | fi |
| 83 | printf '%s\n' "$_bw_sess" |
| 84 | } |
| 85 | |