Files
platform/scripts/setup/officer-setup/lib/env.sh
T
pastilhasandClaude Opus 5 2e7fb84454 fix three officer-setup bugs found on a general review, unrelated to macOS
env_get() never matched its own file. write_env() always writes OFFICERDEV_-prefixed
keys, but env_get() compared against the bare name — verified against a real written
.env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000]. Every one of its 5
call sites uses the bare name, so this always came back empty. Two consequences: the
"recover what earlier runs already decided" block never recovers anything — its own
comment says it exists to fix "Build announced PUBLIC_URL not set on a machine whose
.env had been written twenty minutes earlier", and that exact bug was still live — and
re-running the Environment step showed the wrong default (9000, tailnet URL) instead
of the machine's actual current config. Fixed to check the prefixed key first,
falling back to the bare one for an old unprefixed file, matching the runtime's own
`OFFICERDEV_X ?? X` priority.

pg_password_from_env_file() returned success with an empty string when the compose
.env existed but had no POSTGRES_PASSWORD= line. The caller
(`pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"`) would
then treat that as "reuse this password" and build POSTGRES_URL with a blank
credential rather than falling through to provision fresh. Only reachable via a
hand-edited or corrupted file — write_pg_compose always writes the line — but
"silently reuse a blank password" was the wrong direction to fail in. Now returns 1
when the line is missing or empty.

SETUP_DOCKER_NETWORK was read independently by both scripts, each defaulting to
"services", but never actually persisted across the machine-setup -> officer-setup
boundary — not one of the three keys load_machine_answers recognised. A customised
bridge name from machine-setup would silently not carry over, and Proxy would land
back on "services" regardless of what Docker was actually told to use. Now saved by
machine-setup's save_answers(), recognised by officer-setup's load_machine_answers(),
and PROXY_NET is re-derived from it right after that load — proxy.sh's own top-level
assignment runs at source time, before the answers file could have populated the
variable, same ordering issue ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL already had to
solve.

All three verified with isolated reproductions before and after: env_get against a
real prefixed .env and a legacy bare one, pg_password_from_env_file against a normal
and a corrupted compose .env, and the full save/load/re-derive chain for
SETUP_DOCKER_NETWORK. bash -n clean on every touched file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 04:01:52 +01:00

157 lines
6.1 KiB
Bash

#!/bin/bash
# =============================================================================
# officer-setup — the environment file
# =============================================================================
#
# Definitions only.
#
# ── No secrets are written here ──
#
# Every encryption and signing key lives in the secret store — a 0600 SQLite file
# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first
# use. See docs/secret-store.md and the Secrets section of officer-setup.sh.
#
# So this file holds no credential except POSTGRES_URL, which is a connection
# string to a database bound to loopback.
#
# ── Derived, not asked ──
#
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone too, and this time nothing
# replaces them. The platform derives the install root as the parent of its own
# working directory, so data/, capabilities/ and dockers/ follow from the layout
# on disk, and the owner's home comes from the OS. They were three environment
# variables that had to agree with each other and with the directory tree.
[[ -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.
#
# Checks the OFFICERDEV_-prefixed key first, matching the priority `OFFICERDEV_X ?? X`
# gives it at runtime — write_env ALWAYS writes prefixed keys (see below), so plain `env_get
# PORT` used to compare against a key that could never appear in a file this script wrote,
# and always came back empty. That silently defeated the resume block below it was written
# to fix, and the Environment step's own re-run showed the wrong default. Verified against a
# real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000], before this.
# Falls back to the bare key so an old, hand-written unprefixed file still works, same as the
# runtime does.
env_get() {
[[ -r "$(env_file)" ]] || return 0
awk -F= -v k="$1" -v pk="OFFICERDEV_$1" '
$1 == pk {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
print v
exit
}
$1 == k && !found {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
bare = v
found = 1
}
END {
if (bare != "") print bare
}' "$(env_file)"
}
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.
#
# EVERY NAME IS PREFIXED \`OFFICERDEV_\`. Bun auto-loads this file into every
# process started in the platform directory, so a bare PORT or POSTGRES_URL
# reaches the sidecars, the tools they spawn and anything an agent runs from
# here — names half the software in the world already uses. Prefixed, they
# cannot be picked up by accident.
#
# Every consumer reads \`OFFICERDEV_X ?? X\`, so an older unprefixed file still
# works. \`bun run fix:env\` converts one in place.
OFFICERDEV_PORT="${ENV_PORT}"
# Where Officer is reached from a browser. Not derivable — see the section.
OFFICERDEV_PUBLIC_URL="${ENV_PUBLIC_URL}"
OFFICERDEV_POSTGRES_URL="${POSTGRES_URL}"
ENVF
umask "$prior_umask"
chown "${USERNAME}:$(user_group)" "$dest"
chmod 600 "$dest"
return 0
}
# -----------------------------------------------------------------------------
# A sensible default for PUBLIC_URL
# -----------------------------------------------------------------------------
#
# localhost is the wrong default on a machine with a tailnet, and quietly so:
# it works from the machine itself and from nowhere else, so the mistake shows up
# on the first phone, not during setup.
#
# The tailnet is where Officer is actually reached — it is the perimeter the
# whole security model rests on — so its address is the honest default.
#
# The SHORT MagicDNS name — `officer-dev`, not `officer-dev.ts.example.dev` and
# not the raw 100.x address. All three resolve inside the tailnet; the short one
# is the one anybody actually types, and PUBLIC_URL ends up baked into the page's
# OpenGraph tags by `bun gen:index`, so it is read by people as well as machines.
#
# It relies on the tailnet's search domain, which every Tailscale client sets when
# MagicDNS is on. A device that has somehow lost it resolves the FQDN and not the
# short name — the fix there is to type the longer one, not to default to it.
#
# Falls back to localhost when there is no tailnet, which is correct rather than
# merely tolerable: a machine with no private network has no other address that
# is any better a guess.
tailnet_hostname() {
local dns ip
# jq over grep -oP: this output is real JSON, and GNU grep's -P/\K doesn't exist on BSD
# grep at all (confirmed live — errors visibly), which used to leak straight to the
# terminal since only the `tailscale` call's stderr was suppressed, not the grep's. jq is
# already an unconditional dependency elsewhere in this script (proxy.sh), so this is
# strictly a correctness improvement even setting the portability problem aside.
dns="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty')"
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
dns="${dns%%.*}" # and we want the short name
if [[ -n "$dns" ]]; then
echo "$dns"
return 0
fi
ip="$(tailscale ip -4 2>/dev/null | head -1)"
[[ -n "$ip" ]] && echo "$ip"
}
default_public_url() {
local host
host="$(tailnet_hostname)"
if [[ -n "$host" ]]; then
echo "http://${host}:${1}"
else
echo "http://localhost:${1}"
fi
}