Files
platform/scripts/setup/officer-setup.sh
T
pastilhasandClaude Opus 5 8207824a81 build the secret store: one key per purpose, none in .env
.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in
$OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the
service user, created on first use.

The design doc planned to move ONE at-rest key into the store. What shipped
splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and
service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all
seven, so one leak opened all of them — and it was named after whichever plugin
needed it first, which is why it read as safe to change if you did not run a
vault. A core install bootstraps two, jwt and headscale; the rest appear when
their plugin first asks.

The file IS the secret. No second key unlocks it, because a key beside the store
it opens buys nothing. The gain was never secrecy, it is blast radius: bun
auto-loads .env into all twenty pm2 processes, so a key there is readable from
/proc/<pid>/environ of twenty processes — officer-music held the key that
decrypts wallet seed envelopes.

Two defects found by testing the store rather than reading it, both of which
would have shipped:

  The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than
  inheriting the database's mode, and a freshly written key lives in the WAL
  before checkpoint — so the 0600 on the database was decorative. The 0700
  directory covered it, but only until someone loosened the directory.

  PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set
  AFTER it. With twelve concurrent openers, six died on that line with
  SQLITE_BUSY. Every sidecar opens this store at boot, so they open it
  simultaneously by definition: most of them would have failed to start on a cold
  boot and none on a warm one. Fixed by ordering the pragmas; re-tested with
  twelve racing processes, one key, one row.

crypto.ts takes a purpose as its first argument now, which the design doc had
explicitly promised would not happen — 32 call sites across seven query modules.
That promise is corrected in the doc rather than quietly dropped.

Also live, not just comments: wallet/upstream.ts gated wallet storage on
process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It
asks the store now, and the question it answers changed — not "did somebody set a
variable" but "can this process open the store", since the key is created on
demand.

assertSecretsClosed covers the store, its directory and its WAL. The jwt key
mints owner tokens, so a member's shell reading it is strictly worse than the
.env leak that check was written for.

Not typechecked: node_modules is empty and installs are frozen, so the
officerdb/secret-store subpath could not be resolved at runtime here — verified
that officerdb/types fails identically, so it is the empty tree and not the new
export. The store module itself was tested directly: creation, idempotence across
processes, hasKey not creating, permissions, and the twelve-way race. Every
changed file parses; the setup section runs and degrades correctly when the
import is unavailable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:00:12 +00:00

558 lines
22 KiB
Bash
Executable File

#!/bin/bash
set -e
# =============================================================================
# officer-setup — the platform, on a machine that is already provisioned
#
# The second half of the install. machine-setup/ brings a blank box up to a
# usable machine; this puts Officer on top of it.
#
# Run as root: sudo scripts/setup/officer-setup.sh
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
ONLY_STEP=""
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
ONLY_STEP="${2:-}"
shift 2
;;
--only=*)
ONLY_STEP="${1#*=}"
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0
;;
-h | --help)
echo "usage: officer-setup.sh [--only <step>] [--list]"
exit 0
;;
*) echo "unknown option: $1" >&2 && exit 2 ;;
esac
done
# shellcheck source=officer-setup/lib/base.sh
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
# shellcheck source=officer-setup/lib/preflight.sh
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
# shellcheck source=officer-setup/lib/repo.sh
source "$SCRIPT_DIR/officer-setup/lib/repo.sh"
# shellcheck source=officer-setup/lib/layout.sh
source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
# shellcheck source=officer-setup/lib/postgres.sh
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
# shellcheck source=officer-setup/lib/env.sh
source "$SCRIPT_DIR/officer-setup/lib/env.sh"
# shellcheck source=officer-setup/lib/secrets.sh
source "$SCRIPT_DIR/officer-setup/lib/secrets.sh"
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
# =============================================================================
# 1. Pre-flight
# =============================================================================
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Officer Setup ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./officer-setup.sh"
fi
# ── what machine-setup already established ──
echo ""
if load_machine_answers; then
info "Read from machine-setup: ${MACHINE_ANSWERS}"
else
warn "machine-setup has not run on this machine"
echo " That is fine if you provisioned it another way — the questions it"
echo " would have answered are asked below instead."
fi
# ── the account ──
#
# A remembered answer can go stale: the account it names may have been renamed or
# removed since machine-setup ran. That is a reason to ask again, not a reason to
# stop — so the remembered value is checked before it is trusted, and a bad one
# is reported and replaced rather than ending the run.
if [[ -n "$USERNAME" ]] && ! owner_exists; then
warn "the remembered account '${USERNAME}' does not exist on this machine any more"
USERNAME=""
fi
while [[ -z "$USERNAME" ]] || ! owner_exists; do
echo ""
info "Which account owns this Officer install?"
echo " Its files, its node_modules and its pm2 process list all belong to"
echo " this account rather than to root."
echo ""
ask_required USERNAME "Username" "${SUDO_USER:-}"
owner_exists || warn "There is no account called '${USERNAME}' on this machine."
done
resolve_user_home
# ── where it goes ──
if [[ -z "$OFFICER_ROOT" ]]; then
echo ""
info "Where should Officer be installed?"
echo " One directory holding the app, its data, the item store and any"
echo " containers the app store provisions."
echo ""
ask_required OFFICER_ROOT "Path" "${USER_HOME}/officerdev"
fi
OFFICER_ROOT="${OFFICER_ROOT/#\~/$USER_HOME}"
[[ "$OFFICER_ROOT" == /* ]] || fail "That needs to be an absolute path — got '${OFFICER_ROOT}'"
OFFICER_ROOT="${OFFICER_ROOT%/}"
info "Account: ${USERNAME} (home ${USER_HOME})"
info "Officer: ${OFFICER_ROOT}"
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
# ── is the machine actually ready ──
#
# Checked and reported together. Finding out about a missing bun three sections
# in, after a repository has been cloned and a database started, is a worse way
# to learn it.
echo ""
info "What Officer needs from this machine"
mapfile -t MISSING < <(missing_tools)
mapfile -t MISSING_OPT < <(missing_optional_tools)
for t in "${REQUIRED_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
else
printf ' %-6s %-10s %s\n' "$t" "MISSING" "$(tool_why "$t")"
fi
done
for t in "${OPTIONAL_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
else
printf ' %-6s %-10s %s\n' "$t" "absent" "$(tool_why "$t") — optional"
fi
done
if ((${#MISSING[@]} > 0)); then
echo ""
fail "Missing: ${MISSING[*]}. Run scripts/setup/machine-setup/machine-setup.sh first, or install them yourself."
fi
if ((${#MISSING_OPT[@]} > 0)); then
echo ""
warn "No Docker. Postgres will have to be one you already run, and the app"
echo " store cannot provision anything until Docker is installed."
fi
if [[ -f "$PROGRESS_FILE" ]]; then
echo ""
info "Resuming — $(wc -l <"$PROGRESS_FILE") step(s) already done, and they will be skipped"
echo " To start over instead: sudo rm ${PROGRESS_FILE}"
else
echo ""
echo " This can be stopped at any point and run again later. Completed"
echo " steps are remembered and skipped."
fi
# =============================================================================
# 2. Layout
# =============================================================================
#
# Before the repository, because the repository is cloned into it.
step "Layout"
if ! skip; then
echo ""
info "Layout — everything Officer owns, under one root"
echo " ${OFFICER_ROOT}/"
echo " platform/ the app"
echo " data/ managed homes, attachments, job logs"
echo " dockers/ anything the app store provisions"
echo " capabilities/ skills, tools, tasks, processes"
echo ""
echo " Nothing here is configurable. The original asked separately for the"
echo " data directory and the item store, which were two answers that had"
echo " to agree with each other. One root now, and the rest follows."
echo ""
echo " To put data/ on a bigger volume later, symlink it — that is a"
echo " decision about storage rather than about how Officer is laid out."
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
if ((${#WRONG_OWNER[@]} > 0)); then
echo ""
warn "these exist but do not belong to ${USERNAME}:"
printf ' %s\n' "${WRONG_OWNER[@]}"
echo " Everything that writes into them runs as ${USERNAME} — the platform"
echo " under pm2, the app store's compose files, the item store the agent"
echo " authors into. Left as they are, those writes fail in a way that"
echo " reads as a bug in the platform."
if confirm "Give them to ${USERNAME}?"; then
for d in "${WRONG_OWNER[@]}"; do chown -R "${USERNAME}:$(user_group)" "$d"; done
ok "ownership corrected"
SUMMARY+=("Layout: ownership corrected on ${#WRONG_OWNER[@]} directory(ies)")
fi
fi
create_layout
ok "layout in place under ${OFFICER_ROOT}"
SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)")
step_ok
fi
# =============================================================================
# 3. Repository
# =============================================================================
step "Repository"
if ! skip; then
PLATFORM_DIR="$(platform_dir)"
echo ""
info "Repository — where the platform's code lives"
echo " path: ${PLATFORM_DIR}"
if repo_exists; then
echo " remote: $(repo_remote)"
echo " branch: $(repo_branch)"
echo " working: $(repo_is_dirty && echo 'has uncommitted changes' || echo 'clean')"
# Reported, never silently corrected. Repointing somebody's remote is a
# decision about where their work goes, and this script is not entitled to
# make it quietly.
if [[ -n "$(repo_remote)" && "$(repo_remote)" != "$OFFICER_REPO" ]]; then
echo ""
warn "this checkout points somewhere other than ${OFFICER_REPO}"
echo " Left alone. To move it:"
echo " git -C ${PLATFORM_DIR} remote set-url origin ${OFFICER_REPO}"
fi
if repo_is_dirty; then
echo ""
echo " not pulling — there are uncommitted changes here, and a pull"
echo " would either fail or bury them"
SUMMARY+=("Repository: present at ${PLATFORM_DIR}, left alone (uncommitted changes)")
elif confirm "Pull the latest changes?"; then
if pull_repo; then
ok "up to date on $(repo_branch)"
SUMMARY+=("Repository: pulled, on $(repo_branch)")
else
# --ff-only, so this means the branch has diverged rather than that the
# network failed. Saying which matters.
warn "could not fast-forward — the local branch has diverged from the remote"
ERRORS+=("Repository: pull refused, branch diverged")
SUMMARY+=("Repository: present, pull refused (diverged)")
fi
else
SUMMARY+=("Repository: present at ${PLATFORM_DIR}")
fi
else
echo " nothing there yet"
echo ""
info "Clone from ${OFFICER_REPO}?"
echo " Cloned as ${USERNAME}, not as root — a repository owned by root is"
echo " one you cannot pull, commit in, or install into."
CLONE_URL="$OFFICER_REPO"
if confirm "Clone it now?"; then
if clone_repo "$CLONE_URL"; then
ok "cloned to ${PLATFORM_DIR} on $(repo_branch)"
SUMMARY+=("Repository: cloned from ${CLONE_URL}")
else
# GIT_TERMINAL_PROMPT=0 in clone_repo means this is a real failure rather
# than a prompt nobody answered.
fail "could not clone ${CLONE_URL} — nothing below can run without it."
fi
else
fail "Nothing below can run without the repository."
fi
fi
step_ok
fi
# =============================================================================
# 4. Dependencies
# =============================================================================
step "Dependencies"
if ! skip; then
echo ""
info "Dependencies — bun install, as ${USERNAME}"
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
echo ""
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
echo " so a package.json that disagrees with it fails rather than quietly"
echo " picking newer versions. That friction is deliberate."
echo ""
echo " node-pty has no Linux prebuild, so this compiles it from source"
echo " every time — which is what build-essential and python3 are for."
if deps_installed && node_pty_built; then
ok "already installed, and node-pty is built"
SUMMARY+=("Dependencies: already installed")
elif confirm "Install them?"; then
if install_deps; then
if node_pty_built; then
ok "installed, node-pty built"
SUMMARY+=("Dependencies: installed")
else
# The install can succeed while the native module does not get built —
# bun skips a dependency's lifecycle scripts unless it trusts the
# package. Worth naming, because the symptom is a terminal that never
# comes up rather than an install error.
warn "installed, but node-pty has no built module at node_modules/node-pty/build/Release/"
echo " The terminal sidecar cannot start without it. Try:"
echo " cd $(platform_dir) && bun install --force"
ERRORS+=("Dependencies: node-pty not built")
SUMMARY+=("Dependencies: installed, node-pty NOT built")
fi
else
warn "bun install failed"
echo " If it complained about the lockfile, package.json and bun.lock"
echo " disagree — that is the frozen lockfile doing its job, and it"
echo " wants a human to look at the diff."
ERRORS+=("Dependencies: bun install failed")
SUMMARY+=("Dependencies: FAILED")
fi
else
warn "skipped by request"
SUMMARY+=("Dependencies: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 5. Database
# =============================================================================
#
# POSTGRES_URL is set here and written by the environment section below.
step "Database"
if ! skip; then
echo ""
info "Database — Postgres, the only one Officer has"
echo " It holds the account, passkeys, settings, dashboards, email"
echo " accounts and the job queue. Nothing else in the platform is a"
echo " database."
echo ""
# One network for everything Officer provisions. Created before the compose
# file references it, since it is declared external there.
if ensure_docker_network; then
ok "docker network '${OFFICER_NETWORK}' created"
SUMMARY+=("Docker network: ${OFFICER_NETWORK} created")
elif docker_network_exists; then
echo " network: ${OFFICER_NETWORK} (already there)"
fi
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
POSTGRES_URL=""
# An existing compose file means this ran before. Reuse its password rather
# than minting a new one, which would leave the container and the URL
# disagreeing about the credential.
if pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"; then
POSTGRES_URL="$(pg_url "$PG_EXISTING_PASSWORD")"
echo ""
echo " already provisioned here — reusing the password from $(pg_env_file)"
pg_container_running || {
info " starting it"
pg_compose_up >/dev/null 2>&1 || true
}
if pg_wait_ready; then
ok "postgres answering on 127.0.0.1:${PG_PORT}"
SUMMARY+=("Database: existing Postgres at ${PG_CONTAINER}")
else
warn "the container is not answering — check: docker logs ${PG_CONTAINER}"
ERRORS+=("Database: provisioned but not answering")
fi
else
echo ""
info "Which Postgres should Officer use?"
echo ""
echo " [1] provision one here"
echo " ${PG_IMAGE} in $(pg_service_dir), bound to 127.0.0.1 only."
echo " Docker publishes ports by writing iptables rules beneath ufw,"
echo " so a database published to every interface is reachable from"
echo " the internet whatever the firewall says. Loopback is all the"
echo " platform needs — it runs on this machine."
echo ""
echo " [2] use one you already run"
echo " Give the connection URL. Nothing is provisioned."
echo ""
DB_PICK=""
while [[ -z "$DB_PICK" ]]; do
if ! read -rp " Which one? (1/2) [1]: " DB_CHOICE; then
echo ""
fail "No answer."
fi
case "${DB_CHOICE:-1}" in
1)
if ! command -v docker &>/dev/null; then
warn "Docker is not installed, so there is nothing to provision into."
continue
fi
if pg_port_in_use; then
warn "something is already listening on ${PG_PORT} — provisioning here would fail to bind"
echo " If that is a Postgres you already run, pick 2 and give its URL."
continue
fi
DB_PICK=provision
;;
2) DB_PICK=existing ;;
*) warn "Pick 1 or 2." ;;
esac
done
if [[ "$DB_PICK" == provision ]]; then
PG_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
write_pg_compose "$PG_PASSWORD"
ok "compose written to $(pg_compose_file)"
if pg_compose_up && pg_wait_ready; then
POSTGRES_URL="$(pg_url "$PG_PASSWORD")"
ok "postgres answering on 127.0.0.1:${PG_PORT}, database '${PG_DATABASE}'"
SUMMARY+=("Database: provisioned at $(pg_service_dir)")
else
warn "the container did not come up — check: docker logs ${PG_CONTAINER}"
ERRORS+=("Database: container did not start")
SUMMARY+=("Database: provisioning FAILED")
fi
else
echo ""
ask_required POSTGRES_URL "Connection URL" "postgresql://user:password@host:5432/officer"
if pg_url_works "$POSTGRES_URL"; then
ok "reachable"
SUMMARY+=("Database: existing, ${POSTGRES_URL%%:*}://…")
else
# Not fatal. The URL may be right and the database not started yet, and
# refusing to continue over that would be worse than saying so.
warn "could not connect with that URL"
echo " Kept anyway — check it before running the schema step."
ERRORS+=("Database: the given URL did not answer")
SUMMARY+=("Database: existing URL kept, did not answer")
fi
fi
fi
step_ok
fi
# =============================================================================
# 6. Environment
# =============================================================================
step "Environment"
if ! skip; then
echo ""
info "Environment — $(env_file)"
# Read back before anything is asked; existing values become the defaults.
ENV_PORT="$(env_get PORT)"
if env_exists; then
echo " exists — its values are the defaults below"
else
echo " does not exist yet"
fi
# ── what is asked ──
echo ""
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
echo ""
echo " to write:"
echo " PORT=${ENV_PORT}"
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
echo ""
echo " the install root is not written here — the platform derives it as the"
echo " parent of the repo, so data/, capabilities/ and dockers/ follow from"
echo " ${OFFICER_ROOT} without anything having to agree with anything."
echo ""
if confirm "Write it?"; then
write_env
ok "written, 0600, owned by ${USERNAME}"
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
SUMMARY+=("Environment: $(env_file)")
else
warn "skipped by request"
SUMMARY+=("Environment: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 7. Secrets
# =============================================================================
#
# The store creates keys on demand, so this section is not strictly required —
# the first `sign()` would mint the jwt key by itself. It runs anyway for two
# reasons: the file should exist with the right owner and mode before anything
# races to create it, and an install that finishes without ever saying the words
# "back this up" is one where nobody learns the file matters until it is gone.
step "Secrets"
if ! skip; then
echo ""
info "Secret store — $(secret_store_path)"
echo " Every encryption and signing key the platform holds, one SQLite file,"
echo " one key per purpose. Nothing goes in .env."
echo ""
echo " bootstrapped now:"
echo " jwt signs every session token"
echo " headscale encrypts the Headscale admin API key in Postgres"
echo ""
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
echo " service-connections — is created when its plugin is installed. A"
echo " plugin cannot read another plugin's key."
echo ""
if confirm "Create it?"; then
if bootstrap_secret_store; then
ok "created, 0600, owned by ${USERNAME}"
echo ""
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump."
echo " Losing it signs everyone out and makes every encrypted column in"
echo " Postgres unreadable. For the wallet seed that is unrecoverable:"
echo " the passphrase opens the inner envelope, this is the outer one."
echo ""
echo " Keeping it beside a dump defeats it — the dump is the ciphertext"
echo " and this is the key. Separate backups, or it is one theft."
SUMMARY+=("Secrets: $(secret_store_path)")
else
warn "could not create the store — the platform will create it on first use"
SUMMARY+=("Secrets: NOT created; the platform will do it on first use")
fi
else
warn "skipped by request — the platform will create it on first use"
SUMMARY+=("Secrets: SKIPPED; the platform will create it on first use")
fi
step_ok
fi
# =============================================================================
# NOT BUILT YET
# =============================================================================
# 6 Schema db:push
# 7 Build gen:index
# 8 Services pm2 startOrRestart · save · startup
# 9 Verify are the processes actually up
echo ""
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
echo ""