From 5bc8a27f605eb513bd078e76dc7896d2b13ebae4 Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Fri, 18 Sep 2026 11:55:27 -0400 Subject: [PATCH] secrets: fail loudly on a bad 1Password token instead of a LiteLLM prompt A bad token paste (or a vault/item that did not match the login-name default) used to surface only as mcp-config asking for a LiteLLM key, hiding that every vault key was missing. Now: - the token is verified with `op whoami` and re-prompted on a bad paste - vault and item are validated, auto-picked when the token sees only one, and otherwise prompted with the list it can see - bootstrap skips mcp-config and hermes, with an error naming the cause, when no LITELLM_KEY came from the vault Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +++- bootstrap.sh | 26 +++++++++++-- secrets/install.sh | 91 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0bc7e8d..cb719c2 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,12 @@ Already have a clone? Just run it directly: sources, so opening a shell never calls `op`. One vault per user and one item in it, both named after your login by default; each field's label is the variable name (`GITEA_KEY`, `LITELLM_KEY`, `PROXMOX_TOKEN_SECRET`, …). - The first run prompts for the service-account token (and vault/item), and - keeps them in `~/.config/dotfiles/op.env` (0600, not loaded into shells). + The first run prompts for the service-account token and checks it with + 1Password straight away, re-prompting on a bad paste. It then picks the vault + and item: the only one the token can see, or a prompt that lists them. It keeps + all three in `~/.config/dotfiles/op.env` (0600, not loaded into shells). If + that fails, bootstrap skips mcp-config/ and hermes/ with an error rather + than asking for the LiteLLM key by hand. Runs after `bin/` (needs `op` and `jq`); bootstrap loads the result so later modules get the keys without prompting. Day to day: `opr NAME` reads a field, `opc NAME [VALUE]` creates or updates one (prompts, hidden, without diff --git a/bootstrap.sh b/bootstrap.sh index 098c244..f5bdb50 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -103,10 +103,28 @@ run_module node # be repaired by re-running bootstrap. Losing ~/.zshrc.local (or the hook line # in it) left opencode with no covert-gpt provider and no way to get it back # short of curl-ing this module by hand. -run_module mcp-config -# Hermes Agent (Nous Research) is distributed as an npm package with a Python -# dependency. Runs after node/ ensures npx is available. -run_module hermes +# +# Both it and hermes/ are SKIPPED when the vault did not deliver LITELLM_KEY +# (and no box-local copy exists): mcp-config/ would otherwise fall back to a +# "paste your LiteLLM key" prompt, which reads like a LiteLLM problem and hides +# the real one — a bad 1Password token, vault or item, which also means every +# other vault key is missing. +if [ -z "${LITELLM_KEY:-}" ] && [ ! -s "$HOME/.config/mcp-sync/key" ]; then + echo "" >&2 + echo "bootstrap: ERROR: no LITELLM_KEY from 1Password — skipping mcp-config and hermes." >&2 + case " $FAILED " in + *" secrets "*) echo "bootstrap: the secrets module failed (see its messages above)." >&2 ;; + *) echo "bootstrap: the vault item has no LITELLM_KEY field; add it with: opc LITELLM_KEY" >&2 ;; + esac + echo "bootstrap: fix it with $SCRIPT_DIR/secrets/install.sh, then re-run bootstrap." >&2 + echo "" >&2 + FAILED="$FAILED mcp-config hermes" +else + run_module mcp-config + # Hermes Agent (Nous Research) is distributed as an npm package with a Python + # dependency. Runs after node/ ensures npx is available. + run_module hermes +fi run_module ansible # Runs last: needs rootless Docker already up (started at boot) so it can # delegate cgroups to it. No-ops where that does not apply. diff --git a/secrets/install.sh b/secrets/install.sh index aa0f673..b885bd1 100755 --- a/secrets/install.sh +++ b/secrets/install.sh @@ -48,8 +48,17 @@ for _tool in op jq; do done # --- 1. resolve the service account, vault and item ------------------------- -# Precedence: the environment, then op.env, then a prompt (token) / defaults. +# Precedence: the environment, then op.env, then a prompt / auto-detection. +# +# WHY each piece is checked on its own: the vault holds every key later +# modules need (LITELLM_KEY, GITEA_KEY, ...). A bad token paste used to fall +# through as a generic "could not read item", bootstrap carried on, and the +# first visible symptom was mcp-config/ asking for a LiteLLM key by hand — +# which hid the real problem. Now a rejected token, a wrong vault and a wrong +# item each get their own message, and on a TTY a re-prompt. _env_token="${OP_SERVICE_ACCOUNT_TOKEN:-}" +_token_src="from OP_SERVICE_ACCOUNT_TOKEN" +[ -n "$_env_token" ] || _token_src="stored in $OP_ENV" _env_vault="${OP_VAULT:-}" _env_item="${OP_ITEM:-}" if [ -f "$OP_ENV" ]; then @@ -61,6 +70,10 @@ OP_VAULT="${_env_vault:-${OP_VAULT:-}}" OP_ITEM="${_env_item:-${OP_ITEM:-}}" _user="$(id -un)" +# Probe in a subshell: in POSIX sh a failed redirect on a special builtin +# like `:` exits the whole script. +have_tty() { ( : < /dev/tty ) 2>/dev/null; } + # ask — read one line from the TTY; echoes the default on empty. ask() { printf '%s [%s]: ' "$1" "$2" >&2 @@ -68,31 +81,75 @@ ask() { printf '%s' "${_ans:-$2}" } -if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then - if [ -f "$SECRETS_ENV" ]; then - say "secrets: no service-account token; keeping existing $SECRETS_ENV." - exit 0 +if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ] && [ -f "$SECRETS_ENV" ]; then + say "secrets: no service-account token; keeping existing $SECRETS_ENV." + exit 0 +fi + +# The token: `op whoami` is the cheapest call that fails on a bad token and +# nothing else. Up to three pastes before giving up. +_tries=0 +while :; do + if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then + export OP_SERVICE_ACCOUNT_TOKEN + op whoami >/dev/null 2>&1 && break + if [ "$_tries" -eq 0 ]; then + say "secrets: 1Password REJECTED the service-account token $_token_src (bad paste, revoked or expired?)." + else + say "secrets: 1Password REJECTED that token — most likely a truncated or bad paste." + fi fi - # Probe in a subshell: in POSIX sh a failed redirect on a special builtin - # like `:` exits the whole script, which would skip the message below. - if ! ( : < /dev/tty ) 2>/dev/null; then - say "secrets: no OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt." + if ! have_tty; then + say "secrets: no valid OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt." say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0" exit 1 fi + if [ "$_tries" -ge 3 ]; then + say "secrets: giving up after $_tries attempts. Every key in the vault (LITELLM_KEY," + say " GITEA_KEY, ...) is missing until this works: re-run $0" + exit 1 + fi + _tries=$((_tries + 1)) printf '1Password service-account token (ops_..., input hidden): ' >&2 stty -echo < /dev/tty 2>/dev/null || true IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < /dev/tty || true stty echo < /dev/tty 2>/dev/null || true printf '\n' >&2 OP_SERVICE_ACCOUNT_TOKEN="$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | tr -d ' \t\r\n')" - [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ] || { say "secrets: no token entered."; exit 1; } - [ -n "$OP_VAULT" ] || OP_VAULT="$(ask 'Vault' "$_user")" - [ -n "$OP_ITEM" ] || OP_ITEM="$(ask 'Item' "$_user")" -fi -OP_VAULT="${OP_VAULT:-$_user}" -OP_ITEM="${OP_ITEM:-$_user}" -export OP_SERVICE_ACCOUNT_TOKEN + [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ] || say "secrets: no token entered." +done +say "secrets: service-account token OK." + +# pick +# Resolve a vault or item name: keep if it checks out; otherwise use +# the only one the token can see, or ask (TTY) showing what it can see. +pick() { + _kind="$1" _cur="$2" _def="$3"; shift 3 + _check="$1"; shift + _names="$("$@" --format json 2>/dev/null | jq -r '.[] | .name // .title')" + if [ -n "$_cur" ]; then + $_check "$_cur" && { printf '%s' "$_cur"; return 0; } + say "secrets: $_kind '$_cur' not found. This token can see:" + printf '%s\n' "$_names" | sed 's/^/ - /' >&2 + fi + if [ "$(printf '%s\n' "$_names" | grep -c .)" -eq 1 ]; then + say "secrets: using the only $_kind this token can see: $_names" + printf '%s' "$_names"; return 0 + fi + have_tty || { say "secrets: set OP_$(printf '%s' "$_kind" | tr a-z A-Z) and re-run."; return 1; } + printf '%s\n' "$_names" | grep -Fxq -- "$_def" || _def="$(printf '%s\n' "$_names" | head -n 1)" + while :; do + _cur="$(ask "$_kind" "$_def")" + $_check "$_cur" && { printf '%s' "$_cur"; return 0; } + say "secrets: $_kind '$_cur' not found. This token can see:" + printf '%s\n' "$_names" | sed 's/^/ - /' >&2 + done +} +vault_ok() { op vault get "$1" >/dev/null 2>&1; } +item_ok() { op item get "$1" --vault "$OP_VAULT" >/dev/null 2>&1; } + +OP_VAULT="$(pick vault "$OP_VAULT" "$_user" vault_ok op vault list)" || exit 1 +OP_ITEM="$(pick item "$OP_ITEM" "$_user" item_ok op item list --vault "$OP_VAULT")" || exit 1 # --- 2. fetch the item once and render it ----------------------------------- # One `op item get` instead of one `op read` per field. Labels that are not @@ -132,6 +189,8 @@ fi mv -f "$_tmp" "$SECRETS_ENV" trap - EXIT say "secrets: wrote $_count variables to $SECRETS_ENV (0600)." +grep -q '^export LITELLM_KEY=' "$SECRETS_ENV" \ + || say "secrets: WARNING: op://$OP_VAULT/$OP_ITEM has no LITELLM_KEY field; mcp-config/ and hermes/ need it." # Remember the account only after it has actually worked. {