#!/bin/sh # clawdie-vault-fetch — pull agent secrets from Vaultwarden into the host. # # Language-neutral bridge between the Clawdie Vaultwarden instance and a host's # secret store. The TS post-install setup flow shells out to this; the live USB # and firstboot can call it directly. It depends only on the `bw` (Bitwarden) # CLI — no node module, no jq — so the same helper serves the deployed disk and # the live operator image (see docs in clawdie-ai/docs/VAULTWARDEN-SETUP.md). # # Contract: each secret is one login item in the `agent-secrets` collection # whose ITEM NAME is exactly the env var name (e.g. ANTHROPIC_API_KEY) with the # value in the password field. `bw get password ` then returns it raw. # # Bootstrap (the one secret that can't live in the vault) is read from a 0600 # env file holding BW_CLIENTID / BW_CLIENTSECRET / BW_PASSWORD. Absent file => # nothing to do (exit 3), so the manual setup wizard remains the floor. # # Usage: # clawdie-vault-fetch # print KEY=VALUE lines to stdout # clawdie-vault-fetch --write-env FILE # upsert results into FILE (0600) # clawdie-vault-fetch --bootstrap FILE # explicit bootstrap env file # clawdie-vault-fetch --keys "A B C" # override the key name list # # Exit codes (so callers can distinguish "skip" from "broken"): # 0 ran cleanly (zero or more keys printed/written) # 1 vault was configured but login/unlock/fetch failed # 3 no bootstrap config found — caller should fall back to manual entry # 4 `bw` CLI not installed set -eu SERVER="${VAULT_SERVER:-${BW_SERVER:-https://vault.smilepowered.org}}" # NOTE: items are fetched by name with `bw get password`, which is fail-closed # on ambiguity (multiple matches error out). Item names must therefore be unique # in the agent account's visible vault — see docs/VAULTWARDEN-SETUP.md. We do not # scope by collection here to avoid a JSON-parse (jq) dependency. BOOTSTRAP_FILE="${VAULT_BOOTSTRAP_FILE:-${HOME}/.config/vault-bootstrap.env}" WRITE_ENV="" # Provider key names mirror clawdie-ai's PROVIDER_KEY_BY_PROVIDER (the non-null # ones). Extend via --keys or VAULT_FETCH_KEYS without editing this file. KEYS="${VAULT_FETCH_KEYS:-ANTHROPIC_API_KEY OPENAI_API_KEY OPENROUTER_API_KEY ZAI_API_KEY DEEPSEEK_API_KEY GEMINI_API_KEY GROQ_API_KEY}" usage() { echo "usage: clawdie-vault-fetch [--write-env FILE] [--bootstrap FILE] [--keys \"K1 K2\"]" exit "${1:-0}" } while [ $# -gt 0 ]; do case "$1" in --write-env) WRITE_ENV="${2:?--write-env needs a value}"; shift 2 ;; --bootstrap) BOOTSTRAP_FILE="${2:?--bootstrap needs a value}"; shift 2 ;; --keys) KEYS="${2:?--keys needs a value}"; shift 2 ;; -h|--help) usage 0 ;; *) echo "clawdie-vault-fetch: unknown argument: $1" >&2; usage 1 ;; esac done log() { echo "clawdie-vault-fetch: $*" >&2; } # No vault intent on this host => skip quietly so the manual path is unaffected. # Checked before the bw probe: a host with no bootstrap drop shouldn't care # whether the CLI is installed. if [ ! -f "$BOOTSTRAP_FILE" ]; then log "no bootstrap file at $BOOTSTRAP_FILE — skipping vault fetch" exit 3 fi # The bootstrap file holds the vault master password. Refuse to read it if it is # group/world-readable, unless explicitly overridden. stat differs across BSD # (-f '%Lp') and GNU (-c '%a'); a non-octal/unknown result skips the check. _mode="$(stat -f '%Lp' "$BOOTSTRAP_FILE" 2>/dev/null || stat -c '%a' "$BOOTSTRAP_FILE" 2>/dev/null || echo '')" case "$_mode" in ''|*[!0-7]*) _mode='' ;; esac if [ -n "$_mode" ] && [ "$(( 0$_mode & 077 ))" -ne 0 ]; then if [ -n "${VAULT_ALLOW_INSECURE_BOOTSTRAP:-}" ]; then log "WARNING: $BOOTSTRAP_FILE is mode $_mode (group/world-readable) — proceeding (override set)" else log "refusing: $BOOTSTRAP_FILE is mode $_mode (group/world-readable). chmod 600 it, or set VAULT_ALLOW_INSECURE_BOOTSTRAP=1" exit 1 fi fi if ! command -v bw >/dev/null 2>&1; then log "bw (Bitwarden CLI) not found — install @bitwarden/cli first" exit 4 fi # Load bootstrap creds without echoing them. set -a so they reach bw via env. set -a # shellcheck disable=SC1090 . "$BOOTSTRAP_FILE" set +a if [ -z "${BW_CLIENTID:-}" ] || [ -z "${BW_CLIENTSECRET:-}" ] || [ -z "${BW_PASSWORD:-}" ]; then log "bootstrap file is missing BW_CLIENTID / BW_CLIENTSECRET / BW_PASSWORD" exit 1 fi RUNTIME_BASE="${XDG_RUNTIME_DIR:-${HOME}/.cache/clawdie/runtime}" mkdir -p "$RUNTIME_BASE" chmod 700 "$RUNTIME_BASE" 2>/dev/null || true WORK="$(mktemp -d "${RUNTIME_BASE}/clawdie-vault.XXXXXX")" # Lock the vault on any exit; never leave an unlocked session behind. cleanup() { bw lock >/dev/null 2>&1 || true rm -rf "$WORK" } trap cleanup EXIT INT TERM # Set the server. When already logged in, `bw config` refuses with # "Logout required before server config update". Tolerate that only when the # current bw server already matches the expected Clawdie endpoint; otherwise # fail closed so a stale login cannot fetch from the wrong Bitwarden host. if ! bw config server "$SERVER" >"$WORK/config.out" 2>"$WORK/config.err"; then if grep -qi 'logout required\|already configured\|already set' "$WORK/config.err" "$WORK/config.out" 2>/dev/null; then CURRENT_SERVER="$(bw config server 2>/dev/null || true)" if [ "$CURRENT_SERVER" != "$SERVER" ]; then log "bw is already logged in with server '$CURRENT_SERVER' (expected '$SERVER'); logout and rerun" exit 1 fi else log "could not set bw server to $SERVER:" sed 's/^/ /' "$WORK/config.err" >&2 exit 1 fi fi # login --apikey reads BW_CLIENTID/BW_CLIENTSECRET from env. Tolerate the # "already logged in" case so repeat runs don't fail. if ! bw login --apikey >/dev/null 2>"$WORK/login.err"; then if ! grep -qi 'already logged in' "$WORK/login.err"; then log "bw login failed:" sed 's/^/ /' "$WORK/login.err" >&2 exit 1 fi fi SESSION="$(bw unlock --raw --passwordenv BW_PASSWORD 2>"$WORK/unlock.err")" || { log "bw unlock failed:" sed 's/^/ /' "$WORK/unlock.err" >&2 exit 1 } if [ -z "$SESSION" ]; then log "bw unlock returned an empty session" exit 1 fi # Make sure local cache reflects the server (web-UI edits don't sync otherwise). bw sync --session "$SESSION" >/dev/null 2>&1 || true # Collect KEY=VALUE pairs for every key name that resolves to a value. A missing # item is not an error — the operator may only have stored a subset. found=0 out="$WORK/out.env" : >"$out" for key in $KEYS; do val="$(bw get password "$key" --session "$SESSION" 2>/dev/null)" || continue [ -n "$val" ] || continue printf '%s=%s\n' "$key" "$val" >>"$out" found=$((found + 1)) done log "resolved $found of $(echo "$KEYS" | wc -w | tr -d ' ') key(s) from agent-secrets" if [ -n "$WRITE_ENV" ]; then # Upsert into the target env file at 0600 without disturbing other keys. touch "$WRITE_ENV" chmod 0600 "$WRITE_ENV" merged="$WORK/merged.env" cp "$WRITE_ENV" "$merged" 2>/dev/null || : >"$merged" while IFS= read -r line; do k="${line%%=*}" # Drop any existing definition of this key, then append the new one. grep -v "^${k}=" "$merged" >"$merged.tmp" 2>/dev/null || : >"$merged.tmp" mv "$merged.tmp" "$merged" printf '%s\n' "$line" >>"$merged" done <"$out" cp "$merged" "$WRITE_ENV" chmod 0600 "$WRITE_ENV" log "wrote $found key(s) into $WRITE_ENV" else cat "$out" fi exit 0