Wire encrypted secrets: Vaultwarden fetch + per-agent seed import

Two parallel, additive paths so a host gets its secrets out of the box;
the manual setup wizard stays the floor (no config = no-op).

clawdie-vault-fetch (new): language-neutral bw bridge. Reads a 0600
~/.config/vault-bootstrap.env, pulls keys from the agent-secrets
collection (item name = env var name, value in password field, so no jq),
prints KEY=VALUE or --write-env upserts 0600. Exit codes distinguish
skip (3, no bootstrap) / broken (1) / no bw (4). Pinned
@bitwarden/cli@2026.5.0 for offline bundling; staged in
configure_live_operator_session.

clawdie-live-seed: extend the CLAWDIESEED FAT32 importer from the
authorized_keys allowlist to a per-agent directory convention —
/<agent>/ with env (merged 0600), harness.toml (pi|zot|local), soul/
(staged), ssh/authorized_keys. Live USB single-agent (first dir = active);
extra dirs staged + flagged for deployed multi-agent. Optional
consume-and-shred. Import core is unit-testable via CLAWDIE_SEED_TEST.

README rewritten to document the per-agent contract and the operator
decision to allow plaintext secrets on the seed (seeded sticks are
secret-bearing media; 0600 landing + shred mitigations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam & Claude 2026-06-16 08:46:01 +02:00
parent c905e7a31c
commit 1af0e62942
5 changed files with 468 additions and 62 deletions

View file

@ -1221,6 +1221,11 @@ configure_live_operator_session() {
"${MOUNT_POINT}/usr/local/bin/clawdie-noblank-guard.sh"
install -m 0755 "${LIVE_SESSION_DIR}/hw-report" \
"${MOUNT_POINT}/usr/local/bin/hw-report"
# Vaultwarden secret bridge (bw -> .env). Always staged: needs only the
# bundled `bw` CLI and a 0600 bootstrap drop; absent bootstrap = no-op, so
# the manual setup wizard stays the floor. See docs/VAULTWARDEN-SETUP.md.
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-vault-fetch" \
"${MOUNT_POINT}/usr/local/bin/clawdie-vault-fetch"
# The stock FreeBSD memstick starts bsdinstall from /etc/rc.local before
# our graphical live session can own the USB workflow. Preserve a copy for

View file

@ -1,39 +1,56 @@
#!/bin/sh
# Clawdie operator USB live seed importer.
# Reads operator-provided files from the FAT32 CLAWDIESEED partition
# (mountable on Linux pre-flash) and installs them into the live system
# at every boot. Idempotent by design: editing the seed and rebooting
# re-applies it. Failure is never fatal — missing/empty/unmountable seed
# logs and continues so the operator can still log in via SDDM.
# (mountable on Linux/macOS/Windows pre- or post-flash) and installs them into
# the live system at every boot. Idempotent by design: editing the seed and
# rebooting re-applies it. Failure is never fatal — a missing/empty/unmountable
# seed logs and continues so the operator can still log in via SDDM.
#
# Runs before LOGIN so sshd sees the imported authorized_keys on first
# attach. See live/operator-session/clawdie-live-seed.README.txt for the
# allowlisted file contract written to the FAT at build time.
# Two layers of contract (see clawdie-live-seed.README.txt for the operator view):
# 1. Legacy top-level allowlist: /authorized_keys, /ssh/authorized_keys.
# 2. Per-agent directories: /<agent-name>/ holding env, soul/, harness.toml,
# ssh/authorized_keys. On the live USB (single agent) the first agent dir
# maps to the clawdie user; on a deployed host the importer loops every dir
# (multi-agent provisioning is a documented follow-up seam).
#
# SECURITY: this partition is plaintext FAT32. By operator decision the env
# files here may carry secrets (provider API keys, vault-bootstrap creds).
# Treat seeded sticks as secret-bearing media. The importer lands secrets 0600
# owned by the agent user and supports optional consume-and-shred (a /shred
# marker file on the seed) to wipe env files after import.
#
# Runs before LOGIN so sshd sees imported authorized_keys on first attach.
# PROVIDE: clawdie_live_seed
# REQUIRE: FILESYSTEMS devfs
# BEFORE: LOGIN
# KEYWORD: nojail
. /etc/rc.subr
# rc.subr only exists on the FreeBSD target. Guard it so the import functions
# below can be sourced and unit-tested on a non-FreeBSD host (CLAWDIE_SEED_TEST=1).
if [ -r /etc/rc.subr ]; then
. /etc/rc.subr
fi
name="clawdie_live_seed"
rcvar="${name}_enable"
start_cmd="${name}_start"
stop_cmd=":"
# This service is one-shot/idempotent, not a daemon — the default
# rc.subr status check looks for a pidfile and would say "not running"
# even after a successful import, which is actively misleading. Override
# with a log-tail report so `service clawdie_live_seed status` answers
# the question the operator is actually asking ("did the import run?").
status_cmd="${name}_status"
extra_commands="status"
SEED_LABEL="CLAWDIESEED"
SEED_MOUNT="/mnt/clawdie-seed"
SEED_LOG="/var/log/clawdie-live-seed.log"
SEED_USER="clawdie"
SEED_USER_HOME="/home/clawdie"
SEED_MOUNT="${SEED_MOUNT:-/mnt/clawdie-seed}"
SEED_LOG="${SEED_LOG:-/var/log/clawdie-live-seed.log}"
SEED_USER="${SEED_USER:-clawdie}"
SEED_USER_HOME="${SEED_USER_HOME:-/home/clawdie}"
# Where imported agent payloads are staged. Runtime consumption (loading a soul
# into the agent workspace cwd, launching the chosen harness) reads from here.
SEED_IMPORT_ROOT="${SEED_IMPORT_ROOT:-/var/db/clawdie/seed}"
# Directory names reserved at the seed root (not treated as agent dirs).
SEED_RESERVED_DIRS="ssh"
# Valid harness values mirror Colibri's AgentRuntime enum (colibri-glasspane).
SEED_VALID_HARNESSES="pi zot local"
_seed_log() {
printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$1" >>"${SEED_LOG}" 2>/dev/null || true
@ -66,26 +83,189 @@ _seed_find_partition() {
_seed_install_authorized_keys() {
_src="$1"
_user="${2:-${SEED_USER}}"
_home="${3:-${SEED_USER_HOME}}"
_ssh_dir="${SEED_USER_HOME}/.ssh"
_ssh_dir="${_home}/.ssh"
_dst="${_ssh_dir}/authorized_keys"
mkdir -p "${_ssh_dir}"
chown "${SEED_USER}:${SEED_USER}" "${_ssh_dir}" 2>/dev/null || true
chown "${_user}:${_user}" "${_ssh_dir}" 2>/dev/null || true
chmod 0700 "${_ssh_dir}"
# Strip CRLF so keys created on Windows/Linux editors don't get rejected
# by sshd for trailing whitespace.
tr -d '\r' <"${_src}" >"${_dst}.new"
mv -f "${_dst}.new" "${_dst}"
chown "${SEED_USER}:${SEED_USER}" "${_dst}" 2>/dev/null || true
chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true
chmod 0600 "${_dst}"
_seed_log "installed authorized_keys from ${_src} -> ${_dst}"
}
# Merge KEY=VALUE pairs from a plaintext env file into a target .env, preserving
# keys the source does not mention and replacing those it does. Lands 0600 owned
# by the agent user. Mirrors clawdie-vault-fetch's --write-env upsert.
_seed_import_env() {
_src="$1"
_dst="$2"
_user="${3:-${SEED_USER}}"
[ -f "${_src}" ] || return 0
mkdir -p "$(dirname "${_dst}")"
touch "${_dst}"
chmod 0600 "${_dst}"
_merged="${_dst}.seedmerge"
cp "${_dst}" "${_merged}" 2>/dev/null || : >"${_merged}"
_count=0
while IFS= read -r _line || [ -n "${_line}" ]; do
# Skip blanks, comments, and lines without a KEY=.
case "${_line}" in
''|'#'*) continue ;;
*=*) : ;;
*) continue ;;
esac
_line="$(printf '%s' "${_line}" | tr -d '\r')"
_k="${_line%%=*}"
grep -v "^${_k}=" "${_merged}" >"${_merged}.tmp" 2>/dev/null || : >"${_merged}.tmp"
mv "${_merged}.tmp" "${_merged}"
printf '%s\n' "${_line}" >>"${_merged}"
_count=$((_count + 1))
done <"${_src}"
cp "${_merged}" "${_dst}"
chmod 0600 "${_dst}"
chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true
rm -f "${_merged}"
_seed_log "imported ${_count} env key(s) from ${_src} -> ${_dst}"
}
# Validate + record a harness descriptor. We only parse the `harness` value to
# sanity-check it against the AgentRuntime enum; the full file is staged as-is
# for the runtime to read.
_seed_import_harness() {
_src="$1"
_stage="$2"
[ -f "${_src}" ] || return 0
_h="$(grep -E '^[[:space:]]*harness[[:space:]]*=' "${_src}" 2>/dev/null \
| head -n 1 | sed -E 's/^[^=]*=[[:space:]]*"?([A-Za-z]+)"?.*/\1/' | tr 'A-Z' 'a-z')"
_ok=0
for _v in ${SEED_VALID_HARNESSES}; do
[ "${_h}" = "${_v}" ] && _ok=1
done
if [ "${_ok}" -ne 1 ]; then
_seed_log "WARN harness '${_h:-<none>}' in ${_src} not in {${SEED_VALID_HARNESSES}} — recording anyway"
fi
mkdir -p "${_stage}"
cp "${_src}" "${_stage}/harness.toml" 2>/dev/null || true
_seed_log "recorded harness '${_h:-<none>}' -> ${_stage}/harness.toml"
}
# Stage a soul/ backup tree for later consumption by the agent workspace.
_seed_import_soul() {
_src="$1"
_stage="$2"
[ -d "${_src}" ] || return 0
mkdir -p "${_stage}/soul"
# cp -R is portable; the tree is small (layered-soul ~600 KB).
cp -R "${_src}/." "${_stage}/soul/" 2>/dev/null || true
_seed_log "staged soul backup ${_src} -> ${_stage}/soul"
}
# Import one /<agent-name>/ directory. On the live USB this is called once with
# the clawdie user/home as the target; on a deployed host the caller loops.
_seed_import_agent_dir() {
_dir="$1" # absolute path to the agent dir on the mounted seed
_agent="$2" # agent name (already validated)
_user="$3"
_home="$4"
_stage="${SEED_IMPORT_ROOT}/${_agent}"
mkdir -p "${_stage}"
printf '%s\n' "${_agent}" >"${_stage}/agent-name" 2>/dev/null || true
_seed_import_env "${_dir}/env" "${_home}/.env" "${_user}"
_seed_import_harness "${_dir}/harness.toml" "${_stage}"
_seed_import_soul "${_dir}/soul" "${_stage}"
if [ -f "${_dir}/ssh/authorized_keys" ]; then
_seed_install_authorized_keys "${_dir}/ssh/authorized_keys" "${_user}" "${_home}"
fi
_seed_log "imported agent dir '${_agent}' -> stage ${_stage}, user ${_user}"
}
# Return 0 if NAME is a safe agent directory name (no traversal, not reserved).
_seed_agent_name_ok() {
_n="$1"
case "${_n}" in
''|.|..|*/*) return 1 ;;
esac
# Allowlist characters to keep this off the filesystem's sharp edges.
case "${_n}" in
*[!A-Za-z0-9._-]*) return 1 ;;
esac
for _r in ${SEED_RESERVED_DIRS}; do
[ "${_n}" = "${_r}" ] && return 1
done
return 0
}
# Core import routine, factored out of the rc start_cmd so it can be tested
# against a pre-mounted directory (CLAWDIE_SEED_TEST). Operates on $SEED_MOUNT.
_seed_import_tree() {
_imported=0
# Layer 1: legacy top-level authorized_keys (default clawdie user).
if [ -f "${SEED_MOUNT}/ssh/authorized_keys" ]; then
_seed_install_authorized_keys "${SEED_MOUNT}/ssh/authorized_keys"
_imported=1
elif [ -f "${SEED_MOUNT}/authorized_keys" ]; then
_seed_install_authorized_keys "${SEED_MOUNT}/authorized_keys"
_imported=1
fi
# Layer 2: per-agent directories. On the live USB (single agent) the first
# valid dir, sorted, maps to the clawdie user. Additional dirs are staged
# but flagged: deployed multi-agent provisioning is the follow-up seam.
_first=1
for _entry in "${SEED_MOUNT}"/*; do
[ -d "${_entry}" ] || continue
_agent="$(basename "${_entry}")"
if ! _seed_agent_name_ok "${_agent}"; then
[ "${_agent}" = "ssh" ] || _seed_log "skipping non-agent dir '${_agent}'"
continue
fi
if [ "${_first}" -eq 1 ]; then
_seed_import_agent_dir "${_entry}" "${_agent}" "${SEED_USER}" "${SEED_USER_HOME}"
printf '%s\n' "${_agent}" >"${SEED_IMPORT_ROOT}/active-agent" 2>/dev/null || true
_first=0
_imported=1
else
# Stage payload for visibility but do not provision a second live
# identity — the live USB is single-agent.
_seed_import_agent_dir "${_entry}" "${_agent}" "${SEED_USER}" "${SEED_USER_HOME}.${_agent}"
_seed_log "NOTE additional agent dir '${_agent}' staged; deployed multi-agent provisioning not yet wired"
fi
done
if [ "${_imported}" -eq 0 ]; then
_seed_log "no allowlisted files or agent dirs on seed — nothing to import"
fi
return 0
}
clawdie_live_seed_start() {
: >>"${SEED_LOG}" 2>/dev/null || true
mkdir -p "${SEED_IMPORT_ROOT}" 2>/dev/null || true
_dev=$(_seed_find_partition)
if [ -z "${_dev:-}" ]; then
@ -99,19 +279,20 @@ clawdie_live_seed_start() {
return 0
fi
# Allowlist: /ssh/authorized_keys takes precedence over /authorized_keys.
# Any other file on the partition is ignored on purpose.
_imported=0
if [ -f "${SEED_MOUNT}/ssh/authorized_keys" ]; then
_seed_install_authorized_keys "${SEED_MOUNT}/ssh/authorized_keys"
_imported=1
elif [ -f "${SEED_MOUNT}/authorized_keys" ]; then
_seed_install_authorized_keys "${SEED_MOUNT}/authorized_keys"
_imported=1
fi
_seed_import_tree
if [ "${_imported}" -eq 0 ]; then
_seed_log "no allowlisted files on ${_dev} — nothing to import"
# Optional consume-and-shred: a /shred marker on the seed asks us to wipe
# env files after import so secrets do not persist on the stick. Requires a
# brief RW remount; off unless the operator opts in per stick.
if [ -f "${SEED_MOUNT}/shred" ]; then
umount "${SEED_MOUNT}" 2>/dev/null || true
if mount -t msdosfs "${_dev}" "${SEED_MOUNT}" 2>>"${SEED_LOG}"; then
find "${SEED_MOUNT}" -name env -type f -exec rm -f {} + 2>/dev/null || true
rm -f "${SEED_MOUNT}/shred" 2>/dev/null || true
_seed_log "consume-and-shred: wiped env files from seed"
else
_seed_log "consume-and-shred requested but RW remount failed — env left on seed"
fi
fi
umount "${SEED_MOUNT}" 2>/dev/null || true
@ -128,6 +309,12 @@ clawdie_live_seed_status() {
return 1
}
load_rc_config "$name"
: "${clawdie_live_seed_enable:=YES}"
run_rc_command "$1"
# On FreeBSD, hand off to rc.subr. Under test (no rc.subr / CLAWDIE_SEED_TEST),
# skip it so the functions above can be exercised directly.
if [ -n "${CLAWDIE_SEED_TEST:-}" ]; then
:
elif command -v run_rc_command >/dev/null 2>&1; then
load_rc_config "$name"
: "${clawdie_live_seed_enable:=YES}"
run_rc_command "$1"
fi

View file

@ -1,28 +1,29 @@
CLAWDIE LIVE USB — SEED PARTITION
=================================
This FAT32 partition lets you customize the live USB BEFORE flashing.
On every boot, /usr/local/etc/rc.d/clawdie_live_seed imports a small,
allowlisted set of files from this partition. Editing a file and
This FAT32 partition lets you customize the live USB BEFORE flashing or
between boots. On every boot, /usr/local/etc/rc.d/clawdie_live_seed imports
an allowlisted set of files from this partition. Editing a file and
rebooting re-applies it — the importer is idempotent.
USAGE FROM LINUX
----------------
USAGE FROM LINUX / macOS / WINDOWS
----------------------------------
1. Flash the image to USB with dd (or write the .img directly).
1. Flash the image to USB (dd, or write the .img directly).
2. Mount the CLAWDIESEED partition (typically the third partition on the
stick, e.g. /dev/sdX3):
stick, e.g. /dev/sdX3 on Linux):
sudo mount -t vfat /dev/sdX3 /mnt/clawdie-seed
3. Drop seed files (see ALLOWLIST below).
3. Drop seed files (see the two layers below).
4. Unmount and boot the USB:
sync
sudo umount /mnt/clawdie-seed
ALLOWLIST — files honored by the importer
-----------------------------------------
LAYER 1 — SIMPLE ALLOWLIST (top level)
--------------------------------------
/authorized_keys Public SSH keys for the operator account.
Installed to ~clawdie/.ssh/authorized_keys
@ -32,31 +33,87 @@ ALLOWLIST — files honored by the importer
/ssh/authorized_keys Same as above, in a nested ssh/ namespace.
Takes precedence over /authorized_keys.
Anything else on this partition is IGNORED on purpose. The importer logs
to /var/log/clawdie-live-seed.log on the live system.
PLANNED (not yet active)
------------------------
LAYER 2 — PER-AGENT DIRECTORIES
-------------------------------
These paths are reserved for future work; do not rely on them yet:
Create one directory per agent. THE DIRECTORY NAME IS THE AGENT NAME.
Inside it, any of these are honored:
/hostname Override the live hostname (default: clawdie-live).
/tailscale-authkey One-shot Tailscale auth key for headless bring-up.
/wifi.env WiFi SSID + PSK for first-boot wpa_supplicant.
/<agent>/env Plaintext KEY=VALUE lines. Merged into the
agent's .env (mode 0600). Keys you list
replace existing values; keys you omit are
preserved. Blank/`#` lines are ignored.
Typical contents: provider API keys
(ANTHROPIC_API_KEY=..., ZAI_API_KEY=...),
and optionally the Vaultwarden bootstrap
(BW_CLIENTID/BW_CLIENTSECRET/BW_PASSWORD).
SECURITY NOTES
--------------
/<agent>/harness.toml Which agent harness to run + basic knobs:
- This is FAT32 — any user with physical access can read and write it.
- Public SSH keys are not secret; this is the right place for them.
- Do NOT put private keys, long-lived API tokens, or passwords here.
A future encrypted-seed format is the right home for that.
harness = "zot" # zot | pi | local
model = "claude-opus-4-8"
cost_mode = "smart"
`harness` must be one of zot, pi, local
(Colibri's AgentRuntime). Recorded for the
runtime to launch the right harness.
/<agent>/soul/ A layered-soul backup tree (SOUL.md, USER.md,
IDENTITY.md, memories/, skills/, ...). Staged
under /var/db/clawdie/seed/<agent>/soul for
the agent workspace to load.
/<agent>/ssh/authorized_keys Public SSH keys for this agent.
Agent directory names may contain only A-Z a-z 0-9 . _ - (no spaces or
slashes). The name `ssh` is reserved for Layer 1.
LIVE USB vs DEPLOYED
--------------------
The live USB is single-agent: the FIRST agent directory (alphabetical) maps
to the clawdie user and becomes the active agent (recorded at
/var/db/clawdie/seed/active-agent). Additional agent directories are staged
and logged, but a second live identity is NOT provisioned here — multi-agent
provisioning is a deployed-host feature.
CONSUME-AND-SHRED (optional)
----------------------------
Drop an empty file named `shred` at the seed root to have the importer wipe
all `env` files from this partition AFTER importing them, so secrets do not
persist on the stick:
/shred
This needs a writable seed; if the remount fails the env files are left in
place and the importer logs it. Off unless you add the marker, per stick.
SECURITY — READ THIS
--------------------
- This is FAT32: UNENCRYPTED and readable by anyone who plugs the stick
into any machine. There is no access control on this partition.
- By operator decision, env files here MAY carry secrets (API keys, and
the Vaultwarden bootstrap, which includes the master password). That is a
deliberate trade-off: treat every seeded stick as SECRET-BEARING MEDIA.
Do not lose it; do not lend it; prefer `shred` for one-shot provisioning.
- Imported secrets land mode 0600 owned by the agent user. Public SSH keys
are not secret and are always safe to place here.
- The importer runs at every boot. Removing a file from the seed and
rebooting does NOT remove the previously-installed copy from the
live system; re-flash the image to wipe state.
rebooting does NOT remove an already-installed copy from the live system;
re-flash the image to wipe state.
The importer logs to /var/log/clawdie-live-seed.log
(`service clawdie_live_seed status` tails it).
CONTACT
-------
clawdie.si — repository: clawdie-iso, file:
clawdie.si — repository: clawdie-iso, files:
live/operator-session/clawdie-live-seed
live/operator-session/clawdie-live-seed.README.txt

View file

@ -0,0 +1,152 @@
#!/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 <NAME>` 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:-https://vault.smilepowered.org}"
# agent-secrets collection in the Clawdie org. Overridable for other vaults.
COLLECTION_ID="${VAULT_COLLECTION_ID:-94ba61b8-633c-454e-b749-f115617eeac3}"
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
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
WORK="$(mktemp -d "${TMPDIR:-/tmp}/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
bw config server "$SERVER" >/dev/null 2>&1 || {
log "could not set bw server to $SERVER"
exit 1
}
# 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

View file

@ -5,3 +5,8 @@
# npm's moving latest dist-tag during ISO builds.
@earendil-works/pi-coding-agent@0.78.0
# Bitwarden CLI (`bw`) — headless access to the Clawdie Vaultwarden instance,
# used by clawdie-vault-fetch. Bundled offline so a booted image can pull agent
# secrets without a network npm install. See clawdie-ai/docs/VAULTWARDEN-SETUP.md.
@bitwarden/cli@2026.5.0