diff --git a/build.sh b/build.sh index 4af80dac..7fef5743 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/live/operator-session/clawdie-live-seed b/live/operator-session/clawdie-live-seed index 20f9f7bd..3f6de560 100644 --- a/live/operator-session/clawdie-live-seed +++ b/live/operator-session/clawdie-live-seed @@ -1,39 +1,59 @@ #!/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: // holding env, soul/, harness.toml, +# ssh/authorized_keys. On the live USB (single agent) the first agent dir +# is activated for the clawdie user; on a deployed host the importer loops +# every dir, staging the rest (multi-agent provisioning is a 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" +# Vaultwarden bootstrap creds are routed out of .env into this file (relative to +# the agent home) so clawdie-vault-fetch can consume them. +SEED_VAULT_BOOTSTRAP_REL=".config/vault-bootstrap.env" _seed_log() { printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$1" >>"${SEED_LOG}" 2>/dev/null || true @@ -66,26 +86,272 @@ _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}" } +# Valid POSIX-ish shell env var name. Guards against garbled seed lines +# injecting odd content into .env. +_seed_key_ok() { + case "$1" in + ''|[!A-Za-z_]*) return 1 ;; + *[!A-Za-z0-9_]*) return 1 ;; + esac + return 0 +} + +# Merge KEY=VALUE pairs from a plaintext source into a target file, preserving +# keys the source does not mention and replacing those it does. Skips blanks, +# comments, and invalid key names. Lands 0600 owned by the agent user. +_seed_merge_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 + case "${_line}" in + ''|'#'*) continue ;; + *=*) : ;; + *) continue ;; + esac + _line="$(printf '%s' "${_line}" | tr -d '\r')" + _k="${_line%%=*}" + if ! _seed_key_ok "${_k}"; then + _seed_log "skipping invalid env key '${_k}'" + continue + fi + 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 "merged ${_count} key(s) -> ${_dst}" +} + +# Split an agent's seed env into two staged files: bootstrap creds (BW_*) and +# everything else. Writes ${_outdir}/.app.env and ${_outdir}/.boot.env. +_seed_split_env() { + _src="$1" + _outdir="$2" + + _app="${_outdir}/.app.env" + _boot="${_outdir}/.boot.env" + : >"${_app}" + : >"${_boot}" + chmod 0600 "${_app}" "${_boot}" 2>/dev/null || true + + [ -f "${_src}" ] || return 0 + + while IFS= read -r _line || [ -n "${_line}" ]; do + case "${_line}" in + ''|'#'*) continue ;; + *=*) : ;; + *) continue ;; + esac + _line="$(printf '%s' "${_line}" | tr -d '\r')" + _k="${_line%%=*}" + _seed_key_ok "${_k}" || continue + case "${_k}" in + BW_CLIENTID|BW_CLIENTSECRET|BW_PASSWORD) + printf '%s\n' "${_line}" >>"${_boot}" ;; + *) + printf '%s\n' "${_line}" >>"${_app}" ;; + esac + done <"${_src}" +} + +# 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. +_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:-}' 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:-}' -> ${_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" +} + +# Stage non-home-affecting payload for any agent dir: soul, harness, agent name, +# and a 0600 copy of the raw env (so a staged-only agent's secrets are not lost). +# Never writes to a home directory or installs SSH keys. +_seed_stage_agent() { + _dir="$1" + _agent="$2" + + _stage="${SEED_IMPORT_ROOT}/${_agent}" + mkdir -p "${_stage}" + chmod 0700 "${_stage}" 2>/dev/null || true + printf '%s\n' "${_agent}" >"${_stage}/agent-name" 2>/dev/null || true + + _seed_import_harness "${_dir}/harness.toml" "${_stage}" + _seed_import_soul "${_dir}/soul" "${_stage}" + if [ -f "${_dir}/env" ]; then + cp "${_dir}/env" "${_stage}/env" 2>/dev/null || true + chmod 0600 "${_stage}/env" 2>/dev/null || true + fi + echo "${_stage}" +} + +# Activate one agent into a real home: env -> .env, BW_* -> vault-bootstrap.env, +# ssh keys installed. Only the live USB's single active agent gets this. +_seed_activate_agent() { + _dir="$1" + _agent="$2" + _user="$3" + _home="$4" + + _stage="$(_seed_stage_agent "${_dir}" "${_agent}")" + + if [ -f "${_dir}/env" ]; then + _seed_split_env "${_dir}/env" "${_stage}" + _seed_merge_env "${_stage}/.app.env" "${_home}/.env" "${_user}" + if [ -s "${_stage}/.boot.env" ]; then + _seed_merge_env "${_stage}/.boot.env" "${_home}/${SEED_VAULT_BOOTSTRAP_REL}" "${_user}" + _seed_log "routed Vaultwarden bootstrap creds -> ${_home}/${SEED_VAULT_BOOTSTRAP_REL}" + fi + rm -f "${_stage}/.app.env" "${_stage}/.boot.env" + fi + + if [ -f "${_dir}/ssh/authorized_keys" ]; then + _seed_install_authorized_keys "${_dir}/ssh/authorized_keys" "${_user}" "${_home}" + fi + + _seed_log "activated agent '${_agent}' for user ${_user} (home ${_home})" +} + +# Safe agent directory name: no traversal, no leading dot (skips macOS/FAT +# system dirs like .Spotlight-V100, .fseventsd), allowlisted charset, not reserved. +_seed_agent_name_ok() { + _n="$1" + case "${_n}" in + ''|.|..|.*|*/*) return 1 ;; + *[!A-Za-z0-9._-]*) return 1 ;; + esac + for _r in ${SEED_RESERVED_DIRS}; do + [ "${_n}" = "${_r}" ] && return 1 + done + return 0 +} + +# An agent dir must carry at least one recognized payload to count as an agent. +_seed_agent_has_payload() { + _d="$1" + [ -f "${_d}/env" ] && return 0 + [ -f "${_d}/harness.toml" ] && return 0 + [ -d "${_d}/soul" ] && return 0 + [ -f "${_d}/ssh/authorized_keys" ] && return 0 + return 1 +} + +# 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, is activated for the clawdie user. Additional dirs are + # staged only — no home is created — pending deployed multi-agent provisioning. + _first=1 + for _entry in "${SEED_MOUNT}"/*; do + [ -d "${_entry}" ] || continue + _agent="$(basename "${_entry}")" + if ! _seed_agent_name_ok "${_agent}"; then + case "${_agent}" in + ssh) : ;; # reserved, expected + *) _seed_log "skipping dir '${_agent}' (reserved, hidden, or invalid name)" ;; + esac + continue + fi + if ! _seed_agent_has_payload "${_entry}"; then + _seed_log "skipping dir '${_agent}' (no recognized agent payload)" + continue + fi + + if [ "${_first}" -eq 1 ]; then + _seed_activate_agent "${_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 + _seed_stage_agent "${_entry}" "${_agent}" >/dev/null + _seed_log "NOTE additional agent dir '${_agent}' staged only; 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 +365,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 +395,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 diff --git a/live/operator-session/clawdie-live-seed.README.txt b/live/operator-session/clawdie-live-seed.README.txt index eb4cbc22..fa897902 100644 --- a/live/operator-session/clawdie-live-seed.README.txt +++ b/live/operator-session/clawdie-live-seed.README.txt @@ -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. + //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 --------------- + //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. + + //soul/ A layered-soul backup tree (SOUL.md, USER.md, + IDENTITY.md, memories/, skills/, ...). Staged + under /var/db/clawdie/seed//soul for + the agent workspace to load. + + //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 diff --git a/live/operator-session/clawdie-vault-fetch b/live/operator-session/clawdie-vault-fetch new file mode 100644 index 00000000..898a6605 --- /dev/null +++ b/live/operator-session/clawdie-vault-fetch @@ -0,0 +1,170 @@ +#!/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:-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 + +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 diff --git a/packages/npm-globals.txt b/packages/npm-globals.txt index c77cc5a5..f21d0d7a 100644 --- a/packages/npm-globals.txt +++ b/packages/npm-globals.txt @@ -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