Vaultwarden secrets + live seed import (5 review concerns addressed) #67
5 changed files with 572 additions and 62 deletions
5
build.sh
5
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
|
||||
|
|
|
|||
|
|
@ -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: /<agent-name>/ 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:-<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"
|
||||
}
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
170
live/operator-session/clawdie-vault-fetch
Normal file
170
live/operator-session/clawdie-vault-fetch
Normal file
|
|
@ -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 <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}"
|
||||
# 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue