officer-setup: the environment section

Writes .env, and the whole point of the section is the two values it must not
write twice.

JWT_SECRET and VAULT_STORE_KEY are read back from any existing .env and kept.
The original script reminted JWT_SECRET on every run that agreed to regenerate
.env, which logs every device out with no stated reason, and never wrote
VAULT_STORE_KEY at all — so a scripted install had no at-rest key and the vault
and wallet refused to store anything.

VAULT_STORE_KEY is the more dangerous of the two now that it is being written.
It is not Vaultwarden's despite the name: it encrypts every secret column in
Postgres, and the wallet seed envelope on top of the owner passphrase. Changing
it is unrecoverable for the seed, because the passphrase opens the inner
envelope and that is the outer one. Said in the section, in the file it writes,
and in .env.example, which described it as Vaultwarden's and understated it.

DATA_PATH and OFFICER_ITEMS_DIR are derived from $OFFICER_ROOT rather than
asked — two questions that had to agree with each other and with the app store.

ALLOW_ANY_ORIGIN is written explicitly from whether tailscale0 exists, rather
than left to the platform default. The default is ON, which CLAUDE.md says is
only defensible because the tailnet is the perimeter; with no tailnet there is
no perimeter, so it goes out as false. Added to .env.example, which omitted it.

PORT defaults to 9000, matching .env.example. The old script used 9010; nothing
depends on either, and it is a prompt.

write_env restores the prior umask. It was set to 077 so the secrets are never
briefly world-readable, but umask is not scoped to a function and would have
made every file the later sections create owner-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 22:41:49 +00:00
co-authored by Claude Opus 5
parent 9eabc3ee4c
commit cb67b22f80
3 changed files with 242 additions and 3 deletions
+15 -2
View File
@@ -9,6 +9,11 @@ PUBLIC_URL=http://localhost:9000
# it to "dev" on a local machine you trust, since that disables all three. # it to "dev" on a local machine you trust, since that disables all three.
PUBLIC_BUILD_ENV=production PUBLIC_BUILD_ENV=production
# Origin checking is OFF unless this is explicitly "false" — an inversion of the usual rule, and one
# that is only defensible when the tailnet is the perimeter. On a machine with no tailnet, set it to
# false. Written explicitly rather than left to the default so the choice is visible.
ALLOW_ANY_ORIGIN=true
DATA_PATH=/path/to/data DATA_PATH=/path/to/data
OFFICER_ITEMS_DIR=/path/to/officer-items OFFICER_ITEMS_DIR=/path/to/officer-items
HOME_DIR=/home/user HOME_DIR=/home/user
@@ -31,9 +36,17 @@ BROWSER_RELAY_PORT=18792
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as # daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
# X-API-Key on every forwarded request. # X-API-Key on every forwarded request.
# Vaultwarden (officer-vault). VAULT_STORE_KEY encrypts stored secrets at rest — any strong secret # Vaultwarden (officer-vault).
# of 16+ chars works, and CHANGING IT MAKES EXISTING STORED SECRETS UNREADABLE.
VAULTWARDEN_URL=http://127.0.0.1:8222 VAULTWARDEN_URL=http://127.0.0.1:8222
# VAULT_STORE_KEY is NOT Vaultwarden's, despite the name and where it sits — it is the platform's
# at-rest key, and it encrypts every secret column in Postgres: Headscale admin API keys, app-store
# service credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top
# of the owner passphrase that seals it. Any strong secret of 16+ chars works.
#
# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the
# passphrase opens the inner envelope and this is the outer one. See docs/secret-store.md, which is
# the design for moving this key out of here and making rotation a supported operation.
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>" VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
# Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which # Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which
+104 -1
View File
@@ -46,6 +46,8 @@ source "$SCRIPT_DIR/officer-setup/lib/repo.sh"
source "$SCRIPT_DIR/officer-setup/lib/layout.sh" source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
# shellcheck source=officer-setup/lib/postgres.sh # shellcheck source=officer-setup/lib/postgres.sh
source "$SCRIPT_DIR/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"
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 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
@@ -447,10 +449,111 @@ if ! skip; then
step_ok step_ok
fi fi
# =============================================================================
# 6. Environment
# =============================================================================
step "Environment"
if ! skip; then
echo ""
info "Environment — $(env_file)"
# Read back before anything is asked. The two secrets below are kept, never
# reminted, and everything else becomes the default for its question.
ENV_JWT_SECRET="$(env_get JWT_SECRET)"
ENV_VAULT_STORE_KEY="$(env_get VAULT_STORE_KEY)"
ENV_PORT="$(env_get PORT)"
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
ENV_MAIL_TRANSPORT="$(env_get MAIL_TRANSPORT)"
ENV_DISCORD_WEBHOOK="$(env_get DISCORD_BUG_REPORT_WEBHOOK)"
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
if env_exists; then
echo " exists — its values are the defaults below, and the two secrets are kept"
else
echo " does not exist yet"
fi
# ── the secrets ──
if [[ -n "$ENV_JWT_SECRET" ]]; then
echo " JWT_SECRET: kept (regenerating it logs everybody out)"
else
ENV_JWT_SECRET="$(generate_secret)"
echo " JWT_SECRET: generated"
fi
if [[ -n "$ENV_VAULT_STORE_KEY" ]]; then
echo " VAULT_STORE_KEY: kept"
else
ENV_VAULT_STORE_KEY="$(generate_secret)"
echo " VAULT_STORE_KEY: generated"
echo ""
warn "back up VAULT_STORE_KEY somewhere safe, now."
echo " It encrypts every upstream credential the platform stores, and the"
echo " wallet's seed on top of your passphrase. Lose it and those are gone"
echo " — the passphrase does not help, because it opens the inner envelope"
echo " and this is the outer one."
fi
# ── what is asked ──
echo ""
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}"
echo ""
echo " PUBLIC_URL is where Officer is reached from a browser. Allowed"
echo " origins are derived from it, and passkeys are bound to its host —"
echo " so it has to be the address you actually use, not localhost, unless"
echo " localhost is genuinely it."
ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT}}"
# ── origin checking, decided by the machine rather than by a default ──
#
# ALLOW_ANY_ORIGIN defaults to ON inside the platform, which CLAUDE.md says is
# only defensible because the tailnet is the perimeter. So the value is written
# explicitly here, from whether this machine actually has one.
if tailnet_present; then
ENV_ALLOW_ANY_ORIGIN="true"
ORIGIN_WHY="tailscale0 is up, so the tailnet is the perimeter"
else
ENV_ALLOW_ANY_ORIGIN="false"
ORIGIN_WHY="no tailnet on this machine, so origin checking is left ON"
fi
echo ""
echo " ALLOW_ANY_ORIGIN=${ENV_ALLOW_ANY_ORIGIN}${ORIGIN_WHY}"
echo ""
ENV_MAIL_TRANSPORT="${ENV_MAIL_TRANSPORT:-}"
read -rp " Mail transport, blank for none [${ENV_MAIL_TRANSPORT}]: " REPLY_MAIL || true
ENV_MAIL_TRANSPORT="${REPLY_MAIL:-$ENV_MAIL_TRANSPORT}"
echo ""
echo " to write:"
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
echo " PUBLIC_URL=${ENV_PUBLIC_URL}"
echo " ALLOW_ANY_ORIGIN=${ENV_ALLOW_ANY_ORIGIN}"
echo " DATA_PATH=${OFFICER_ROOT}/data"
echo " OFFICER_ITEMS_DIR=${OFFICER_ROOT}/capabilities"
echo " HOME_DIR=${USER_HOME}"
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
echo " JWT_SECRET, VAULT_STORE_KEY — not shown"
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
# ============================================================================= # =============================================================================
# NOT BUILT YET # NOT BUILT YET
# ============================================================================= # =============================================================================
# 5 Environment .env
# 6 Schema db:push # 6 Schema db:push
# 7 Build gen:index # 7 Build gen:index
# 8 Services pm2 startOrRestart · save · startup # 8 Services pm2 startOrRestart · save · startup
+123
View File
@@ -0,0 +1,123 @@
#!/bin/bash
# =============================================================================
# officer-setup — the environment file
# =============================================================================
#
# Definitions only.
#
# ── Two secrets that must never be regenerated ──
#
# JWT_SECRET signs every session token. Minting a new one logs everybody out of
# every device, silently — the symptom is people being signed out for no stated
# reason. The original regenerated it on every run that answered "yes" to
# regenerating .env.
#
# VAULT_STORE_KEY is worse, and the original never wrote it at all — so a
# scripted install had no key and the vault and wallet refused to store anything.
# It encrypts every upstream credential the platform holds (see docs/secret-store.md
# for the full list) and, on top of the owner passphrase, the BIP39 seed envelope.
# Changing it makes all of them unreadable, and for the seed that is unrecoverable:
# the passphrase opens the inner envelope, and the outer one is gone. Unless the
# mnemonic was written down offline, so are the coins.
#
# Both are read back from an existing .env and kept. Both are slated to move into
# the secret store — docs/secret-store.md — which is what makes changing them an
# operation rather than data loss.
#
# ── Derived, not asked ──
#
# DATA_PATH and OFFICER_ITEMS_DIR come from $OFFICER_ROOT. They were two separate
# questions in the original, which had to agree with each other and with where the
# app store looks.
[[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0
OFFICER_SETUP_ENV_LOADED=1
env_file() { echo "$(platform_dir)/.env"; }
env_exists() { [[ -f "$(env_file)" ]]; }
# One value out of an existing .env, without sourcing it — the file holds
# secrets and arbitrary shell would run as root.
env_get() {
[[ -r "$(env_file)" ]] || return 0
awk -F= -v k="$1" '
$1 == k {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
print v
exit
}' "$(env_file)"
}
# Long enough to be worth having, and stripped of characters that would need
# quoting in a file everything reads with a naive parser.
generate_secret() { openssl rand -base64 48 | tr -d '/+=\n' | head -c 48; }
# Origin checking is OFF unless this is explicitly false — CLAUDE.md is explicit
# that the inversion is deliberate and is only defensible because the tailnet is
# the perimeter. With no tailnet there is no perimeter, so the default stops
# being defensible and the value has to be written the other way.
tailnet_present() { ip link show tailscale0 &>/dev/null; }
write_env() {
local dest
dest="$(env_file)"
[[ -f "$dest" ]] && cp -a "$dest" "${dest}.before-officer-setup"
# Restrictive from the moment it exists rather than chmod'd afterwards, so the
# secrets are never briefly world-readable. Restored straight after: umask is
# not scoped to a function, and leaving it at 077 would quietly make every file
# a later section creates owner-only.
local prior_umask
prior_umask="$(umask)"
umask 077
cat >"$dest" <<ENVF
# Written by officer-setup.
#
# Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it
# holds the token-signing secret and the database credential.
PORT="${ENV_PORT}"
PUBLIC_URL="${ENV_PUBLIC_URL}"
PUBLIC_BUILD_ENV="production"
# The browser relay listens on its own port, separate from the app.
BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
# ── Do not regenerate either of these ──
#
# JWT_SECRET signs every session token. A new one logs everybody out, everywhere.
JWT_SECRET="${ENV_JWT_SECRET}"
# VAULT_STORE_KEY encrypts every upstream credential in Postgres, and encrypts
# the wallet's seed envelope on top of the owner passphrase. Changing it makes
# all of them unreadable — and for the seed that is unrecoverable, passphrase or
# not. Back it up with the same seriousness as the mnemonics.
VAULT_STORE_KEY="${ENV_VAULT_STORE_KEY}"
POSTGRES_URL="${POSTGRES_URL}"
# Derived from the install root — see scripts/setup/officer-setup/lib/layout.sh.
DATA_PATH="${OFFICER_ROOT}/data"
OFFICER_ITEMS_DIR="${OFFICER_ROOT}/capabilities"
# The owner's real login home, which is where terminals, chats and task runs
# actually execute — as opposed to the managed home under DATA_PATH.
HOME_DIR="${USER_HOME}"
# Origin checking. Off by default in the platform, which is only safe behind a
# tailnet; written explicitly here so the machine's actual situation decides it.
ALLOW_ANY_ORIGIN="${ENV_ALLOW_ANY_ORIGIN}"
MAIL_TRANSPORT="${ENV_MAIL_TRANSPORT}"
DISCORD_BUG_REPORT_WEBHOOK="${ENV_DISCORD_WEBHOOK}"
ENVF
umask "$prior_umask"
chown "${USERNAME}:$(user_group)" "$dest"
chmod 600 "$dest"
return 0
}