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) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 11:55:27 -04:00
co-authored by Claude Opus 5
parent 8ec3269f4a
commit 5bc8a27f60
3 changed files with 103 additions and 22 deletions
+6 -2
View File
@@ -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 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 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 variable name (`GITEA_KEY`, `LITELLM_KEY`, `PROXMOX_TOKEN_SECRET`, …).
The first run prompts for the service-account token (and vault/item), and The first run prompts for the service-account token and checks it with
keeps them in `~/.config/dotfiles/op.env` (0600, not loaded into shells). 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 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 modules get the keys without prompting. Day to day: `opr NAME` reads a
field, `opc NAME [VALUE]` creates or updates one (prompts, hidden, without field, `opc NAME [VALUE]` creates or updates one (prompts, hidden, without
+22 -4
View File
@@ -103,10 +103,28 @@ run_module node
# be repaired by re-running bootstrap. Losing ~/.zshrc.local (or the hook line # 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 # in it) left opencode with no covert-gpt provider and no way to get it back
# short of curl-ing this module by hand. # 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 # Both it and hermes/ are SKIPPED when the vault did not deliver LITELLM_KEY
# dependency. Runs after node/ ensures npx is available. # (and no box-local copy exists): mcp-config/ would otherwise fall back to a
run_module hermes # "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 run_module ansible
# Runs last: needs rootless Docker already up (started at boot) so it can # Runs last: needs rootless Docker already up (started at boot) so it can
# delegate cgroups to it. No-ops where that does not apply. # delegate cgroups to it. No-ops where that does not apply.
+75 -16
View File
@@ -48,8 +48,17 @@ for _tool in op jq; do
done done
# --- 1. resolve the service account, vault and item ------------------------- # --- 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:-}" _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_vault="${OP_VAULT:-}"
_env_item="${OP_ITEM:-}" _env_item="${OP_ITEM:-}"
if [ -f "$OP_ENV" ]; then if [ -f "$OP_ENV" ]; then
@@ -61,6 +70,10 @@ OP_VAULT="${_env_vault:-${OP_VAULT:-}}"
OP_ITEM="${_env_item:-${OP_ITEM:-}}" OP_ITEM="${_env_item:-${OP_ITEM:-}}"
_user="$(id -un)" _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 <prompt> <default> — read one line from the TTY; echoes the default on empty. # ask <prompt> <default> — read one line from the TTY; echoes the default on empty.
ask() { ask() {
printf '%s [%s]: ' "$1" "$2" >&2 printf '%s [%s]: ' "$1" "$2" >&2
@@ -68,31 +81,75 @@ ask() {
printf '%s' "${_ans:-$2}" printf '%s' "${_ans:-$2}"
} }
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ] && [ -f "$SECRETS_ENV" ]; then
if [ -f "$SECRETS_ENV" ]; then say "secrets: no service-account token; keeping existing $SECRETS_ENV."
say "secrets: no service-account token; keeping existing $SECRETS_ENV." exit 0
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 fi
# Probe in a subshell: in POSIX sh a failed redirect on a special builtin if ! have_tty; then
# like `:` exits the whole script, which would skip the message below. say "secrets: no valid OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt."
if ! ( : < /dev/tty ) 2>/dev/null; then
say "secrets: no OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt."
say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0" say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0"
exit 1 exit 1
fi 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 printf '1Password service-account token (ops_..., input hidden): ' >&2
stty -echo < /dev/tty 2>/dev/null || true stty -echo < /dev/tty 2>/dev/null || true
IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < /dev/tty || true IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < /dev/tty || true
stty echo < /dev/tty 2>/dev/null || true stty echo < /dev/tty 2>/dev/null || true
printf '\n' >&2 printf '\n' >&2
OP_SERVICE_ACCOUNT_TOKEN="$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | tr -d ' \t\r\n')" 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_SERVICE_ACCOUNT_TOKEN" ] || say "secrets: no token entered."
[ -n "$OP_VAULT" ] || OP_VAULT="$(ask 'Vault' "$_user")" done
[ -n "$OP_ITEM" ] || OP_ITEM="$(ask 'Item' "$_user")" say "secrets: service-account token OK."
fi
OP_VAULT="${OP_VAULT:-$_user}" # pick <kind> <current> <default> <check-fn> <list-cmd...>
OP_ITEM="${OP_ITEM:-$_user}" # Resolve a vault or item name: keep <current> if it checks out; otherwise use
export OP_SERVICE_ACCOUNT_TOKEN # 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 ----------------------------------- # --- 2. fetch the item once and render it -----------------------------------
# One `op item get` instead of one `op read` per field. Labels that are not # 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" mv -f "$_tmp" "$SECRETS_ENV"
trap - EXIT trap - EXIT
say "secrets: wrote $_count variables to $SECRETS_ENV (0600)." 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. # Remember the account only after it has actually worked.
{ {