Author SHA1 Message Date
pastilhasandClaude Opus 5 fe0012635a stop leaking the parent claude session into the one we spawn
pm2 inherits the environment of whoever ran pm2 start, so restarting this
sidecar from inside a claude code terminal — which is how it is restarted
most of the time — bakes that terminal's session into the daemon. right now
this process is carrying CLAUDE_CODE_MESSAGING_SOCKET for an unrelated pid
that has been alive for an hour and a half.

three of these were already stripped; the rest arrived with 2.x and were
never added. this is hygiene, not the fix for today's hang — a spawn was
verified to succeed with the whole set present — but a child attaching to a
stranger's ipc socket is not a failure anyone would recognise from the
symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:36:32 +00:00
pastilhasandClaude Opus 5 547662842b a dead claude process no longer hangs the chat forever
the sdk runs two independent tasks per session: the consumer loop
(`for await (const msg of q)`) and an input pump that writes the queue to
the child's stdin. the consumer loop's `finally` is what removes a session
from the map — but when the CHILD dies it is the input pump that fails,
with `ProcessTransport is not ready for writing`, and that rejection
neither ends the consumer loop nor is caught anywhere.

so the loop stayed parked on a stream with no writer, `finally` never ran,
the session stayed in the map, and spawnClaudeStreaming handed every later
turn to the same corpse. each one pushed a message onto a queue nobody
drained: no error, no result, no timeout. the client spun forever and the
only trace was one unhandledRejection line in the sidecar log.

observed on the host today; the only cure was pm2 restart
officer-claude-code.

a member's turn already supplied its own spawn function because it has to
go through setpriv. the owner had none, and therefore no place to observe
the child — which is exactly why its death was invisible. so give the owner
one too, and wrap both in watchChild: on exit or error, drop the session
from the map and, if a turn was in flight, tell the client.

emitting only while generating is deliberate. a child that exits between
turns is invisible to the user, and an error bubble arriving in a chat
nobody is looking at would be noise — dropping the map entry is the whole
repair there, because the next turn builds a fresh session and resumes the
transcript by id.

the stall timer now tears the session down as well. it used to keep it —
"it may still be working, and the next turn resumes it" — which is right
for a slow agent and wrong for a wedged one: the session stayed broken, so
every later turn hung the same way and "send again to continue" was a lie.
sessions with background tasks outstanding are still left alone, since a
job can be silent far longer than ten minutes and still land its
notification.

verified live against the real manager: killed the child mid-turn, saw the
error surface and the next turn rebuild the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:36:09 +00:00
pastilhasandClaude Opus 5 eb1fd8c31a headscale is core, so stop offering to install it
Reported as: the routes are unreachable and there is no dock tile, on a server where
officer-headscale is up and healthy. Both symptoms, one cause.

Availability is derived ONLY from the sidecar_installs table — `usable` is the rows
with status='installed' AND enabled, and every capability mapped to a sidecar outside
that set is added to `unavailable`. A CORE sidecar never gets a row there, because
core processes are started by pm2 from the generated ecosystem file and never go
through the app store. So `headscale` and `vpn` were permanently unavailable, which
withheld the dock manifest AND put /headscale into deniedRoutes for the route guard.

The design already knew. catalogue.test.ts has a test called "does not offer to
install the baseline", and it has been FAILING since headscale was promoted:

  Expected to not contain: "officer-headscale"

docs/secret-store.md predicted it in as many words — "moving headscale into the light
profile also removes it from the app store automatically: catalogue.test.ts asserts
the catalogue equals full − light, so the test fails until the entry is deleted". The
entry was never deleted, and the failing test was never read.

So: entry removed, and the tile moved to CORE_DOCK_ITEMS, where the other things that
are always present live. DashboardLayout filters every tile through canVisit(), so a
member still never sees it — the capability is kind: 'admin'.

The entry's existingFields (URL + API key) are not lost. Servers are added from the
Servers view inside the app — ServersView.tsx, ServerForm.tsx, useHeadscaleServers.ts
— which is where they were really configured; the app-store form was a second place to
type the same two values.

Verified: catalogue.test.ts 19 pass/1 fail → 20 pass/0 fail, tsgo clean.

PRE-EXISTING, not touched: 10 other tests fail on master, 8 of them in
src/servers/capabilities. Confirmed identical before and after this change by
stashing it and re-running. Worth a look but not this change's business.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:31:00 +00:00
pastilhasandClaude Opus 5 d85f089817 tsgo is clean
All five errors gone. Both were real resolution bugs rather than dead code that
happened to be noisy — the unreachable parts were unreachable for the wrong reason.

officerdb's export map gave the wildcard no extension:

  "./types": "./src/types.ts"     explicit entries carry it
  "./*":     "./src/*"            the wildcard did not

so `officerdb/soulseek/schema` resolved to `src/soulseek/schema`, which is not a
file, while `src/soulseek/schema.ts` sat right there. Now "./src/*.ts". Verified
every subpath still resolves at RUNTIME with Bun.resolveSync — an exports map is
exactly the thing where a typecheck fix can break the running app, and three of the
four paths are load-bearing.

types.ts inferred EmailAccount* and PushDevice* from `./schema`, the aggregator that
drizzle-kit reads — where both tables are commented out because they belong to
plugins. But inferring a TYPE has nothing to do with whether the table exists in the
live database: these describe rows the plugin's own code passes around, and that code
compiles whether or not the plugin is installed. Reading them off the aggregator
coupled the two, so commenting a plugin out of schema.ts broke the build of code that
was already unreachable.

They now come from ./email/schema and ./notify/schema directly — the same move the
query modules made when the split landed, and the thing that lets a table leave the
aggregator without breaking anything. src/databases/CLAUDE.md already describes this
as the rule; types.ts was the one file that had not followed it.

Verified: bunx tsgo --noEmit, zero output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:24:32 +00:00
pastilhasandClaude Opus 5 11710f283a wire the reverse proxy in as officer-setup 12
~/npm-setup-draft/setup-npm.sh, adapted to the script's own helpers and placed last
— it is the only step that needs Officer already running.

It ignores --unattended, as asked. Every other question in this script has a
defensible default; a domain name, a DNS provider and that provider's API
credentials do not, and the step is opt-in besides. Its prompts read stdin directly
instead of going through confirm()/ask_required(), and they are NAMED APART
(proxy_confirm, proxy_ask) so nobody later consolidates them into the shared helpers
and quietly makes --unattended agree to publishing a public hostname.

The valve is a TTY check rather than the flag: with no terminal there is nobody to
ask, so it skips and prints the manual instructions. A cron-driven install still
works.

Five fixes to the draft:

  - `${OFFICER_REPO}/scripts/store-npm-credential.ts` — OFFICER_REPO is a git URL,
    not a directory, so that path was https://…/platform.git/scripts/… and the -f
    test could never pass. The whole persist-to-platform branch was dead code
    falling through to the print. Dropped it: the comment beside it already argued
    that not storing this password is a legitimate outcome, since only a human
    logging into the admin UI needs it.
  - NOT re-runnable, despite saying so. claim_admin returned early on an already
    claimed instance without setting NPM_EMAIL/NPM_PASSWORD, and get_token
    dereferenced both under set -u. Second run died on an unbound variable. It now
    asks for the existing credentials.
  - $HOME/dockers → $OFFICER_ROOT/dockers, matching data-path.ts. And the network
    is SETUP_DOCKER_NETWORK (`services`), not a second bridge called `officerdev`.
  - dig → getent hosts. dnsutils is not installed by this platform, so the check was
    command-not-found on a fresh VPS — and an empty answer is indistinguishable from
    "not resolving yet", so it waited the full 30 minutes before failing.
  - python3 → jq for host-side JSON. jq is already in the core package list; the one
    remaining python3 runs INSIDE the NPM container to read its own credential
    template, which is the point of reading it from there.

Failure is contained: every function warns and returns non-zero rather than exiting,
so a proxy that does not come up leaves a finished Officer install behind. Retry
with `--only Proxy`.

Verified: bash -n, shellcheck -S warning clean, --list shows Proxy, all seven
external commands present, and the jq filters checked against sample payloads
including the multi-line DNS credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:56:40 +00:00
pastilhasandClaude Opus 5 b6feca8350 owner can reset a member's platform password
The gap at the other end of create-user.ts: the owner could set a password once, at
creation, and never again. Losing it meant a hand-written UPDATE with an argon2
hash — the same "edit Postgres by hand" hole that creating accounts used to have.

POST /api/users/:id/password, owner-gated, with a button on the row.

GENERATED, not typed. The failure this exists for is "I created the account and
forgot to copy the password down", and an owner typing a replacement can lose it the
same way on the second go. Shown once in a dialog built to be copied — a dialog and
not a toast, because a toast that times out while somebody finds a pen loses the one
thing they came for.

The generator satisfies validatePassword BY CONSTRUCTION rather than by luck: one
character drawn from each of the four required classes, the rest from the union,
then Fisher-Yates shuffled so the first four positions are not always
lower/upper/digit/special. Rejection sampling throughout — `% n` on a byte biases
the early characters. Then it runs validatePassword on its own output, so if the
rules ever gain a requirement the alphabets do not cover it throws at the one call
site instead of minting passwords the login form rejects. Measured: 20,000
generations, all four classes present every time.

l, I, 1, O and 0 are absent from the alphabets. This gets read off a screen and
typed somewhere else.

Signs them out everywhere, as asked: passwordChangedAt = now, and userMiddleware
already refuses any token whose iat predates it. That overwrites the null
create-user leaves to mean "the owner chose this, not them" — checked, nothing reads
that column except the token check.

The Linux account is deliberately untouched, and the dialog says so. Members have no
Linux password and never had one: ensureOsUser runs useradd with no -p, so it is
created locked. Their terminal goes through setpriv, which does not authenticate;
their SSH is the key the owner pasted; `su - <member>` as root does not ask. And
machine-setup sets PasswordAuthentication no — verified on this host — so one could
not be used to log in even if it existed. Setting one would be a new way in, not a
repair.

The owner is excluded: they have change-password, which asks for the current one,
and resetting themselves here would end the session doing it.

Verified: transpiles, all lucide icons exist, 20k generator runs. tsgo next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:07:43 +00:00
pastilhasandClaude Opus 5 1d95ad3d1b install the rootless docker prerequisites with docker itself
Reported from a member's daemon failing: "rootless Docker needs these packages on
the host: uidmap".

They were being installed — but only inside branch [2] "rootless Docker for
<owner>" in section 22. The owner's choice is not the only one that matters: every
Developer account the platform provisions gets its own rootless daemon whatever the
owner picked for themselves. So on a machine where the owner chose the docker
group, the host never got them and every member's daemon failed.

Moved into install_docker_engine, so they arrive with Docker rather than with one
particular answer to a question about the owner.

Three packages, not the one in the error. checkDockerPrerequisites in
os-user-docker.ts is the authority and wants uidmap (newuidmap, newgidmap) AND
docker-ce-rootless-extras (dockerd-rootless-setuptool.sh); dbus-user-session is
what keeps a member's systemd --user alive without a login session. rootless-extras
is only RECOMMENDED by docker-ce — installed by default, so usually there by luck,
and absent on any host configured with --no-install-recommends. Named explicitly.

Reproduced on this machine while checking: rootless-extras present via Recommends,
uidmap absent, newuidmap and newgidmap missing. Exactly the reported failure, on a
box that chose the docker group.

The rootless branch still installs uidmap and dbus-user-session behind its
pkg_is_installed guard. Redundant now, kept deliberately: it is the only thing that
fixes a machine whose Docker was installed by an older run of this script.

Verified: bash -n on both files, and all three packages present in the noble
archive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:58:23 +00:00
pastilhasandClaude Opus 5 e881015df5 members get the same aliases as the owner
The set that just went into the owner's zshrc, mirrored into shell-skel/zshrc, so a
shell on this machine and a shell in a member's account behave alike rather than
diverging by who you happen to be.

Not a copy-paste. Three differences, each because this file has rules the owner's
appended block does not:

  - `n` and `vim` are NOT repeated. The Editor block above already sets them, and
    only when nvim is actually installed — better than the owner's unguarded pair.
  - the eza family keeps its `else` branch rather than only being guarded. A member
    with no eza still gets a coloured, grouped listing instead of bare `ls`, and
    every alias in the family has a real fallback: lll, lh, ltr and l were added to
    that branch too rather than silently existing only when eza does.
  - lazydocker is guarded like its neighbours duf and lazygit, per this file's
    stated rule that nothing is required beyond zsh itself.

eza needs no separate install for members: they share the host, and machine-setup
puts it in the core package list.

KNOWN, same shape as append_once: seedShellConfig only rewrites .zshrc while it is
still byte-for-byte the template, so a member provisioned before this keeps the old
one. No members exist right now, so nothing to migrate.

Verified: zsh -n, and the fallback branch resolving all ten aliases with eza absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:17:06 +00:00
pastilhasandClaude Opus 5 3862558b92 real aliases for the owner, and eza to go with them
The owner's `aliases` block was one line — `alias sz`. Members got a full set from
shell-skel/zshrc and the owner got that. Replaced with the eza ls family, the
oh-my-zsh standards, and n/vim/sz/ld/httpserver.

eza added to all four core package lists. It is in the noble archive at 0.18.2-1,
so this is a package rather than a binary fetch, and Core utils is section 5 — well
before Shell at 26, so `command -v eza` is already true when the block is written.

The eza aliases are GUARDED behind `command -v eza` and the rest are not, and the
asymmetry is deliberate: these replace `ls`. Unguarded, a machine where eza failed
to install has no working `ls` in any new shell, which reads as a broken machine
rather than a missing package. `alias ld=lazydocker` without lazydocker is one
command-not-found when you type it — that can degrade honestly. Same principle
shell-skel/zshrc already holds to.

python3, not python, for httpserver: Ubuntu ships no `python` binary at all, so as
given it would have been a command-not-found on every machine this targets.

Checked the editor block first — it only exports EDITOR/VISUAL/SUDO_EDITOR, so
n and vim do not collide with anything already appended.

KNOWN: append_once returns 1 when its marker is already present, so a machine that
has already run this keeps the old one-line block and gets none of the above. That
is the function working as designed — it exists so a second run does not duplicate
its work, and it cannot tell a stale block from one the owner edited. Fix by hand:
delete the `# >>> machine-setup: aliases >>>` block from ~/.zshrc and re-run
`machine-setup.sh --only Shell`.

Verified: bash -n, zsh -n on the block, the eza guard leaving ls unset when eza is
absent, and vim resolving through n to nvim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:12:46 +00:00
pastilhasandClaude Opus 5 977e30e782 point the setup default at public https
was  ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git
now  https://gitea.officer.dev/officerdev/platform.git

Bigger than a URL swap. The SSH default could not clone on a genuinely fresh
machine: the key machine-setup generates there is brand new and Gitea has never
seen it, so `--repo` was effectively mandatory on a first install — which is the
problem that flag was added for two hours ago. HTTPS needs no key and no agent, so
the default now works on a blank box.

The old comment explained SSH-because-private and set the condition for changing
it: "back to HTTPS when the repository is public". It now is — verified with an
anonymous `git ls-remote`, which lists refs with no credentials. Rewrote the
comment to record why it moved and what to do if it ever goes private again, since
that reasoning is the part worth keeping.

clone_repo already runs GIT_TERMINAL_PROMPT=0, so a private repo would fail fast
rather than hang on a username prompt. No change needed there.

repo.sh is still the only place that sets this, and --repo / OFFICER_REPO still
override it. Verified both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:11:42 +00:00
pastilhasandClaude Opus 5 edbe446b34 revert "allow port 22 through the docker-user allowlist"
this reverts e36c6bb4. the rule was added to fix gitea ssh on one box, but this
file provisions every machine and most will never run gitea. opening 22 to
containers by default is the wrong trade — the box that needs it can add the
line deliberately.

also restores the accuracy of the prompt in machine-setup.sh, which tells the
operator the rules allow "only 80 and 443".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:58:18 +00:00
pastilhasandClaude Opus 5 e36c6bb431 allow port 22 through the docker-user allowlist
the DOCKER-USER chain is the only thing gating docker-published ports from
the internet — docker writes its own DNAT/FORWARD rules and bypasses ufw, so
`ufw allow <port>` has no effect on a published container port. the allowlist
permitted only 80 and 443, so a machine provisioned from this template dropped
gitea ssh silently.

the failure is hard to spot: the port looks open locally and docker ps shows it
published, but external clients hang at TCP connect with no refusal. local tests
pass because they arrive via lo and match the loopback RETURN before reaching the
DROP. comments added so the next person recognises it faster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:51:22 +00:00
pastilhasandClaude Opus 5 4d14e11f6c --unattended: every question that has a default answers itself
51 yes/no prompts and ~20 free-text ones, of which about six actually need a human.
The line drawn is "a question with a default answers itself; a question with no
possible default still asks", so it stays attended without being a conversation.

Half of it already existed: ASSUME_YES=1 was implemented and honoured by confirm()
in both scripts, returning each question's OWN default — so a "do the thing you
asked for" question goes yes and a genuine extra goes no. --unattended sets it.

The new part is menu_answer(), for the eight numbered menus. It sets the variable
EMPTY rather than passing a default in, because every menu already consumes its
choice as `${CHOICE:-<n>}` — the default lives next to the options it selects
between, which is the right place, and a second copy in the helper could drift from
the one the prompt advertises. Verified all eight consume that way before touching
them. `read <<<''` rather than eval or `declare -g`, which is bash 4.2+ and rules
out the bash 3.2 macOS still ships.

officer-setup's ask_required takes its default too, except where there is none — the
owning account on a machine machine-setup never ran on, where a guess would install
as the wrong user.

STILL ASKS, deliberately: the username; the Tailscale control plane, login server
and auth key; the git identity; and an SSH public key when the account has none.
That last one is a trap I nearly walked into — on a fresh VPS KEY_COUNT==0 forces
ADD_KEY=true with no confirm, and the menu's default is "[1] paste a public key",
which then prompts with no default at all. Auto-answering that menu would hang or
fail, so it is excluded by name. adduser also still asks for a password; that is
the tool, not us.

Two pre-existing bugs fixed on the way: machine-setup's sudo re-exec passed "$@"
after `shift` had emptied it, so --only and --reask stopped existing the moment it
escalated — same bug as officer-setup had. And UNATTENDED/ASSUME_YES are named in
all three sudo lists, because env_reset would otherwise drop the flag at
escalation, which is now the fourth variable lost that way.

Verified: bash -n on five files, --help on all three, and menu_answer + confirm
under the flag showing a menu resolving to its default and a no-default confirm
correctly answering no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 06:46:48 +00:00
pastilhasandClaude Opus 5 4c33ef7206 fix the silent death after installing zsh
Reported from a fresh Hetzner VPS: the run stopped dead right after apt finished
installing zsh, printing nothing at all — just install.sh's "machine setup did not
finish".

install_oh_my_zsh carried a comment saying it "Returns 0 whatever happens". It did
not. Under `set -e` a failing command inside a function aborts the SHELL at that
line when the function is called plainly; `return 0` underneath is never reached.
The command is also `>/dev/null 2>&1`, so the cause was invisible — which is why
the transcript just ends.

`|| true` is what actually makes it non-fatal. The file already uses that idiom
correctly in four other places, so this was a slip rather than a misunderstanding.

set_login_shell had the identical bug on `chsh`, which the same run would have hit
on the very next question. Fixed differently and deliberately: `|| true` there
would let the caller announce a login shell that was never set, so it returns
chsh's real status and the CALLER guards the call — which is also what keeps set -e
out of it. A refusal now reports, names the manual chsh command, and carries on,
because a machine with zsh installed and bash at login still works.

Does not explain WHY oh-my-zsh failed on that host — the output was discarded. It
will now say "oh-my-zsh did not install" and continue, which is enough to see it.

Verified: bash -n on both files, and a reduced case proving broken() exits 1 while
fixed() survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 06:23:45 +00:00
pastilhasandClaude Opus 5 6c13e0d8f6 tell the operator they are still root, once
Neither script ever becomes the user it sets the machine up for — a process cannot
change its own uid, so both run as root and drop privileges per command instead.
Everything Officer owns ends up belonging to that user and every pm2 process runs
as them, but the session you are left holding is root's.

Two things that fixes are invisible until they bite: group membership is fixed at
LOGIN, so the `docker` group just granted is not in the current session, and the
shell configuration was written into their home and is not loaded in root's. Both
present as "the machine is broken" rather than "log in again".

Printed by whichever half runs LAST. The first attempt put it at the end of both,
which says it twice on a full install — and the first time it is wrong, because
officer-setup is about to run and still needs the root session it tells you to
leave. install.sh is the only thing that knows whether anything follows, so it
sets OFFICER_SETUP_FOLLOWS and machine-setup stays quiet.

Also drops "Pre-flight complete. The remaining sections are not built yet." from
the end of officer-setup. All 11 sections exist; that line last made sense when 6
did.

Verified: bash -n on all three, the set -e behaviour of `$RUN_OFFICER && export`
under --machine-only, and the suppression across all five ways in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 06:05:57 +00:00
pastilhasandClaude Opus 5 cbfe376a42 accept the repo URL as an argument
bun setup -- --repo https://github.com/you/platform.git

The default is a private Gitea over SSH, which only authenticates on a machine
whose key it already knows — so a genuinely fresh server could not clone at all
without editing lib/repo.sh or knowing OFFICER_REPO existed.

Added to both entry points. install.sh exports it rather than forwarding an
argument it does not own; officer-setup.sh sets it before lib/repo.sh is sourced,
which reads `${OFFICER_REPO:-<default>}`, so an absent flag still defaults.

Two bugs found doing it, both pre-existing:

  - officer-setup.sh ALREADY had an arg parser, at the top, before the sources. My
    first attempt added a second one further down that was unreachable — every
    argument had already been consumed and `*)` would have exited 2 on --repo.
    Caught because `--help` printed the wrong usage.

  - both scripts re-execute through sudo passing `"$@"`, which the parse loop had
    already emptied with `shift`. So `officer-setup.sh --only build` run as a
    normal user silently became a FULL run the moment it escalated, and
    `install.sh --officer-only` re-ran the machine half. Nothing said so; the flag
    just stopped existing. ORIGINAL_ARGS is captured before the loop now.

`${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}` is the set -u safe form — expanding an
empty array is an error on bash before 4.4, and this runs on whatever the machine
came with.

Verified: bash -n on both, --help/--list/--repo/--repo=/unknown-option on both, the
set -e behaviour of the guarded export, and that args survive the shift loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:54:56 +00:00
pastilhasandClaude Opus 5 64f3de59fb CLAUDE.md caught up on how setup is run
It said `bun setup` runs officer-setup.sh and was "IN PROGRESS, sections 1-6 of 10".
It runs scripts/install.sh, and both halves are finished — machine-setup has 28
sections, officer-setup 11.

That line is probably why the orchestrator got doubted: the one document you would
check to find out how to install says the wrong entry point.

Added what install.sh actually is — an orchestrator that runs the two halves and
nothing else, either half runnable alone, both re-runnable, run it as yourself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:48:33 +00:00
pastilhasandClaude Opus 5 081920c61f drop fastfetch from machine-setup
It stopped a real install. The guard covered the wrong half: a PPA that fails to
ADD is caught and skipped, but one that adds cleanly while carrying no package for
the running codename gets past that and dies on `pkg_install_now fastfetch`.

It was also the only tool in the set with no source but a third-party PPA on Ubuntu
24.04 and older. A neofetch clone is not worth a branch in a script whose whole job
is to survive machines nobody has seen.

Removed from tools_default, the tool_command mapping and its installer. No shell
config invoked it, so nothing is left calling a missing binary.

software-properties-common stays in the core apt list for now, with a note: it
provides add-apt-repository, the fastfetch PPA was its only caller, and Docker
writes its own sources.list.d entry by hand — so it is now dead weight. Left as a
separate decision rather than folded into this one.

Verified: bash -n on all four scripts, and no live reference remains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:46:59 +00:00
pastilhasandClaude Opus 5 fb0286e1a9 put rootless docker back, behind the developer gate
Uncommented at all three sites: the call and import in provisionOsAccount, the
~/.local/dockers bind-mount directory in confineUserTree, and the DOCKER_HOST block in
the member zshrc.

Not restored unconditionally, which is how it was before. It now sits behind the same
Developer check as the Postgres role — the gate that prompted disabling it in the first
place. So rolePermitsDatabase is renamed rolePermitsDevTools: it gates two things now
and a name saying "database" while deciding whether you get containers is the kind of
comment that goes stale silently.

~/.local/dockers is created for EVERY account rather than only Developers. It is two
install calls, and confineUserTree is the function that places the layout, not the one
that knows who is a Developer — so a member promoted later finds it already correct.

Postgres role work is untouched and still in place.

Verified: transpiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:49:32 +00:00
pastilhasandClaude Opus 5 d6f01862fe fix a stack overflow in the wallet copy button
Mine, from the clipboard sweep. format.ts exported copyToClipboard wrapping
navigator.clipboard; the sweep replaced the body call with copyToClipboard(value),
so the function called itself. CopyField.tsx is the caller, so every copy button in
the Wallet was an infinite recursion.

Removed the wrapper rather than repointing it — helpers/clipboard already does more
(execCommand fallback on an insecure origin) and CopyField imports it directly now.

Found by finally running tsgo, in the officerdev-test tree, which has node_modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:33:23 +00:00
pastilhasandClaude Opus 5 bf7e919593 only a Developer gets a postgres role
The gate rootless Docker was under, which I did not know about when the Postgres role
replaced it. It inherits the rule along with the purpose: a member not trusted to run
containers is not thereby trusted to run databases.

rolePermitsDatabase() is the single place that rule is written. Admin is deliberately
NOT included — administering the platform is not developing on it, and they are
separate roles precisely so they can be held separately. Say so if that is wrong.

provisionOsAccount now takes the role. Two call sites: create-user passes what the
owner picked, provision-linux-route reads it from the row — which makes that route
the way a member promoted to Developer gets the database role they did not qualify
for when their account was made.

The half that makes the gate real is in updateUserRoleHandler. Without it the rule
would decide what a Developer gets at creation and never look again, so demoting one
would leave their role, their databases and a working password in their ~/.zshenv —
a permission surviving its own revocation, with the UI then saying something untrue.

Revoke before recording, grant after: the drop runs BEFORE updateUser so a failure
aborts with the role unchanged and the whole thing retryable. Promotion runs after and
is non-fatal, like every other provisioning step.

Demotion keeps their data, same as deletion — databases are reassigned to the platform
role, not dropped — and logs where it went, so it does not look deleted.

KNOWN, not handled: a demoted member keeps a stale ~/.pgpass and ~/.zshenv naming a
role that no longer exists. Harmless (the connection just fails) but untidy, and it
means `cat ~/.zshenv` shows a password that no longer works.

Verified: transpiles, both call sites updated. Still no tsgo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:30:33 +00:00
pastilhasandClaude Opus 5 2ac58e2007 drop the postgres role when a member is decommissioned
It was written and never called — dropPostgresRole had zero call sites, so deleting a
member left their role and databases on the cluster.

That is the uid trap in a different id space, and worse. provisionPostgresRole ADOPTS
an existing role, so a role left behind is inherited whole, with its databases, by the
next member who gets the same username. useradd hands out the lowest free uid by
accident; the owner hands out usernames on purpose, so reuse is likelier here, not
less.

Rewritten to PRESERVE rather than destroy. The first version dropped the databases,
which is inconsistent with severMemberTree three files away — that chowns a member's
files to the service user rather than deleting them, and a database is the same kind
of thing. The owner removing an account has not necessarily asked to destroy the work
in it, and dropping is the one choice that cannot be walked back.

Needs the full idiom, per database, and both halves matter:

  ALTER DATABASE .. OWNER TO         REASSIGN OWNED does not move database ownership
  REASSIGN OWNED BY .. TO ..         moves tables, schemas, functions
  DROP OWNED BY ..                   removes what is left, which after a reassign is
                                     only the GRANTS — without it DROP ROLE still
                                     refuses, an ACL entry is a dependency too

Both statements act only on the database they are connected to, so it is a connection
per database rather than a loop over `db`.

Ordered after the Linux teardown (which can fail and abort, and must not do so after
something irreversible) and before deleteUser (the row is what remembers there is
anything to clean up).

Verified live: a member with a database, a table, a row and a schema. Naive DROP ROLE
refused with "2 objects in database carol_app". After the sequence: role gone, row
intact, table owned by postgres. Then recreated the same username and confirmed she is
REFUSED from the old database — the login trigger holds because ownership moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:27:13 +00:00
pastilhasandClaude Opus 5 9f15de3448 put the postgres credentials somewhere findable
The password now lands in ~/.zshenv as PGHOST/PGPORT/PGUSER/PGPASSWORD, as well as in
~/.pgpass. Asked for on the grounds that it is an easier place to remember, which is a
real requirement — a credential you cannot find is one you will ask about every time.

.zshenv rather than the .zshrc that was asked for, for two reasons, neither about
secrecy:

  - zsh sources .zshrc for INTERACTIVE shells only. Verified: `zsh -c` prints an empty
    PGUSER when it is set there, and the right one from .zshenv. A script, a cron entry
    or an agent turn running psql would silently get nothing.
  - .zshrc is a shared template and seedShellConfig only updates it while it still
    matches byte-for-byte, so members keep their edits. A per-member password in it
    would strand every member on the template they were created with — a silent
    maintenance break rather than a tradeoff.

Both files are still written because they are not redundant: .pgpass is what libpq
reads with no shell involved, so it is the one that works for psycopg, a systemd unit
or a compiled binary. The rotate condition now covers both — either missing means we
cannot reconstruct it from the other, so we regenerate.

Also drops the `head -1 ~/.pgpass` parsing I had put in the zshrc template. It was a
hack, and the values are in the environment before that file is read now anyway.

Verified: zsh sourcing order and both shell types, transpiles. Still no tsgo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:24:05 +00:00
pastilhasandClaude Opus 5 3ca7f5f331 close the cross-member database leak with a login event trigger
The residue the last commit documented is gone. A database one member creates is now
refused to every other member at connection time, so the catalogue metadata never
becomes readable in the first place.

Both obvious routes are dead ends, measured rather than assumed: datacl is not
inherited from the template, and CREATE DATABASE fires no event trigger because it is
a global object. What works is a `login` event trigger (PG17+) installed in template1
— event triggers live in a per-database catalogue and CREATE DATABASE copies the
template's catalogues, so every member-created database carries it automatically. No
naming convention, no sweep, no window.

The first version put the function in `public` and a member defeated it in one
statement:

  DROP FUNCTION public.officer_owner_only() CASCADE;   -- takes the trigger with it

They could not drop or disable the trigger itself, but in PG15+ `public` is owned by
pg_database_owner — which resolves to THEM in their own database — and a schema owner
may drop objects in it they do not own. Both objects were owned by postgres and it
made no difference. Caught because I tried it rather than reasoned about it.

Moved into a platform-owned schema with PUBLIC revoked. Every route then refused:
DROP EVENT TRIGGER, ALTER .. DISABLE, DROP FUNCTION, DROP SCHEMA, ALTER SCHEMA ..
OWNER TO, CREATE OR REPLACE over the top, and PGOPTIONS=-c event_triggers=off (that
GUC is superuser-only). A superuser can still set it, which is the recovery path.

ensureTemplateIsolation opens its own short-lived connection because a connection
cannot change database and CREATE DATABASE requires no other session on the template
— a pooled connection to template1 would make every member's `createdb` fail.

Verified live, end to end: alice in her own, bob refused, alice refused from bob's,
postgres in, owner reads and writes normally, and both members refused CONNECT on
`officer`. Probe roles and databases dropped; template1 keeps the trigger, which is
the intended state.

Still not typechecked — node_modules is empty in this tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:18:31 +00:00
pastilhasandClaude Opus 5 2273941e71 give each member a postgres role instead of a docker daemon
A Postgres login role named the same as their Linux account, with CREATEDB, plus a
~/.pgpass so psql never prompts. This is what replaces rootless Docker: the case it
was really there for was "let me run a database to develop against", and a container
per member answered it with a daemon, an image cache and a subuid range each.

Measured on postgres:18-alpine before writing any of it, because three things I
asserted turned out to be wrong:

  - a fresh LOGIN role CAN connect to `officer` (datacl NULL = PUBLIC has CONNECT),
    but CANNOT read any application table — privileges are owner-only, so
    has_table_privilege('users','UPDATE') is false. The capability model was never
    reachable from here.
  - the `trust` line in pg_hba does not cover host connections: Docker's NAT rewrites
    the source, so they fall through to scram-sha-256. Verified with a wrong password.
  - revoking from the ROLE does nothing. Privileges are additive and there is no DENY;
    only revoking from PUBLIC is a lock.

So ensureAppDatabaseClosed revokes CONNECT+TEMPORARY on the platform's own database
from PUBLIC, and it runs inside provisionPostgresRole rather than in the setup script
— an install set up before today, or restored from a dump, then still cannot end up
with a member who can connect to `officer`.

Password is generated per member, 40 chars, rejection-sampled over an alphanumeric
alphabet: CREATE ROLE is a utility statement and cannot take a bind parameter, so the
safety comes from the alphabet rather than from escaping. Not stored anywhere — it
lives in their 600 ~/.pgpass, the same posture as their SSH key, where we keep only
the public half. Only (re)set when .pgpass is missing, so a reprovision does not
rotate a credential they may have pasted into an app config.

KNOWN RESIDUE, not handled: a database one member creates is metadata-readable by
another. datacl is not inherited from the template (measured: closing template1 and
creating from it still produced NULL), and CREATE DATABASE fires no event trigger, so
nothing can close it at creation. A second member can read table and column NAMES from
the catalogue. They cannot read a row and cannot create anything. Closing it needs a
sweep or a pg_hba rule per member; both are decisions, not details.

Verified live against the running cluster: role creation, refusal on `officer`,
creating and using two databases, and the DROP ... WITH (FORCE) teardown. All probe
roles and databases dropped afterwards.

NOT verified: bunx tsgo, still — node_modules is empty in this tree. The drizzle
return shape was checked by reading PostgresJsQueryResultHKT (RowList<T[]>, extends
Array) rather than by running it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:14:02 +00:00
pastilhasandClaude Opus 5 41cdd1e63a stop creating the container bind-mount directory too
~/.local/dockers existed only so a rootless container's inner uid could traverse to a
bind source. No daemon, no need. Commented out with its reasoning intact in
confineUserTree.

.local itself stays — it is not Docker's. The claude installer targets ~/.local/bin,
and the comment above it records that directory being created root-owned and blocking
the install.

Nothing to change in deprovisionOsAccount: it never called os-user-docker.ts. Its
only Docker-shaped part is capturing /etc/subuid before userdel, which already treats
a missing entry as normal and is worth keeping for any rootless tooling.

Verified: transpiles. No test referenced composeDir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:58:06 +00:00
pastilhasandClaude Opus 5 7b32f5bc2f don't provision rootless docker for new members
Commented out at the call site in provisionOsAccount, with the import. The code in
os-user-docker.ts stays and is now unreferenced — turning it back on is uncommenting
two blocks.

Only affects NEW accounts. Members provisioned before this keep their daemon, and
deprovisionOsAccount still tears one down, which is what those accounts need.

Verified: transpiles. tsgo has still not run in this tree (node_modules is empty).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:56:16 +00:00
pastilhasandClaude Opus 5 cc1eab7794 docs: a triage map of the documentation
42 documents, 13,000 lines, and no way to tell from a filename which describe the
system as it is and which record an afternoon in July. This sorts them: living,
stale, historical, and two clusters that want consolidating.

Says plainly how much was verified — mostly filenames, status lines and greps for
what changed today — so it reads as a starting point rather than a verdict.

Names the two obvious consolidations without performing them. Nine opencode
documents for one migration that has landed (verified: `opencode serve` is in the
sidecar, so the plan's "nothing here is implemented" is false), and three
mobile-dav documents that are one correspondence. Both need all of them read
first, which is not a 4am job.

Marks the historical ones as not-to-be-rewritten. claude-sidecar-isolation.md
records the officer-claude to officer-agent rename that preceded tonight's rename
to officer-claude-code; editing it to match today's code would destroy the
reasoning it exists to hold.

And notes what most of them share: they were written when the estate was twenty
processes and everything was simply present. A core install is six. The fix is
usually one line — say whether the thing is core or a plugin — not a rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:26:48 +00:00
pastilhasandClaude Opus 5 8826c2847e docs: working-on-officer catches up to the code
It described four capability kinds and said terminal, chat and files can never be
granted. There are five, and those three moved to `confined` on 2026-08-11 — the
kernel enforces the boundary because the account has its own Linux user, and a
grant means nothing without one.

The layout diagram was missing dockers/ and secrets/, and implied the paths are
configured. They are derived from the working directory, which is why the pm2 cwd
pin and assertInstallLayout exist.

Adds what is switched off as of tonight: six core processes, every plugin router
commented out beside its capability claim, the ecosystem files now generated, and
.env down to three values with the keys in the secret store.

First of a documentation sweep. 42 docs; this one first because it is the
operational guide somebody actually reaches for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:26:07 +00:00
pastilhasandClaude Opus 5 236a3a5481 docs: first container test pass, and what it found
Ubuntu 24.04, Debian 12, Arch and Fedora 41. OS and package-manager detection
correct on all four; --help works unprivileged; and the install report is written
end to end in a container that had never seen this code — task 1's mechanism
confirmed off the machine it was written on.

Three real findings.

--only does not isolate a step. Running --only "Core utils" still created a user
account, because ask_username and the account creation sit in the preamble above
the step framework, so everything before the first `step` runs every time. It is
defensible and it is not what the flag appears to promise.

.setup-answers travels with a copy of the tree. Correctly gitignored and 0600,
but it lives inside the repository directory, so `cp -r` carries it — a container
that had never run setup came up already knowing the username and created that
account. Nothing secret in it; it is a surprise, which in an installer is the
expensive kind.

adduser leaks its own interactive prompt ("Try again? [y/N]") on the
account-creation path. Harmless here because the run had already stopped, but a
hang on a real unattended install.

Also records what containers cannot reach: no init means systemd, netplan, ufw
and the sshd drop-ins are only verifiable as "wrote the right file"; Docker and
Postgres are untested; macOS is unreachable entirely and everything about it is
reasoned rather than executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 04:00:31 +00:00
pastilhasandClaude Opus 5 d7d64cd6d6 docs: the install-variant tree, for tomorrow's decision
Enumerates the forty-seven prompts the two scripts actually ask and sorts them
into branches, consents and values — the distinction that decides what a
generated leaf can remove. Four real branches (OS, role, tailnet state, which
half), fourteen consents that let a leaf omit a section entirely, and a set of
values that must stay prompts because baking them in would mean publishing
somebody's hostname.

Names the two things that need deciding rather than deciding them:

Whether a leaf strips dead code or sets constants and calls the base. They are
different artifacts and the plan rests on which one is meant — the first is what
makes it auditable by being short, the second is what keeps it maintainable.

And the combinatorics: 4 OS x 3 roles x 3 tailnet states is 36 leaves before
consents, so the tree cannot be the full product. Publishing a few opinionated
leaves keeps the static-file-anyone-can-diff property; generating on demand does
not, which is the property per-leaf scripts existed for.

Also notes that --unattended and a generated leaf are the same mechanism seen
twice, and that install_config's existing behaviour — keep the user's file when
there is no tty — is the conservatism every unattended answer needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:58:32 +00:00
pastilhasandClaude Opus 5 cd209483e3 fix the clipboard over http, and audit the rest
navigator.clipboard is secure-context only, like crypto.randomUUID before it —
over plain http on a tailnet address the object does not exist. Twenty call
sites across eighteen files, in three states that all looked fine in review:
bare calls that threw and killed the handler, optional-chained calls that
silently did nothing, and one carrying the comment "Officer is always behind
HTTPS", which it is not.

The optional-chained ones are the worst of the three: a copy button that reports
success and copies nothing is indistinguishable from a working one until someone
pastes.

helpers/clipboard.ts falls back to document.execCommand('copy') over an
off-screen textarea — deprecated, and it works on any origin because it predates
the secure-context rule. Off-screen rather than hidden, because display:none and
visibility:hidden elements cannot be selected and the copy fails silently.

Reading the clipboard has no equivalent: execCommand('paste') was never permitted
from script. The file browser's paste-a-file path now checks canReadClipboard()
and explains itself instead of throwing.

docs/http-secure-context-audit.md is the full sweep the owner asked for: what was
fixed, what cannot be, and what was checked and found clear. crypto.subtle is
used nowhere in the frontend, which was the one worth confirming since it has no
cheap fallback. Notification's six matches are type names, not the API.
geolocation and navigator.share are already guarded. getUserMedia is in four
files and is being removed — but QrTransfer uses it for the CAMERA, not a
microphone, so "remove audio" does not cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:56:57 +00:00
pastilhasandClaude Opus 5 77f1284925 install report: first cut, generated by the helpers
Every run writes a timestamped install-report.md recording what was installed,
changed, kept, skipped, started and run as root. Written for an adversarial read:
the person who just ran a setup script off the internet hands it to an agent of
their choosing and asks whether it did anything it should not have.

Recorded by the HELPERS rather than by the sections. pkg_install and
install_config report themselves, so anything installed or written through them
appears whether or not a section author remembered — a section that has to
remember is a section that will forget, and an incomplete report is worse than
none because it reads as a full account.

"Kept" is recorded as carefully as "changed". Leaving somebody's .zshrc alone is
the claim a reviewer most wants substantiated, and it is invisible unless stated.

Secrets are redacted at the moment of recording rather than filtered at render,
so a credential never sits in memory formatted for printing. Verified against a
POSTGRES_URL and an api_key/password pair.

REPORT_FILE is passed through the sudo re-exec. It was not, first time, and the
report silently vanished — the third variable this evening lost to env_reset.

Unfinished on purpose, paused mid-task at the owner's request: machine-setup's 26
sections still only report through the two shared helpers, so the sections that
change system state directly — systemd units, netplan, ufw, sshd drop-ins — are
not yet recorded. That is the half a reviewer would care most about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:33:48 +00:00
pastilhasandClaude Opus 5 74b4c7908a fix the crash over http: crypto.randomUUID is secure-context only
Chat took the whole page down at the end of every turn with
`TypeError: crypto.randomUUID is not a function`.

`crypto.randomUUID()` is SECURE-CONTEXT ONLY — over plain http on anything that
is not localhost it is not defined at all. Officer is reached at
http://officer-dev:9000, which is neither, so all eighteen call sites in the
frontend were throwing. The stack shows why it was fatal rather than merely
broken: it was called inside a `useState` initialiser, so the throw happened
during render and unmounted the tree. The assistant message that has no id yet
is created at the end of a turn, which is exactly when it fired.

No TLS needed. `crypto.getRandomValues()` carries no such restriction — it is on
`Crypto`, not `SubtleCrypto`, and works in an insecure context. helpers/random-id
uses randomUUID when it exists and otherwise assembles a v4 from the same CSPRNG:
same 122 bits, same version and variant bits. Verified both paths produce a UUID
matching the v4 pattern, including with randomUUID deleted.

`crypto.subtle` is not used anywhere in the frontend, so randomUUID was the whole
of the problem. Audio recording is a different matter — getUserMedia genuinely
requires a secure context and cannot be polyfilled.

Nine files, eighteen call sites. The vendored hls.mjs is left alone.

Two of my own mistakes on the way, both caught by parsing rather than by reading:
the rewrite added an import of the helper TO the helper, and inserted another one
inside a multi-line import block — the same trap as the officerdb move earlier
tonight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:23:31 +00:00
pastilhasandClaude Opus 5 f1cfc0042f rename officer-agent to officer-claude-code
The old name said nothing about what the process runs, and it sits directly
beside officer-anthropic-proxy — a different process doing a different job — so
"the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to
spend a paragraph insisting the two are not the same thing. It spawns `claude`;
the name says so now.

Only two references were functional: the generator's CORE_PROCESSES and the CORE
list in catalogue.test.ts. Everything else was prose or comments.

Left alone deliberately: `x-officer-agent-token`. It looks like the same string
and is not — it is the agent-handoff HTTP header, naming a per-panel bearer
token, unrelated to any pm2 process. Renaming it would have changed a wire
protocol to tidy a label.

Historical docs keep the old name. claude-sidecar-isolation.md and
open-threads-after-per-user-claude.md are dated investigations that record the
PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would
make that history unreadable. CLAUDE.md notes the change instead, where somebody
reading those will be looking.

Also worth recording, from the owner: merging this with officer-anthropic-proxy
into one sidecar was investigated tonight and rejected. They stay separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:10:39 +00:00
pastilhasandClaude Opus 5 88c9e96895 recover earlier answers on resume
A skipped section leaves its variables unset and later sections read them, so on
a resume — which skips every section before the one that stopped — Build
announced "PUBLIC_URL <not set — run the Environment section first>" on a machine
whose .env had been written twenty minutes earlier.

Three variables cross a section boundary: ENV_PORT and ENV_PUBLIC_URL from
Environment, POSTGRES_URL from Database. They are read back once near the top,
from the file that already holds the answers, rather than per-section — the next
variable to cross would otherwise have to remember to do it again.

Only fills what is empty, so a value passed on the command line still wins and a
section that actually runs still overwrites it.

Build had its own late read-back that made the generation work while the screen
said it would not. Removed, now that the value is there before anything prints.

Verified against the real install at /home/pastilhas/officerdev-test: --only Build
now reports the URL that run chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:08:18 +00:00
pastilhasandClaude Opus 5 e5fe966308 count the schema tables instead of printing "?"
The Schema section read ${SCHEMA_TABLES:-?} and nothing ever assigned it, so it
announced "? tables" — which reads as "the count could not be determined" rather
than "nobody set this". schema_table_count existed in lib/build.sh and was never
called.

Counted from schema.ts rather than hardcoded, so the number stays true when a
plugin line is uncommented. Reports 21 against the current tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:06:04 +00:00
pastilhasandClaude Opus 5 60ab531294 use the short tailnet name, not the FQDN
http://officer-dev:9000 rather than http://officer-dev.ts.pastilhas.dev:9000.
All three forms resolve inside the tailnet — short name, FQDN, raw 100.x — and
the short one is what anybody actually types. PUBLIC_URL is read by people too:
gen:index bakes it into the page's OpenGraph tags.

It depends on the tailnet's search domain, which every Tailscale client sets when
MagicDNS is on. A device that has lost it resolves the FQDN instead, and the
answer there is to type the longer one rather than to default everybody to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:05:04 +00:00
pastilhasandClaude Opus 5 f33e7474b0 default PUBLIC_URL to the tailnet address, not localhost
localhost is wrong on a machine with a tailnet, and quietly so: it works from the
machine itself and nowhere else, so the mistake surfaces on the first phone
rather than during setup. And PUBLIC_URL is not decoration — gen:index bakes it
into the page's OpenGraph tags, the task API hands it to scripts as
OFFICER_API_HOST, and the CalDAV profile builder refuses without it.

The tailnet is where Officer is actually reached, and it is the perimeter the
whole security model rests on now that origin checking is gone. Its address is
the honest default.

Prefers the MagicDNS name over the raw 100.x address — both work, but the name
survives a node being re-registered and is something a person can type. Falls
back to localhost with no tailnet, which is right rather than merely tolerable:
a machine with no private network has no better address to guess.

Suggests http://officer-dev.ts.pastilhas.dev:9000 on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:03:45 +00:00
pastilhasandClaude Opus 5 bedc420d4d clone over SSH while the repository is private
An HTTPS clone of a private repo prompts for a username, and under sudo with no
interactive terminal that hangs or dies with "could not read Username" — which is
what gitea.pastilhas.dev does right now.

ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git instead, temporarily.
Back to HTTPS when it is public; nothing else in the script cares which.

Tested the path the script actually takes, not just the URL: the clone runs as
the OWNER rather than root, and sudo drops SSH_AUTH_SOCK, so there is no agent to
answer a passphrase. `sudo -u pastilhas env -u SSH_AUTH_SOCK git ls-remote`
returns HEAD, so the key works unaided on this machine. A passphrase-protected
key that relies on an agent would not.

Still overridable with OFFICER_REPO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:56:55 +00:00
pastilhasandClaude Opus 5 9b56e04b5e point the clone at gitea.pastilhas.dev
gitea.officer.dev is not serving yet. Overridable with OFFICER_REPO, as it always
was, so a fork or a mirror needs no edit here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:55:42 +00:00
pastilhasandClaude Opus 5 ea87ffed04 ask for privileges, do not demand them
Run any of the three as yourself. On Linux they now ask through sudo and
re-execute, rather than refusing until you type it. Typing `sudo` still works and
changes nothing — it just stops being the price of starting.

This also fixes a trap that had nothing to do with taste. `sudo` strips the
environment by default (`env_reset`), so `OFFICER_ROOT=/somewhere sudo ./install.sh`
silently loses the variable and installs to the default path instead. The
re-exec passes OFFICER_ROOT, SETUP_USERNAME and MACHINE_ROLE to sudo BY NAME
rather than relying on -E, which env_reset ignores. This project has already lost
a variable to that once — see the DATA_PATH commit.

The invoking account is recovered the way it always was: sudo sets SUDO_USER,
which lib/base.sh already reads, including the check for a SUDO_USER that is
itself uid 0 on providers whose default account is root under an ordinary name.

macOS never escalates, in any of the three. Homebrew refuses to run as root, the
account running the script IS the owner, and the sections that needed root are
the ones the macOS path skips.

--help and argument errors still work with no privileges at all, since arguments
are parsed before any of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:54:10 +00:00
pastilhasandClaude Opus 5 3cca07187e scripts/install.sh — one command for both halves
`bun setup` runs it. Machine setup first, then officer setup, stopping if the
first does not finish rather than running the second against a machine that is
not ready.

They stay two scripts because they answer two different questions and are worth
running apart — a machine you already trust needs only the second, one you are
rebuilding needs only the first. --machine-only and --officer-only say so
directly, and both halves remain runnable by path.

Privileges are checked here, before anything is done, because the two systems
want opposite things: Linux needs root for apt, systemd, useradd, netplan and ufw
and for creating directories owned by the service account; macOS must NOT be
root, since Homebrew refuses to run as one. Each script already enforces its own
rule, so this is only about failing early instead of halfway.

officer-setup.sh gained the same OS-aware check. It required root unconditionally,
which on macOS would have failed immediately after machine-setup — which must run
as the user — and for no reason: there the account running it IS the owner, so
there is nothing to chown and nothing to drop privileges to.

Arguments are parsed before privileges, so --help works without sudo and an
unknown option is rejected before anybody is asked for a password. It did not,
first time round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:49:44 +00:00
pastilhasandClaude Opus 5 b5aa2e0387 keep the shell templates together in scripts/setup
starship.toml, tmux.conf and zshrc side by side. tmux.conf and zshrc moved up out
of machine-setup/.

starship.toml could not have moved down to join them: the PLATFORM reads it, at
src/servers/os-user-shell.ts:34, to deploy to every member's Linux account. That
is a runtime path rather than an import, so moving it would have broken member
provisioning silently — no build error, members simply get no starship config.
So the templates collect where the shared one already had to be.

Left as a marker for tomorrow rather than resolved: there are now TWO zshrc
templates, this one for the owner and shell-skel/zshrc for members, while
starship.toml is deliberately one file for both. Either the owner needs different
shell config from a member or they should be the same file. tmux.conf has the
same question waiting, since it is going into provisioning too. Noted in the
zshrc header where whoever picks it up will be looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:47:08 +00:00
pastilhasandClaude Opus 5 0da15d78a1 add an empty zshrc template for the owner
Somewhere to put what the owner actually wants, to be filled in and wired up
tomorrow. Not referenced by the Shell section yet.

The header records the decision that has to be made when it is: the section does
not install a .zshrc today, it appends four marker-wrapped blocks — starship,
agent, aliases, editor — through append_once. A template that is installed AND
appended to ends up with the same lines twice, so those blocks either move into
this file or stay out of it, not both.

zshrc, no leading dot: a template in a repository, not a dotfile in a home
directory, matching tmux.conf beside it and src/servers/shell-skel/zshrc which
has been spelled that way all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:45:08 +00:00
pastilhasandClaude Opus 5 e2faad40a3 rename the tmux template to tmux.conf, no leading dot
It is a template in the repository, not a dotfile in a home directory — and the
destination it is increasingly installed to, ~/.config/tmux/tmux.conf, has no dot
either. Naming the source after a path it may not be written to is how the wrong
file gets read.

The two remaining dotted references are correct and stay: they name the
DESTINATION ~/.tmux.conf, which does have a dot when that is where tmux looks.

.setup-answers and .setup-progress keep theirs too. They are runtime state in a
directory, not templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:44:07 +00:00
pastilhasandClaude Opus 5 61a3ae720f install the tmux config where tmux actually reads it
tmux 3.1 added an XDG location and it takes PRECEDENCE over ~/.tmux.conf.
Verified on 3.4 here by writing a different marker into each and asking tmux
which one it ended up with:

  both present       -> ~/.config/tmux/tmux.conf
  only ~/.tmux.conf  -> ~/.tmux.conf
  only the XDG one   -> the XDG one

So the Shell section writing ~/.tmux.conf on a machine that already has the XDG
file produced a file tmux will never read, and reported "tmux config installed"
having changed nothing anybody could observe. That is the worst shape a config
step can have: it looks done.

tmux_config_target now picks the path tmux will actually load — the existing XDG
file if there is one, otherwise ~/.tmux.conf, which is still what every guide
names and what a machine with neither should get. When both exist the section
says so out loud before targeting the winner, because "your other file wins" is
not something anyone infers from a success message.

Also removed an untracked duplicate at scripts/setup/.tmux.conf. The one the
script installs is scripts/setup/machine-setup/.tmux.conf — SCRIPT_DIR is the
machine-setup directory — and two identical copies with only one of them read is
the drift this whole evening has been about.

Nothing to change about the config itself: the tracked copy is already byte-for-
byte the owner's own ~/.tmux.conf.

Not yet wired into per-user provisioning. src/servers/shell-skel/ seeds a zshrc
for a member and has no tmux config beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:43:08 +00:00
pastilhasandClaude Opus 5 085afe7604 wait for Postgres instead of failing startup work once
The failure here was not a crash — it was the opposite, and that is why it would
never have been noticed.

server.tsx fired initQueue() and cleanupOnStartup() as bare promises with a
.catch() that logged. postgres-js connects lazily, so nothing fails at import;
the first query does. If Postgres is a few seconds behind — exactly what a reboot
looks like, with pm2's resurrect racing Docker starting the container — both log
one line during boot and do nothing else.

cleanupOnStartup is the one that matters. It marks jobs interrupted by the
previous shutdown and promotes the queued backlog, so failing it once leaves
those jobs marked running forever: nothing retries, nothing complains again, and
the only thing that would have corrected them has already run.

officerdb now exports waitForDatabase(timeoutMs = 60s): polls `select 1`, logs
once while waiting, resolves true or false rather than throwing. Bounded on
purpose — an unbounded wait holds a process open with no way to tell starting
from hung, and the caller decides what giving up means.

Deliberately NOT awaited before serve(). The listener is already up by that point
and holding it closed would turn a database thirty seconds late into a reverse
proxy answering connection-refused instead of a page. Requests needing the
database fail honestly in the meantime.

The rest of the estate was already fine, which is worth recording so nobody
"fixes" it again: postgres() opens no socket at construction, officer-agent's
resolveOwner is an unbounded 5s retry loop written after this exact failure cost
a session, and opencode, pty, headscale and the anthropic proxy touch no database
at boot at all.

officer-setup's Services section also waits for pg_isready before starting pm2.
Not because starting early breaks anything, but because Verify would then report
a failure that is really a race — and a red line that is usually noise is a red
line people stop reading.

Verified waitForDatabase against a dead port: logged once, returned false after
the timeout, did not throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:38:36 +00:00
pastilhasandClaude Opus 5 62cbf510cb no ecosystem files in git; officer-setup generates one
Deleted all four — ecosystem.config.cjs, .light., .mac.light. and the
.profile. they derived from. The repository now contains no ecosystem file at
all, and .gitignore keeps it that way.

officer-setup writes one at the end, describing exactly the six processes a core
install runs: officer, officer-anthropic-proxy, officer-agent, officer-opencode,
officer-pty and officer-headscale. No profiles, no derivation, no plugins.

The four existed because a profile has to subtract from something, so the full
list had to name every plugin's process whether or not anybody installed it —
and a test then had to assert the two files still agreed. Generating one file
removes the subtraction, the second list and the test that policed them.

It is .cjs, not the .js PM2's docs use, and that is not a preference:
package.json declares "type": "module", so a .js file here is ESM and
`module.exports` throws. PM2 require()s the config.

Sections 10 (Services) and 11 (Verify) are built on top of it — write,
startOrRestart, save, optional boot hook, then check every process is online with
a sane restart count AND that the API actually answers on PORT. A process can be
`online` and serving nothing, so the port is asked directly rather than inferred.
That completes all eleven sections.

catalogue.test.ts required both deleted files at import, so it could not even
load. Its central assertion — "the store offers exactly what light leaves out" —
has no meaning without a full list to subtract from, which is the point of the
change. Replaced by two weaker but real checks: the store must not offer a core
process, and every process it names must have a sidecar directory to run. The
second catches the same typo the old one did without needing a manifest of
everything; verified it holds for all 15 catalogue entries.

app-store/pm2.ts starts a sidecar with `--only` against this file, which now
holds core alone — so it can stop a plugin but cannot start one that was never
written in. Marked `[open]` there rather than left to be discovered: appending a
plugin's entry is the plugin system's job.

Generated one in a scratch directory and required it with node: six apps, correct
cwd on each, valid CommonJS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:35:12 +00:00
pastilhasandClaude Opus 5 b075f1f882 macOS is a dev machine, and machine-setup now treats it as one
Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the
homelab or VPS case, so the role is not asked for there — it is `dev`, and every
section that exists to make a machine a good server is skipped.

Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh
with a reason each, rather than an `if macos` threaded through each section. Most
would simply fail — no systemd, no ufw, no netplan, no useradd, no
/etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse:
stopping a laptop from sleeping, or freezing the address of a machine that moves
between networks daily.

Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker,
Neovim, JavaScript runtimes, Agent CLIs.

The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to
run as root and says so, so the whole script under sudo would have failed at the
first brew install having already taken a password. It is now required on Linux
and refused on macOS, which works precisely because the macOS path skips
everything that needed it.

Docker is checked, not installed. Docker Desktop is a GUI app that wants opening,
permissions and a running window — not a shell script's business — and colima and
lima both cost an evening the first time something does not resolve. So the step
reports whether the daemon answers and points at the download otherwise. The
group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM
owned by whoever is logged in, so there is no group to join.

Added the Xcode command line tools as a macOS-only step, before anything that
builds. node-pty ships no prebuilt binary on any platform and always falls
through to node-gyp, so `bun install` cannot finish without a compiler — and it
fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select
--install` opens a dialogue and returns immediately, so the step says to come
back rather than pretending to have waited.

Tailscale takes the cask, not install.sh — that script is a Linux package-manager
wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and
does not.

Not run on a Mac. There isn't one here, so this is read from the code and from
what each tool documents, not observed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:29:56 +00:00
pastilhasandClaude Opus 5 7280d13b93 group the barrel by core and plugin
Same 23 features, same order within each group, split by a blank line and a
heading. It mirrors schema.ts, where the plugin tables are commented out.

The difference between the two files is written down rather than left to be
inferred: these lines are NOT commented, because doing so would break nothing at
runtime — hono.ts mounts no plugin router, so none of it is reached — but tsgo
checks every file under src/ whether it runs or not, and the plugin sidecars
still import these symbols. They stay until each plugin is extracted.

No export changed. Verified: same set of re-exported features before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:20:10 +00:00
pastilhasandClaude Opus 5 fbb6d6c78c the agent sidecar crashed on boot without the email plugin
user-instance.ts read `getEmailAccounts` at module level, unguarded, in a
top-level await. Commenting the email schema out earlier tonight means
`email_accounts` does not exist on a core install, so that query throws, the
rejection escapes, and officer-agent exits — into the PM2 restart loop, taking
chat with it. Chat is core; the agent sidecar is what spawns `claude`.

The same file's header documents this exact failure being fixed once already, for
`resolveOwner`: "A query that THROWS — Postgres restarting, or not up yet —
escaped this function, rejected the top-level await, and exited the process into
exactly the PM2 restart loop the comment below says it exists to avoid." That cost
a session on 2026-08-10. This line has the identical shape and was never covered,
because until tonight the table always existed.

Guarded now: no accounts, a warning, and the email_db tool sits idle. An agent
must start without email — it is one tool, not a prerequisite for chat.

Checked the rest while there: the only other top-level awaits in the core
processes are resolveOwner (already hardened), sign (now backed by the secret
store, which creates on demand) and assertSecretsClosed (throws on purpose).

The three vault calls in core auth — signout, revoke, panic — are
`clearVaultTokens(...).catch(() => {})`, fire-and-forget, so a missing table is
swallowed. They are fine as they stand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:16:34 +00:00
pastilhasandClaude Opus 5 3cb39662b5 desktop is a plugin too
Its sidecar, officer-vnc, was already excluded from the light profile, so calling
it core was only the capability kind saying `execution` — which is about who may
reach it, not whether a light install runs it.

Unmounted the same way as the other twelve: `/desktop`, its capability's api and
ws claims, the `desktop` websocket handler and its upgrade route. Implementation
untouched.

Also reverts a mistake from the previous commit. I had commented entries out of
WSData's `provider` union and left `upgradeWs`'s parameter type listing them,
which would have been a type error the moment either was used — and one I cannot
see here, since node_modules is empty and tsgo does not run. Those unions describe
possible values rather than what is served, and neither is a registration. Only
registrations are commented now, which is what was asked for in the first place.

Totality simulated again: 33 live mounts, 5 websockets, zero problems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:14:52 +00:00
pastilhasandClaude Opus 5 ffca309a77 switch off every plugin router, pending extraction
Twelve capabilities unmounted: gitea, music, photos, jellyfin, memos, calendar,
email, notify, transmission, soulseek, invoices, wallet. Mounts commented in
place, implementation untouched on disk, same as vault and the browser relay.

Each needed its capability's `api` claim commented in the same change.
assertCapabilityTotality check 2 refuses to boot on a capability claiming a
prefix nothing mounts, so unmounting alone would have stopped the server
starting — the opposite of what happened with vault, which is exempt.

music also claims two websockets. Its `ws: ['cliamp', 'cliamp-audio']` claim, the
two handlers in server.tsx and the provider union entries all had to move
together: check 3 fails on a served socket nothing claims, check 4 on a claimed
socket nothing serves.

calendar was the awkward one and the mount list was not where it lived. Four of
its six doors are not in the routes table:

  honoServer.route('/dav', davSyncRouter)     the sync door, top-level, not /api
  honoServer.all('/.well-known/caldav')       RFC 6764 autodiscovery
  honoServer.all('/.well-known/carddav')      the same for contacts
  four routes in server.tsx                   /dav, /dav/*, and both well-knowns

and the two that ARE in the table carry trailing comments, which is why the first
pass silently missed them and left /api/caldav and /api/dav mounted with their
claim gone — exactly the boot failure this commit is about.

/vpn deliberately stays. It reads as a plugin (kind 'app') but it is OffTail
enrollment forwarding to officer-headscale, which is core because the tailnet is
the perimeter.

Verified by simulating assertCapabilityTotality against the post-change files
rather than trusting the edits: parse hono's live mounts, the registry's live
claims, server.tsx's live ws providers and totality's two exempt lists, then run
all four checks. It reported the two stale caldav mounts, which is how they were
found. Zero problems now, 34 live mounts, all core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:13:16 +00:00
pastilhasandClaude Opus 5 1eecc400a7 switch off Vaultwarden's routers, pending extraction into a plugin
Same treatment as the browser relay: mounts commented, code left on disk. Its
tables were already commented out of the schema earlier tonight, which is what
made this necessary — /api/vault was mounted against tables db:push no longer
creates, so a fresh core install shipped an endpoint that could only fail with a
Postgres "relation does not exist".

Vaultwarden is not one mount. Eight places had to go, and grepping for `vault`
found them only because several are not named after a router:

  hono.ts   /api/vault                      the authenticated reverse-proxy
            /vaultwarden                    the unauthenticated one for the browser extension
            VAULT_ONLY_PREFIXES loop        /identity, /notifications, /icons, /events
            the isBitwardenClient diverter  an /api/* middleware that hands Bitwarden
                                            clients to the vault router before anything else sees them
            ./api/vault/sidecar-server      a SIDE-EFFECT import capturing the sidecar's port
            UNPROTECTED_API_PREFIXES        the '/vault' entry
  server.tsx  the 'vault' ws provider, its handler, and the notifications upgrade route

The side-effect import is the one worth naming: it registers a sidecar listener
and appears in no route table, so nothing about unmounting the routers would have
stopped it running.

No capability registry change, unlike browser and task-logs. Vaultwarden is
exempt from totality on both halves — EXEMPT_API_PREFIXES has '/vault'
("Bitwarden protocol clients authenticate to Vaultwarden, not to Officer") and
EXEMPT_WS_PROVIDERS has 'vault'. So nothing claims it and nothing breaks by
unmounting it. I said the opposite before checking; the check is what settled it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:05:54 +00:00
pastilhasandClaude Opus 5 595dd082a7 delete Task Logs
A complete read path over a table nothing could write to. task-logger.ts exported
createTaskLog, appendToLog and finalizeLog, and none of the three was called
anywhere in the tree — so `task_logs` could never gain a row, and the screen was
permanently empty for everyone.

The read half was fully wired: mounted router, capability claim, dock icon, two
routes and a page-title rule. That is why it looked alive.

Gone, in the order it was reached:

  Screens/Dashboard/TaskLogs/       the screen
  App.tsx                           /task-logs and /task-logs/:id
  Dashboard/index.tsx               the export
  Layout/Dock.tsx                   the 'Logs' icon, and ScrollText with it
  state/usePageTitle.ts             the title rule
  api/task-logs/task-logs.ts        the router, and its mount in hono.ts
  api/task-logger.ts                101 lines of orphaned writer
  officer_db/src/operations/        the directory
  officer_db/src/schema.ts          the export line
  officer_db/src/types.ts           TaskLogSelect / TaskLogInsert

capabilities/registry.ts loses '/task-logs' from the `tasks` capability's `api`
AND `routes`. The api half is not optional: assertCapabilityTotality check 2
refuses to boot on a capability claiming a prefix nothing mounts, so unmounting
the router while leaving the claim would have stopped the server starting.

db:push now creates 21 tables, down from 43 at the start of the evening.

officer_db/src/operations was one of the two lopsided directories the
schema/queries merge exposed. integrations/ is the remaining one, and it is
legitimate — it spans server and user-data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:56:07 +00:00
pastilhasandClaude Opus 5 f9cd0a798a drop two dead tables: queue_jobs and terminal_containers
Neither had a reader or a writer anywhere in the tree. db:push created them on
every fresh install and nothing ever touched them.

queue_jobs is from when the background job engine kept its state in Postgres; it
works on files now (src/servers/queue/storage.ts). terminal_containers held a
docker id and port per user, from the architecture where every account ran in its
own container — gone, as data-path.ts already records.

Their types went with them: QueueJobSelect/Insert and TerminalContainerSelect/
Insert were unreferenced too. db:push now creates 22 tables.

operations/schema.ts keeps task_logs and a note about what left and why, along
with the thing still wrong there: it has no queries.ts, because task_logs is
reached as `schema.taskLogs` from src/servers/api/task-logger.ts, past this
package's boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:49:20 +00:00
pastilhasandClaude Opus 5 288b4bf683 each feature declares its own exports; the barrel is one line per feature
src/index.ts is 47 lines and reads as a list: `export * from './agent-panels';`
and 22 more, under `db` and `schema`. It was 297 lines of hand-written named
exports.

What a feature exports now lives in <feature>/index.ts, beside the schema and
queries it describes. Adding a query function is one file in one directory rather
than that file plus a list three levels up that nothing enforces — a function
missing from that list was invisible to all 107 consumers while existing and
compiling perfectly.

The old file had drifted in the ways a hand-maintained list does: twelve features
were listed twice because values and types were separate statements repeating the
path, soulseek three times, two features used an inline `type` specifier instead,
and `db` and `schema` — the package's most fundamental exports — sat at line 270
with notify, types and app-store appended after them.

The surface is byte-for-byte the same set. Checked rather than asserted: 247
exported names before, 247 after, no missing and no extra. The per-feature index
files carry the same named lists the barrel did, so `export *` widens nothing.

`operations` is deliberately not in the list, and the root file says why: it has a
schema and no queries, its task_logs is reached as `schema.taskLogs` from
src/servers past this package's boundary, and its other two tables are read by
nothing at all.

Verified: every file in the package parses, every relative import resolves to a
real file, and all 107 consumers still parse. Not typechecked — empty
node_modules, frozen installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:45:31 +00:00
pastilhasandClaude Opus 5 68f2c55ecf one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.

The parallel trees had drifted, which is what the restructure is really fixing:

  four features were named differently on each side — app-store/sidecar-installs,
  email/email-accounts, server/server-config

  operations had a schema and NO query file: its task_logs is reached directly
  from src/servers/api/task-logger.ts, bypassing this package's own boundary

  integrations had queries and NO schema, because it spans two features'
  tables — server_integrations and user_integrations

Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.

Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.

schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.

Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.

One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.

Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:34:04 +00:00
pastilhasandClaude Opus 5 4cebae1c85 schema barrel: core tables only, plugin tables commented
db:push now creates 24 tables instead of 43. The 19 belonging to sidecars a
light install does not run are commented out in schema/index.ts, kept as the
record of what each table is called and which file defines it.

Core is ecosystem.light plus officer-headscale, plus the app store and
service_connections — app-store/effects.ts reads the latter, so it is core
however few plugins exist. Commented: email, music, notify, dav, photos,
jellyfin, invoiceshelf, soulseek, vault, wallet.

The reason this is only a barrel edit is worth writing down. drizzle.config.ts
points `schema` at schema/index.ts, so that file is drizzle-kit's view of the
schema — but it is NOT the runtime's. Every query imports its table object from
a schema file and calls db.select().from(table), and nothing anywhere uses
drizzle's relational API (db.query.X), which is the only thing the `schema`
passed to drizzle() in db.ts is for. So a commented line removes a table from the
database without removing a line of code.

That only held after moving nine query modules off the barrel and onto their own
schema file — email-accounts, music, notify, photos, jellyfin, invoiceshelf,
soulseek, vault and wallet all imported their tables from '../schema', so
commenting the barrel would have broken the query module, then its export, then
its consumers. dav already did it the right way. With that done the cascade is
gone: everything still compiles, and the tables simply are not created.

Two corrections to what was asked, both checked rather than assumed. The query
barrel (src/index.ts) has no bearing on db:push — drizzle never reads it — so
commenting it would not have kept a single table out of the database. And vault
is not plugin-only from the platform's side: /api/vault is mounted top-level in
hono.ts for the Bitwarden client, and api/vault/router.ts imports getVaultTokens.
Its TABLES are out, but that route still exists and will fail against them.

Not typechecked — empty node_modules, frozen installs — and db:push was not run,
since there is no database here. Every file in the package parses, as does
everything in the tree importing officerdb, and nothing outside the package
reached a plugin table through the exported schema namespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:24:26 +00:00
pastilhasandClaude Opus 5 9d6c196848 PUBLIC_URL comes back, and gen:index takes it as an argument
Removing PUBLIC_URL earlier today was wrong, and the Build section is where it
would have surfaced: gen-index.ts exits 1 without it, so `bun gen:index` fails,
index.gen.html is never written, and `bun start` has no page to serve.

It came out because origin validation was being discontinued — but that was one
of four consumers and the only one that is gone. gen-index needs it for
OpenGraph tags, which crawlers fetch standalone and cannot resolve relative;
task-api-env builds OFFICER_API_HOST from it; and dav/router hard-requires it,
https only, to build an iOS profile.

It is also the one value this machine genuinely cannot derive, which is what
separates it from DATA_PATH and the rest that left today.

gen:index now takes a URL as its first argument, ahead of the environment and
.env: `bun gen:index https://officer.example.com`. Changing the public address is
one command rather than an edit plus a regenerate, and a second address can be
generated for without touching the install's .env.

It also validates now. A relative or scheme-less value substituted silently and
produced OpenGraph tags nothing can resolve — invisible until someone shares a
link and the preview comes back blank.

.env is PORT, PUBLIC_URL, POSTGRES_URL. Verified by running the section; all
three paths through gen:index exercised (absent, valid argument, invalid).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:07:33 +00:00
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
pastilhasandClaude Opus 5 1ccb21e67f CLAUDE.md: five capability kinds, not four
The file described a stricter model than the code enforces. It said terminal,
chat, files, tasks, desktop and browser are all `kind: 'execution'` and therefore
never shareable — but terminal, chat and files moved to `confined` on 2026-08-11
with per-user Linux accounts, and are grantable.

The distinction matters and is now written down: a confined grant means nothing
without a Linux user. authorize.ts:97 drops it for an account whose `osUser` is
null, so "granted but unconfined" resolves to no access rather than to the
owner's home — which is what it would otherwise resolve to, since
getOwnerHomeDir ignores the email it is passed. Verified in the code, not
inferred from the comment.

Also brings the Data section in line with today: DATA_PATH, OFFICER_ITEMS_DIR and
HOME_DIR are no longer environment variables, the install root is derived from
cwd, and assertInstallLayout is why a wrong cwd fails instead of relocating the
install. And `bun setup` runs officer-setup.sh, which is sections 1-6 of 10 —
worth saying, since the entry read as though it were finished.

Registry needs real work for where this is going. This is only the docs catching
up to what is there now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:43:16 +00:00
pastilhasandClaude Opus 5 9864fb1a49 switch off the browser relay, pending extraction into a plugin
BROWSER_RELAY_PORT is gone and the second listener no longer starts. The Chrome
extension, api/browser/ and the /browser screen all stay on disk — this is going
to be extracted, and deleting it means writing it again.

Three things had to move together, and the middle one would have failed the boot
on its own:

  server.tsx    the listener, commented out with the variable name recorded
  hono.ts       the /api/browser mount, closed
  registry.ts   the 'browser' capability's claim on /browser, dropped

assertCapabilityTotality checks both directions: check 2 refuses to start on a
capability claiming a prefix nothing serves. Unmounting the router alone would
have left the registry describing it, and the server would not have come up.

The capability itself survives because it also claims /scrape, which shares
nothing with the relay — it launches its own headless chromium through playwright
and never speaks to the extension.

The comment in server.tsx carries the two facts that are not recoverable by
reading the remaining code. First, the port is an INPUT TO A CREDENTIAL:
relay-auth.ts derives each extension's token as HMAC(JWT_SECRET,
'officer-browser-relay-v1:${port}:${userId}:${salt}'), so bringing the relay back
on a different number silently invalidates every paired browser — reported by the
extension as "Relay not reachable", which SETUP.md blames on a wrong address,
port or token. Second, it cannot come back as a kernel-assigned port:0 like the
other sidecars: the extension is configured by hand and stores the value, so a
port that moves each restart breaks the pairing each restart.

Left alone deliberately: the /browser route in App.tsx, its Dock entry, and the
Settings → Browser Relay panel. They will not work against a closed endpoint.
Removing them is frontend work for the extraction, not part of switching the
listener off.

.env is down to PORT and POSTGRES_URL.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes two variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:32:29 +00:00
182 changed files with 5051 additions and 1627 deletions
+17 -19
View File
@@ -1,29 +1,27 @@
# What officer-setup writes. Everything below this block is optional, or is on its way out.
PORT=9000
BROWSER_RELAY_PORT=18792
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
# ── Moving to the secret store ─────────────────────────────────────────────────────────────────
# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without
# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key
# store (docs/secret-store.md), which is designed and not yet built, so an install made by the
# current script will not boot until it is. That is deliberate sequencing, not an oversight.
JWT_SECRET="<generate with: openssl rand -base64 32>"
# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key,
# encrypting 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.
# Where Officer is reached from a browser — the one value the machine cannot derive. Read by
# `bun gen:index` (OpenGraph tags, which need an absolute URL), the task API host, and the CalDAV iOS
# profile builder, which additionally requires https.
#
# 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.
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
PUBLIC_URL=http://localhost:9000
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
# 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.
#
# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2
# processes, so a key here is readable from /proc/<pid>/environ of twenty processes that mostly have
# no business with it — officer-music held the key that decrypts wallet seed envelopes.
#
# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres
# unreadable, and for the wallet seed that is unrecoverable.
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
# Where Officer is reached from a browser. Read by origin validation, the task API host check, and
# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https.
# PUBLIC_URL=https://officer.example.com
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
+5
View File
@@ -58,3 +58,8 @@ public/plugins/
# Written by officer-setup.sh; per-machine.
scripts/setup/officer-setup/.setup-progress
# Generated by officer-setup, describing THIS install's processes. Never committed:
# the repository has no ecosystem file at all any more, and the next machine
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
ecosystem.config.cjs
+56 -23
View File
@@ -16,13 +16,23 @@ written: `users` holds six rows. The accurate statement is narrower and more use
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Terminal, chat, tasks, files, desktop and browser
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's
home, so there is no level of "read" that makes them safe. They have no level at all and the grants
API refuses to store one.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the *owner's*, because the account has its own Linux user, its own
home, and the kernel refusing everything above it.
So "which user is this" now has a real answer for the **app** surface (gitea, music, photos, email,
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
HTTP routes, the websocket doors and the dock together.
So "which user is this" has a real answer for the **app** surface (gitea, music, photos, email,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`.
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
@@ -42,18 +52,20 @@ One Bun process (`src/server.tsx`) serves everything:
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
- a browser relay on its own port (`BROWSER_RELAY_PORT`, default 18792)
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth.
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
@@ -112,16 +124,27 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
Two stores, and the split matters:
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in
`src/queries/`, types inferred from the schema in `src/types.ts`.
email accounts, queue and pipeline jobs. One directory per feature holding `schema.ts` and
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under
`DATA_PATH`) versus `getOwnerHomeDir` (the owner's real login home when `HOME_DIR` is set, which is
where terminals, chats and task runs actually execute).
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the
per-account email SQLite stores — those are the **email sidecar's**, and nothing in the platform opens
them.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `capabilities/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
failing.
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
passed).
### Schema changes use `push`, not migrations
@@ -163,11 +186,13 @@ valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`.
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
(owner only, and `execution` is never grantable at any level).
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). 27 entries as of 2026-08-13.
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them.
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
- `capabilities/totality.ts``assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
@@ -194,9 +219,17 @@ bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
bun setup # runs scripts/install.sh — blank machine to running platform
```
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
needs to, and on macOS never does, because Homebrew refuses to run as root.
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
See `docs/working-on-officer.md` for which process a given change needs restarted.
+84
View File
@@ -0,0 +1,84 @@
# The documentation, triaged
**2026-08-13.** A map of what is in here, what it is for, and what should happen to it. Made because
there are 42 documents and 13,000 lines, and no way to tell from the filenames which describe the
system as it is and which are a record of an afternoon in July.
**How much I verified:** the classifications below are from filenames, status lines, and greps for
things that changed on 2026-08-13. Where I actually read the document or checked the code, it says
so. The rest is a starting point for a conversation, not a verdict.
---
## Living — these describe the system and must stay true
| doc | state |
| --- | --- |
| `working-on-officer.md` | **updated 2026-08-13.** Operational guide. |
| `secret-store.md` | **updated 2026-08-13.** Built; rotation still open. |
| `install-variants.md` | new. The branch tree, for discussion. |
| `http-secure-context-audit.md` | new. What breaks over plain http. |
| `install-container-testing.md` | new. First container pass and its findings. |
| `per-user-linux-accounts.md` | partly updated. `OFFICER_OS_USERS` is gone; check the rest. |
| `navigation-audit.md` | authoritative on routing. Unverified against tonight's route removals. |
| `workspace-panels.md` + `workspace-panel-todo.md` | the panel framework. 1,300 lines combined — likely the biggest cleanup here. |
| `agent-coordination.md` | the north star for panel work. |
| `deprovision-os-account.md` | implemented; the `'disabled'` stage it may mention was deleted tonight. |
## Stale — describe things that changed on 2026-08-13
Each of these references something that no longer exists. **Not yet corrected.**
- `sidecar-topology.md` — "ecosystem.config.cjs is the source of truth". It is generated now, and
holds six processes.
- `sidecar-app-store.md` — derives the catalogue from `full light`. Those files are gone, and
`catalogue.test.ts` was rewritten.
- `sidecar-bootstrapping.md` — "20 PM2 entries, 18 sidecar dirs". Six entries now.
- `mobile-api-keys.md` — partly corrected; recheck the origin-checking claims.
- `wallet-key-custody.md``VAULT_STORE_KEY` is now the per-purpose `wallet` key.
- `push-notifications.md` — "agreed design, 2026-07-31". Notify is a plugin and unmounted.
- `chat-session-lifetime.md`, `chat-ui-walkthrough.md` — reference `officer-agent`, renamed.
## Historical — a record of a moment, and should stay one
Do **not** rewrite these to match today's code. They document how a decision was reached, and
editing them destroys the reasoning. If they mislead, add a dated header pointing forward.
- `sidecar-audit-2026-07.md` (1,377 lines)
- `claude-sidecar-isolation.md` — records the `officer-claude``officer-agent` rename that
preceded tonight's `officer-agent``officer-claude-code`
- `open-threads-after-per-user-claude.md`
- `two-agent-field-report-2026-08-12.md`
- `api-method-changes-2026-08-06.md`
## The opencode cluster — nine documents for one migration
`opencode-fork-decision` · `-parity` · `-api-2-assessment` · `-phase0-review` · `-phase1-report` ·
`-phase1-review` · `-serve-migration-plan` · `-serve-path` · `-testing-checklist`
**The migration landed**`opencode serve` is in the sidecar, verified. So
`opencode-serve-migration-plan.md` saying "Nothing here is implemented" is false.
This is the clearest consolidation candidate in the whole directory: one document recording what was
decided and what shipped, replacing nine that describe stages of getting there. I did not do it
because it needs reading all nine, and deleting documents unread is not a thing to do at 4am.
## The mobile-dav thread — three documents, one conversation
`mobile-dav-provisioning` · `-feedback` · `-reply`. A correspondence. Almost certainly one document.
## Unclassified — I have not looked
`design-language-interface` · `file-sync` · `jobs-unification` · `mobile-photo-sync-api` ·
`nextcloud-replacement` · `agent-git-identity`
---
## The plugin split, which affects most of the above
A core install is six processes. **Everything else is a plugin**, switched off tonight but present on
disk. Most documents here were written when the estate was twenty processes and every one of them was
simply "there", so they describe availability that no longer holds.
The useful rewrite is usually one line, not a rewrite: say whether the thing described is **core** or
**a plugin**, and if a plugin, that it is not mounted on a fresh install.
+84
View File
@@ -0,0 +1,84 @@
# What breaks over plain http
**Audited 2026-08-13**, after `crypto.randomUUID` took the chat page down at the end of every turn.
Officer is reached at `http://officer-dev:9000` — a tailnet address, so **neither https nor
localhost**, and therefore not a [secure context]. A set of browser APIs are unavailable there by
specification, not by policy, and there is no flag that changes it.
The failure mode is what makes this worth a document. Two of the three shapes below are silent:
| shape | what a user sees |
| --- | --- |
| `crypto.randomUUID()` | `TypeError` — and if it is inside a `useState` initialiser, the whole tree unmounts |
| `navigator.clipboard.writeText()` | `TypeError`, killing the click handler |
| `navigator.clipboard?.writeText()` | **nothing at all** — the button reports success and copies nothing |
The optional-chained one is the worst: indistinguishable from working until somebody pastes.
---
## Fixed
### `crypto.randomUUID` — 18 call sites
Secure-context only. `crypto.getRandomValues` is **not** — it lives on `Crypto` rather than
`SubtleCrypto` — so `helpers/random-id.ts` builds the same v4 UUID from the same CSPRNG when
`randomUUID` is absent. Same entropy, same version and variant bits.
### `navigator.clipboard.writeText` — 20 call sites across 18 files
Secure-context only. `helpers/clipboard.ts` falls back to `document.execCommand('copy')` over an
off-screen textarea, which predates the secure-context rule and works on any origin. Deprecated and
working beats modern and absent.
One call site carried the comment *"Officer is always behind HTTPS"*. It was not.
---
## Cannot be fixed this way
### `navigator.clipboard.read()` — pasting a file in the file browser
No fallback exists. `document.execCommand('paste')` was never permitted from script, so on an
insecure origin there is no way to pull clipboard contents on demand — only a real paste event the
user initiates, which is a different interaction. Now guarded by `canReadClipboard()` and refuses
with an explanation instead of throwing.
### `getUserMedia` — audio recording, 4 files
`apps/Chat/useAudioRecording.ts`, `apps/FileBrowser/.../DictateDialog.tsx`,
`apps/QrTransfer/Receiver.tsx`, and a test. Requires a secure context and cannot be polyfilled — the
browser will not hand out a microphone or camera over http.
**Being removed** rather than guarded: the owner uses an external dictation app. Note `QrTransfer`
uses it for the CAMERA rather than a microphone, so removing "audio" does not cover it — that one
needs its own decision.
### `navigator.credentials` — passkeys
WebAuthn is secure-context only. `helpers/passkeys.ts` exists and cannot work over http, whatever is
done to it. Not currently reachable, so nothing is broken today.
---
## Checked and clear
- **`crypto.subtle`** — not used anywhere in the frontend. This was the one worth confirming, since
it would have had no cheap fallback.
- **`Notification`** — the six matches are type names, not the browser API. Nothing calls
`new Notification` or `requestPermission`.
- **Service workers, WebUSB, WebSerial, WebBluetooth, Payment Request, Wake Lock, Storage Manager,
`SharedArrayBuffer`** — not used.
- **`navigator.geolocation`** (`widgets/Weather`) — secure-context only, but already guarded with
`if (!navigator.geolocation) return;`, so it degrades rather than throws. The widget simply cannot
locate you over http.
- **`navigator.share`** (`Headscale/InvitesView`) — already guarded with a `typeof` check, and its
comment notes it is absent on desktop browsers anyway.
- **WebSockets, IndexedDB, localStorage, EventSource** — no secure-context restriction. Chat,
terminal and the sidecar transports are unaffected.
---
## The alternative
All of this disappears with a certificate, and `tailscale cert` issues a real one for the MagicDNS
name in about one command — no public DNS, no port 80 challenge, no renewal to remember. Worth
knowing that the choice here was "make it work over http", not "http is the only option".
[secure context]: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
+74
View File
@@ -0,0 +1,74 @@
# Testing the installer in containers
**2026-08-13.** First pass. Ubuntu 24.04, Debian 12, Arch, Fedora 41.
## What passed
**OS and package-manager detection is correct on all four.**
| image | `OS` | `PM` |
| --- | --- | --- |
| ubuntu:24.04 | `ubuntu` | `apt` |
| debian:12 | `debian` | `apt` |
| archlinux | `arch` | `pacman` |
| fedora:41 | `fedora` | `dnf` |
**`--help` and argument handling work unprivileged** in a clean container, before any escalation.
**The install report is written**, end to end, in a container that had never seen this code. That is
task 1's mechanism confirmed outside the machine it was written on.
**Refusing beats hanging.** With no answer available the run stopped with
`FAIL: No answer. Set ASSUME_YES=1 to run without prompts.` rather than blocking forever on a prompt
nobody could see. That is the behaviour an unattended run needs, and it already exists.
---
## What it found
### 1. `--only` does not isolate a step
Running `--only "Core utils"` still **created a user account**, because `ask_username` and the
account creation happen in the preamble, above the step framework. Everything before the first
`step` call runs on every invocation.
Defensible — every step needs to know who it is installing for — but it means `--only` is not the
surgical tool it appears to be, and a first-time reader will assume it is. Either the preamble
becomes lazy, or `--only` says plainly what it will still do.
### 2. `.setup-answers` travels with a copy of the tree
It lives at `scripts/setup/machine-setup/.setup-answers`, is correctly gitignored, and is `0600`
root-owned. But it is **inside the repository directory**, so `cp -r` or a tarball of the tree
carries it — which is exactly what happened here: a container that had never run setup came up
already knowing the username `pastilhas` and created that account.
Not a leak (username and install path, nothing secret). It is a surprise, and surprises in an
installer are the expensive kind. Worth moving outside the repo, next to the progress file.
### 3. `adduser` leaks its own prompts
```
Use of uninitialized value $answer in pattern match (m//) at /usr/sbin/adduser line 848.
Try again? [y/N]
```
The account-creation path reaches an interactive `adduser` question the script does not answer.
Harmless here because the run stopped anyway, but on a real unattended install this is a hang.
---
## Coverage this cannot reach
Containers have no init by default, so **`systemctl`, netplan, ufw and the sshd drop-ins were not
exercised**. Those sections can only be verified as "wrote the right file", not "the service came
up". Running privileged containers with systemd would close most of that gap and is the obvious next
step.
**Docker-in-Docker** was not attempted, so the Docker section and Postgres provisioning are
untested. Mounting the host socket would test the section's logic while telling us nothing about the
install path.
**macOS is untestable here entirely.** The 17 skipped sections, the Homebrew paths, the Xcode
command line tools step and the refusal-to-run-as-root are all reasoned from documentation and
unverified by execution.
+99
View File
@@ -0,0 +1,99 @@
# The install page, and the scripts behind it
**Status: for discussion, 2026-08-13.** Nothing here is built. It exists so tomorrow's conversation
is about real branches rather than sketched ones — every question below is one the scripts already
ask today.
## The shape agreed
- One **source** — the interactive scripts as they are.
- A **build script** that compiles them into single files, because `curl | bash` cannot fetch libs.
- The build emits **one script per leaf** of the question tree, not one script with pre-seeded
answers. A person auditing before running reads only their own path.
- Verification is of the **generator**, once: anyone regenerates the leaves from source and diffs
them against what is published. One thing to trust rather than N.
---
## The questions that actually exist
Forty-seven prompts across the two scripts. Almost none of them should become a branch — the
distinction that matters is:
**A branch** changes which *code* runs. Removing it makes a script genuinely shorter.
**A value** changes a *string*. Removing it makes a script no shorter — it just moves the answer
from a prompt to a constant.
**A consent** is a yes/no about doing a step at all. These are the interesting middle: pre-answering
one lets the build delete the section entirely.
### Branches — these change what code exists
| question | answers | what it eliminates |
| --- | --- | --- |
| operating system | macOS · Debian/Ubuntu · Arch · Fedora | 17 of 26 machine-setup sections on macOS; the whole `case $PM` ladder collapses to one arm |
| machine role | homelab · vps · dev | swap, ballast, earlyoom, sleep/suspend, boot-hang, static addressing — each is role-gated today |
| tailnet | already connected · set one up · none | the entire Tailscale section, its four sub-options and the offscale explanation |
| which half | machine + officer · officer only · machine only | one of the two scripts disappears |
### Consents — pre-answering deletes a section
Docker · fail2ban · unattended-upgrades · Neovim · agent CLIs · shell config · firewall · SSH
hardening · DNS · swap · ballast · earlyoom · inotify · boot-on-start.
Fourteen sections that a leaf script can simply not contain.
### Values — never a branch
Username · install path · git name and email · port · public URL · Postgres connection · timezone ·
locale · LAN CIDR · swap size · swappiness.
These stay as prompts even in a generated script, or arrive as environment variables. Baking them
into a published file would mean publishing somebody's hostname.
---
## Where this collides with `--unattended`
`--unattended` and a generated leaf are the *same mechanism seen twice*: both are "answer these in
advance". The difference is only whether the answer is baked in at build time or supplied at run
time.
Worth deciding tomorrow whether a leaf script is literally `base.sh --unattended` with a header of
constants, or whether the build truly strips the dead branches. The second is what makes it
auditable-by-being-short; the first is what makes it maintainable. **They are not the same artifact,
and the whole plan rests on which one we mean.**
One thing that already exists and should be preserved either way: with no tty, `install_config`
keeps the user's file rather than replacing it. Every unattended answer needs to be conservative in
that same way, and that is a property of each prompt, not of the flag.
---
## The combinatorics
4 OS × 3 roles × 3 tailnet states = **36 leaves** before any consent is considered, and consents
multiply it past anything anyone would publish.
So the tree the install page walks cannot be the full product. Two ways out, to choose between:
1. **Publish a few opinionated leaves** — "Ubuntu VPS, new tailnet", "macOS dev machine", "Ubuntu
homelab, existing tailnet" — and send everything else to the full interactive script.
2. **Generate on demand** — the page composes the leaf when the questions are answered. Stronger, but
the artifact is no longer a static file anyone can diff against the repo, which costs the
verification property the whole design was for.
My inclination is (1), because (2) quietly trades away the thing that made per-leaf scripts worth
building. But it is a real trade and it is yours.
---
## Open, for tomorrow
- Does a leaf strip dead code, or set constants and call the base?
- How many leaves get published, and what happens to the rest?
- Does the install page show the script before running it? It should — that is the moment auditing
is cheap and nobody will do it afterwards.
- The report from `install-report.md` names a script commit. A generated leaf needs to name the
source commit it was generated from, or the report cannot be checked against anything.
+1 -1
View File
@@ -327,4 +327,4 @@ service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
WebSocket upgrade in `server.tsx`, and the vault socket.
- `src/servers/api/api-keys/router.ts` — the three endpoints.
- `src/databases/officer_db/src/schema/api-keys.ts` — the table, and why it stores what it stores.
- `src/databases/officer_db/src/api-keys/schema.ts` — the table, and why it stores what it stores.
+43 -17
View File
@@ -1,11 +1,19 @@
# The secret store
**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the
current code was checked against the tree on that date; the file:line references are live.
**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and
`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built —
the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts
yet.
A small SQLite database, created during setup, holding every encryption and signing key the platform
uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses
instead of inventing its own.
A small SQLite database holding every encryption and signing key the platform uses. It replaced
`VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses instead of inventing
its own.
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original
plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`,
`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and
`headscale` — and every other purpose is created when its plugin first asks.
---
@@ -69,7 +77,18 @@ both to still exist. That is a table with `id, purpose, key, created_at, retired
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
### 2. It is NOT encrypted at rest, for now
### 2. It is NOT encrypted at rest — and that decision changed shape
**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking
them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the
other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user.
That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all.
The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher
finding is still true and still the reason whole-file encryption is not on the table.
#### The original note
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
@@ -94,25 +113,30 @@ trade and it is written down here so nobody later assumes the file is opaque.
### 3. Where the file goes
**`$OFFICER_ROOT/secrets/officer-keys.db`** — a sibling of `platform/` and `data/`, decided 2026-08-13.
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
problem this design exists to avoid.
`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere
documented loudly enough that a backup script excludes it deliberately.
The setup script says so out loud when it creates the store, because "back this up, but not next to the
other thing you back up" is not a rule anyone infers.
### 4. One secret remains outside
### 4. ~~One secret remains outside~~ — none does
The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the
whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the
two processes that need it.**
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly
one value — or comes from a file read on demand.
The point of the exercise still holds, and it was always about blast radius rather than secrecy: **N
secrets in twenty process environments becomes a file read on demand by the few processes that need
it.** `.env` is auto-loaded by bun into every pm2 process, so a key there is readable from
`/proc/<pid>/environ` of twenty processes — `officer-music` held the key that decrypts wallet seed
envelopes. A file opened by the two or three processes that actually use a key does not.
### 5. What moves in
- `VAULT_STORE_KEY`the at-rest key for everything in the table above.
- `VAULT_STORE_KEY`**split into one key per purpose**, rather than moved. See the status note at the
top: the table above is seven unrelated things, and one key for all of them meant one leak opened all
of them.
- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must
survive restarts, must never be regenerated silently, and benefits from versioning during a rotation.
Leaving one in a store and one in `.env` would be the scattering this is meant to end.
@@ -208,8 +232,10 @@ wallet table and it cannot be interrupted safely, which argues for something tha
## What this does not change
- Secrets stay in Postgres. This moves the **keys**, not the data.
- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from
changes, so no caller is touched.
- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named
at the call site, so it is `encryptSecret('headscale', plaintext)` now and all seven query modules
were touched. That was the cost of the split, and it is worth stating plainly because this line
originally promised the opposite.
- The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent
secrets is the property that makes a stolen `.env` insufficient, and it survives this design.
+1 -1
View File
@@ -5,7 +5,7 @@ does and does not protect against.
Authoritative for the crypto design. The code is `src/servers/sidecar/wallet/keys.ts` (sealing,
derivation, unlock sessions), `src/databases/officer_db/src/crypto.ts` (storage encryption) and
`src/databases/officer_db/src/queries/wallet.ts` (where the two meet).
`src/databases/officer_db/src/wallet/queries.ts` (where the two meet).
## The requirement
+35 -9
View File
@@ -7,27 +7,53 @@ agent sessions start.
Three directories sit there, and knowing which one a change belongs in is most of the job:
```
officer/
$OFFICER_ROOT/
├── platform/ the application — a git repo
├── capabilities/ what the agent can do — a separate git repo
── data/ runtime state — NOT version controlled
── data/ runtime state — NOT version controlled
├── dockers/ containers the app store provisioned
└── secrets/ the key store — 0600, and NOT in your data backup
```
None of those paths is configured. `src/servers/data-path.ts` derives the root as
`resolve(process.cwd(), '..')` and hangs the rest off it, which is why the pm2 `cwd` pin matters and
why `assertInstallLayout` refuses to boot from the wrong directory.
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
granted, because they run as the owner's OS user in the owner's home.
So "which user" has three answers depending on the surface. For the **app** capabilities (gitea,
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
For **execution** — tasks, items, desktop, browser — it is still always the owner, and those can
never be granted at any level.
That is five kinds, not four: `core`, `app`, `confined`, `execution`, `admin`. Terminal, chat and
files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts.
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
and five non-owner accounts are live; treat the capability registry as the source of truth over any
prose, here or elsewhere.
## What is switched off (2026-08-13)
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
plugin, and every plugin router is commented out in `hono.ts` with its capability's `api` claim
commented beside it — they must move together or `assertCapabilityTotality` refuses to boot.
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
extraction into the plugin system.
Also gone: the four ecosystem files (generated now, at setup, and gitignored), origin validation,
`OFFICER_OS_USERS` (per-user Linux accounts are unconditional), and the Task Logs feature.
`.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in
`$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`.
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
above them: where things live, how to change them safely, and the things that are true of the running
system but written down nowhere else.
@@ -67,13 +93,13 @@ Commit messages: simple lowercase, no prefixes, explaining *why*.
## Running and checking your work
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-agent`,
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
`pm2 list` shows them; `pm2 logs officer` follows.
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential
and proxies API traffic; `officer-agent` is the process that actually runs `claude`.**
and proxies API traffic; `officer-claude-code` is the process that actually runs `claude`.**
**Which process to restart.** A change under `src/servers/sidecar/<name>/` needs that sidecar restarted;
a change anywhere else needs `officer`. Both, if you changed the wire between them. Restarting `officer`
+3 -3
View File
@@ -135,7 +135,7 @@ and the rename sequence leaves `workspaces` with no zombie.
- [x] **`ws-terminals-{id}: null` on a live dashboard is a 500.** Same file, `:61-66` — the
`ws-layout-*` branch has a `value === null``deleteDashboard` case (`:42`); the terminals
branches do not. A null falls to the UPDATE branch and sets a `NOT NULL` column
(`databases/officer_db/src/queries/dashboards.ts:70`) → 23502.
(`databases/officer_db/src/dashboards/queries.ts:70`) → 23502.
**Resolved.** A null on either terminals branch is now a no-op: it means "forget this key", and it
only ever arrives paired with `ws-layout-{id}: null` on a rename, by which point the row is gone.
@@ -202,7 +202,7 @@ these.
> which uuid ids would not.
- [ ] **`dashboards.id` is a global primary key but ids are `slugify(name)`.**
`databases/officer_db/src/schema/dashboards.ts` declares `id: text('id').primaryKey()`. Live:
`databases/officer_db/src/dashboards/schema.ts` declares `id: text('id').primaryKey()`. Live:
`"dashboards_pkey" PRIMARY KEY, btree (id)` plus a redundant
`"uq_dashboards_user_id" UNIQUE, btree (user_id, id)` — evidence per-user ids were intended and
half-built. Ids come from `DashboardPreview.tsx:300` (`slugify(trimmed) || generateSlug()`) and the
@@ -213,7 +213,7 @@ these.
(see `databases/CLAUDE.md` → "Composite keys") — harmless churn, but read the plan.
- [x] **`upsertDashboard`'s UPDATE has no `userId` predicate.**
`databases/officer_db/src/queries/dashboards.ts:73` —
`databases/officer_db/src/dashboards/queries.ts:73` —
`db.update(dashboards).set(set).where(eq(dashboards.id, id))`. The `existing` lookup above it _is_
scoped, so it cannot reach another user's row today, but it is a non-transactional read-then-write.
**It becomes a live cross-user overwrite the moment the PK above is made composite.**
-157
View File
@@ -1,157 +0,0 @@
module.exports = {
apps: [
{
name: 'officer',
script: 'bun',
args: 'start',
watch: false,
},
// The Anthropic credential proxy. Despite the old name (`officer-claude`) this process does NOT
// run agents — it holds the proxy secret and forwards to api.anthropic.com. The process that runs
// agents is `officer-agent` below.
{
name: 'officer-anthropic-proxy',
script: 'bun',
args: 'run src/servers/sidecar/claude/index.ts',
watch: false,
},
// The process that actually runs `claude`. It used to be spawned on demand by the main server,
// which made every agent session a grandchild of `officer` and killed it on every restart. As a PM2
// peer it survives them. It resolves the owner from the database and the proxy secret from the
// proxy's state file, so it needs nothing from `officer` in order to start.
{
name: 'officer-agent',
script: 'bun',
args: 'run src/servers/sidecar/claude/user-instance.ts',
watch: false,
},
{
name: 'officer-opencode',
script: 'bun',
args: 'run src/servers/sidecar/opencode/index.ts',
watch: false,
},
{
name: 'officer-email',
script: 'bun',
args: 'run src/servers/sidecar/email/index.ts',
watch: false,
},
// The only sidecar run by `node` rather than `bun`, and the only one that is not TypeScript: node-pty
// is a native addon. It also does not use sidecar/connect.ts, and carries its own copy of the
// reconnect loop.
{
name: 'officer-pty',
script: 'node',
args: 'src/servers/sidecar/pty/index.mjs',
watch: false,
},
{
name: 'officer-vnc',
script: 'bun',
args: 'run src/servers/sidecar/vnc/index.ts',
watch: false,
},
{
name: 'officer-music',
script: 'bun',
args: 'run src/servers/sidecar/music/index.ts',
watch: false,
},
{
name: 'officer-vault',
script: 'bun',
args: 'run src/servers/sidecar/vault/index.ts',
watch: false,
},
{
name: 'officer-slskd',
script: 'bun',
args: 'run src/servers/sidecar/slskd/index.ts',
watch: false,
},
{
name: 'officer-headscale',
script: 'bun',
args: 'run src/servers/sidecar/headscale/index.ts',
watch: false,
},
{
name: 'officer-transmission',
script: 'bun',
args: 'run src/servers/sidecar/transmission/index.ts',
watch: false,
},
// The books. Wraps a self-hosted InvoiceShelf. Instances, their Sanctum tokens and the company each one
// is pinned to are set by the owner from /invoices/settings and stored encrypted in
// `invoiceshelf_accounts` — read here, never from the environment, because Bun auto-loads `.env` into
// every process in this directory and `officer` would hold the token too.
{
name: 'officer-invoiceshelf',
script: 'bun',
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
watch: false,
},
// Video. Wraps a self-hosted Jellyfin. Servers, and the access token each one is signed in with, are set
// by the owner from /jellyfin and stored encrypted in `jellyfin_servers` — read here, never from the
// environment. Video only: Officer's own player owns audio.
{
name: 'officer-jellyfin',
script: 'bun',
args: 'run src/servers/sidecar/jellyfin/index.ts',
watch: false,
},
// Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the
// owner from the UI and stored in `service_connections` — read here, never from the environment.
{
name: 'officer-memos',
script: 'bun',
args: 'run src/servers/sidecar/memos/index.ts',
watch: false,
},
// Code hosting. Wraps a self-hosted Gitea. The instance URL and its personal access token are set by
// the owner from /gitea and stored in `service_connections` — read here, never from the environment.
{
name: 'officer-gitea',
script: 'bun',
args: 'run src/servers/sidecar/gitea/index.ts',
watch: false,
},
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
// the platform authenticates and forwards. See docs/nextcloud-replacement.md.
{
name: 'officer-caldav',
script: 'bun',
args: 'run src/servers/sidecar/caldav/index.ts',
watch: false,
},
// The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from
// /photos/settings and stored encrypted in `photos_config` — read here, never from the environment,
// because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too.
{
name: 'officer-photos',
script: 'bun',
args: 'run src/servers/sidecar/photos/index.ts',
watch: false,
},
// The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so
// it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks.
// The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the
// Discord webhook, behind one interface. A sidecar rather than platform code because the producers
// are spread across sidecars, and a platform-owned notifier would make every one of them call back in.
{
name: 'officer-notify',
script: 'bun',
args: 'run src/servers/sidecar/notify/index.ts',
watch: false,
},
{
name: 'officer-wallet',
script: 'bun',
args: 'run src/servers/sidecar/wallet/index.ts',
watch: false,
},
],
};
@@ -1,55 +0,0 @@
// Linux light profile — the platform without the self-hosted estate around it.
//
// For a machine that should run the file browser, the terminal and Claude/opencode chat, and nothing
// else. Paired with `OFFICER_PROFILE=light bash scripts/setup/setup.sh`, which installs only what these
// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
//
// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
// for the two checks that make a drifted profile fail loudly instead of silently starting less than it
// claims. To change what runs, edit INCLUDE. To change HOW something runs, edit ecosystem.config.cjs
// and every profile follows.
//
// The app itself is unchanged: every API route stays mounted, so features whose sidecars are absent
// report themselves unavailable rather than disappearing. A profile decides which processes start, not
// which code ships.
//
// Start with: pm2 startOrRestart ecosystem.light.config.cjs
const { defineProfile } = require('./ecosystem.profile.cjs');
module.exports = defineProfile({
file: 'ecosystem.light.config.cjs',
include: [
'officer', // the app: SPA, /api, websockets
'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream
'officer-agent', // spawns `claude` — chat is dead without it
'officer-opencode', // the alternative agent
'officer-pty', // the terminal
],
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
// A light install simply is not running the thing behind it.
excluded: {
// Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs
// nothing installed locally. True, and beside the point: a baseline process appears in the Permissions
// screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea
// access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which
// makes "is Gitea here" one question with one answer instead of two that disagree.
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
'officer-vnc': 'no desktop to mirror on a light install',
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
'officer-slskd': 'supervises the slskd daemon',
'officer-headscale': 'fronts a headscale server',
'officer-transmission': 'fronts a transmission daemon',
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
'officer-jellyfin': 'fronts a Jellyfin container',
'officer-memos': 'needs an owner-configured Memos instance URL and token',
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
'officer-caldav': 'supervises Radicale, which the light profile does not install',
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
'officer-wallet': 'holds seed and node credentials',
},
});
@@ -1,68 +0,0 @@
// macOS light profile — the same process set as the Linux light profile, on a laptop.
//
// Paired with scripts/setup/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
// chat; nothing else.
//
// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this
// file specifically: written on 2026-07-28 as a hand-copied process list, it was broken within days by
// two changes it could not see. It ran `officer-claude` against the Anthropic proxy's entry point
// while the process that actually spawns `claude` was never started, and it pointed at a pty sidecar
// that had moved. Both failures were silent — the processes simply did not come up. See
// ecosystem.profile.cjs for the checks that now make that loud.
//
// WHY THIS IS SEPARATE FROM ecosystem.light.config.cjs, given both currently run the same five apps:
// the exclusions mean different things. On macOS officer-vnc cannot run — there is no Xorg to mirror.
// On a Linux light install it could run perfectly well; you have chosen not to. Those diverge as soon
// as one profile gains something the other cannot have, and collapsing them would lose the reason.
//
// Start with: pm2 startOrRestart ecosystem.mac.light.config.cjs
const { defineProfile } = require('./ecosystem.profile.cjs');
module.exports = defineProfile({
file: 'ecosystem.mac.light.config.cjs',
include: [
'officer', // the app: SPA, /api, websockets
'officer-anthropic-proxy', // holds the Anthropic credential, forwards to api.anthropic.com
// Spawns `claude`. Reads the proxy secret from disk, so it needs no ordering against the proxy
// above: if the secret is not written yet it warns and re-reads before the next spawn.
'officer-agent',
'officer-opencode', // the alternative agent
// The terminal. Runs under node rather than bun — node-pty binds a native addon built against
// node's ABI. That detail lives in ecosystem.config.cjs, not here.
'officer-pty',
],
excluded: {
// Cannot run on macOS at all.
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
// Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed
// locally — it points at a remote instance over the network — but a baseline process shows up in the dock
// and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It
// is an app-store install now: `existing` mode, URL and token, same as any other remote service.
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
// Would run, but needs something setup_mac_light.sh deliberately does not install.
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
'officer-music': 'the ffprobe indexer works, but a full ~/Music index is expensive to start by default',
// Fronts a container or daemon a laptop is not running.
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
'officer-slskd': 'supervises the slskd daemon',
'officer-headscale': 'fronts a headscale server',
'officer-transmission': 'fronts a transmission daemon',
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
'officer-jellyfin': 'fronts a Jellyfin container',
// Needs an owner-configured external service.
'officer-memos': 'needs an owner-configured Memos instance URL and token',
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
// Deliberate, for what it holds or who feeds it.
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
'officer-wallet': 'holds seed and node credentials; not on a laptop',
},
});
-85
View File
@@ -1,85 +0,0 @@
// Shared machinery for the pm2 install profiles (ecosystem.light.config.cjs,
// ecosystem.mac.light.config.cjs).
//
// A profile is a SUBSET of ecosystem.config.cjs, declared as names plus reasons. It never restates how
// a process is launched — `script` and `args` are read from the host file at load — because a
// hand-copied process list is exactly what failed here: the macOS list was written on 2026-07-28 and
// within days was starting a sidecar that had been split in two and pointing at a pty entry point that
// had moved. Neither failure said anything; the processes simply did not come up.
//
// So the rule is: ecosystem.config.cjs is the only place a launch command is written down, and a
// profile only decides which of them to run.
//
// Two consistency checks, both of which turn a silent breakage into a loud one at load:
// 1. a name the profile INCLUDES that the host no longer defines — the app was renamed or removed
// 2. an app the host defines that the profile neither includes nor excludes — a new sidecar, which
// must be classified deliberately rather than defaulting to absent because nobody noticed
//
// The second is the one that matters over time. Without it, every sidecar added to the host silently
// stays out of every profile, and the profiles quietly stop meaning what their comments claim.
/**
* @param {object} spec
* @param {string} spec.file this profile's filename, for error messages
* @param {string[]} spec.include app names to run, in start order
* @param {Record<string,string>} spec.excluded app name → why it is not in this profile
*/
// The directory holding the platform's package.json, found by walking up from this file. Independent of
// where in the tree this config is kept, and of where pm2 was invoked from.
function repoRoot() {
const { existsSync, readFileSync } = require('node:fs');
const { dirname, join } = require('node:path');
let dir = __dirname;
for (;;) {
const manifest = join(dir, 'package.json');
if (existsSync(manifest)) {
try {
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir;
} catch {
// Unparseable is not ours; keep walking.
}
}
const up = dirname(dir);
if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname);
dir = up;
}
}
function defineProfile({ file, include, excluded }) {
const full = require('./ecosystem.config.cjs');
const byName = new Map(full.apps.map((app) => [app.name, app]));
const missing = include.filter((name) => !byName.has(name));
if (missing.length) {
throw new Error(
`${file}: ${missing.join(', ')} not found in ecosystem.config.cjs — the app was renamed or ` +
`removed. Update this profile's include list.`,
);
}
const unclassified = full.apps
.map((app) => app.name)
.filter((name) => !include.includes(name) && !(name in excluded));
if (unclassified.length) {
throw new Error(
`${file}: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither included nor ` +
`excluded here. Add it to the include list, or to the excluded map with a reason.`,
);
}
// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does
// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls
// back to the default PORT with no POSTGRES_URL.
//
// It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of
// the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not
// fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it.
//
// This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside
// ecosystem.config.cjs". That stopped being true the moment these files were moved into
// ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is
// true wherever this file ends up living.
return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) };
}
module.exports = { defineProfile };
+1 -1
View File
@@ -25,7 +25,7 @@
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/setup/officer-setup.sh"
"setup": "bash scripts/install.sh"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
+25 -4
View File
@@ -16,18 +16,39 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const template = join(root, 'src/apps/officer-web/index.html');
const output = join(root, 'src/apps/officer-web/index.gen.html');
// The server reads .env through --env-file, but this script runs standalone.
// Where the URL comes from, most specific first:
//
// 1. the first argument `bun gen:index https://officer.example.com`
// 2. PUBLIC_URL in the environment
// 3. PUBLIC_URL in .env (this script runs standalone; the server gets it via --env-file)
//
// The argument exists so changing the public address is one command rather than an edit plus a
// regenerate — and so a second address can be generated for without touching the install's own .env.
const argUrl = process.argv[2]?.trim();
const envPath = join(root, '.env');
if (!process.env.PUBLIC_URL && existsSync(envPath)) {
if (!argUrl && !process.env.PUBLIC_URL && existsSync(envPath)) {
for (const line of (await Bun.file(envPath).text()).split('\n')) {
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
if (match) process.env.PUBLIC_URL = match[1]!.trim().replace(/^["']|["']$/g, '');
}
}
const publicUrl = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
const publicUrl = (argUrl || process.env.PUBLIC_URL || '').replace(/\/+$/, '');
if (!publicUrl) {
console.error('[gen-index] PUBLIC_URL is not set — set it in .env (e.g. https://officer.example.com)');
console.error('[gen-index] no public URL. Pass one — `bun gen:index https://officer.example.com` —');
console.error('[gen-index] or set PUBLIC_URL in .env.');
process.exit(1);
}
// Caught here rather than left to a crawler: a relative or scheme-less value substitutes without
// complaint and produces OpenGraph tags nothing can resolve, which is invisible until someone shares a
// link and the preview is blank.
try {
const parsed = new URL(publicUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('not http(s)');
} catch {
console.error(`[gen-index] "${publicUrl}" is not an absolute http(s) URL — OpenGraph tags need one.`);
process.exit(1);
}
+185
View File
@@ -0,0 +1,185 @@
#!/bin/bash
# =============================================================================
# Officer — install
# =============================================================================
#
# One command, blank machine to running platform. It runs the two halves in
# order and does nothing else itself:
#
# setup/machine-setup/machine-setup.sh a usable machine — packages, tailnet,
# runtimes, docker, shell
# setup/officer-setup.sh the platform on top of it — repo,
# dependencies, postgres, .env, secret
# store, schema, build, pm2
#
# They stay two scripts because they answer two different questions and are worth
# running separately: a machine you already trust needs only the second, and a
# machine you are rebuilding needs only the first. This is the wrapper for the
# case where you want both, which is most first runs.
#
# Both are re-runnable. Each remembers the steps it finished and skips them, so
# stopping halfway and coming back costs nothing.
#
# Run it as yourself — it asks for administrator rights when it needs them.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MACHINE="$SCRIPT_DIR/setup/machine-setup/machine-setup.sh"
OFFICER="$SCRIPT_DIR/setup/officer-setup.sh"
BOLD='\033[1m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
say() { echo -e "$*"; }
die() {
echo -e "${YELLOW}error:${NC} $*" >&2
exit 1
}
[[ -r "$MACHINE" ]] || die "missing $MACHINE"
[[ -r "$OFFICER" ]] || die "missing $OFFICER"
# Which halves to run. Both by default.
RUN_MACHINE=true
RUN_OFFICER=true
# Kept before the loop below eats them: this script re-executes itself through sudo
# further down, and `shift` would otherwise leave it re-running with no arguments —
# silently dropping --officer-only and turning a platform-only run into a full one.
#
# The `${x[@]+"${x[@]}"}` form is for `set -u`: expanding an empty array unquoted-safe
# is an error on bash before 4.4, and this runs on whatever the machine came with.
ORIGINAL_ARGS=(${@+"$@"})
# A `while`/`shift` loop rather than `for arg in "$@"`, because --repo takes a value
# and a for-loop cannot consume the argument after it.
while [[ $# -gt 0 ]]; do
case "$1" in
--machine-only) RUN_OFFICER=false ;;
--officer-only) RUN_MACHINE=false ;;
--repo)
[[ -n "${2:-}" ]] || die "--repo needs a URL"
OFFICER_REPO="$2"
shift
;;
--repo=*) OFFICER_REPO="${1#--repo=}" ;;
# Every question that HAS a default answers itself. The ones with no possible
# default still ask — see the note above the run below.
--unattended | -y)
export UNATTENDED=1 ASSUME_YES=1
;;
-h | --help)
say "usage: install.sh [--machine-only | --officer-only] [--repo <url>]"
say ""
say " no flags both halves, machine first"
say " --machine-only stop after the machine is provisioned"
say " --officer-only the platform only, on a machine you already trust"
say " --repo <url> clone the platform from here instead of the default"
say " --unattended take the default for every question that has one (-y)"
say ""
say " The default is a private Gitea over SSH, which only authenticates on a"
say " machine whose key it already knows. Pass an https URL on a fresh box."
say ""
say " --unattended still asks the questions that have no possible default:"
say " the username, the Tailscale control plane / login server / auth key,"
say " an SSH public key when the account has none, and the git identity."
say " Answer those ahead of time with SETUP_USERNAME, TS_LOGIN_SERVER,"
say " TS_AUTHKEY and TIMEZONE to reduce it further."
exit 0
;;
*) die "unknown option: $1" ;;
esac
shift
done
# Exported so `officer-setup.sh` reads it from the environment and this script does
# not have to forward arguments it does not own. `lib/repo.sh` takes it as
# `${OFFICER_REPO:-<default>}`, so unset here still means the default there.
[[ -n "${OFFICER_REPO:-}" ]] && export OFFICER_REPO
KERNEL="$(uname -s)"
case "$KERNEL" in
Darwin)
[[ "$EUID" -eq 0 ]] && die "do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
;;
Linux) ;;
*) die "unsupported system: $KERNEL. Officer installs on Linux and macOS." ;;
esac
SELF="$SCRIPT_DIR/install.sh"
# One report for the whole run, not one per half. Both scripts append to this
# file, so the person reviewing it sees a single account of what happened rather
# than two they have to stitch together and hope are complete.
#
# Exported before either half starts, and timestamped once here — if each script
# made its own name they would differ by however long the first one took.
export REPORT_FILE="${REPORT_FILE:-${HOME}/officer-install-report-$(date '+%Y%m%d-%H%M%S').md}"
# ── Privileges: asked for, not demanded ──
#
# Run this as YOURSELF. On Linux it needs root for apt, systemd units, useradd,
# netplan, ufw and for creating directories owned by the service account — so it
# asks, once, through sudo, and re-executes itself. Typing `sudo` yourself works
# too and changes nothing, but it should not be the price of starting.
#
# Variables are passed to sudo explicitly rather than with -E. `env_reset` is the
# sudoers default and strips the environment, which is how DATA_PATH was lost
# once already; naming them on the command line survives it.
#
# macOS never escalates. Homebrew refuses to run as root, and nothing in the
# macOS path needs it — the account running this IS the owner, so there is
# nothing to chown and nothing to drop privileges to.
if [[ "$KERNEL" != "Darwin" && "$EUID" -ne 0 ]]; then
command -v sudo >/dev/null 2>&1 || die "this needs root and sudo is not installed — run it as root"
say ""
say " This needs administrator rights. You will be asked for your password."
say ""
exec sudo \
OFFICER_ROOT="${OFFICER_ROOT:-}" \
SETUP_USERNAME="${SETUP_USERNAME:-}" \
MACHINE_ROLE="${MACHINE_ROLE:-}" \
REPORT_FILE="${REPORT_FILE:-}" \
UNATTENDED="${UNATTENDED:-}" \
ASSUME_YES="${ASSUME_YES:-}" \
OFFICER_REPO="${OFFICER_REPO:-}" \
bash "$SELF" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi
say ""
say "${BOLD}Officer install${NC}"
say " system: $KERNEL"
$RUN_MACHINE && say " 1/2 machine setup"
$RUN_OFFICER && say " $($RUN_MACHINE && echo 2/2 || echo 1/1) officer setup"
say ""
say " Either half can be run on its own later:"
say " scripts/setup/machine-setup/machine-setup.sh"
say " scripts/setup/officer-setup.sh"
say ""
# Not `set -e`'s job: a half that exits non-zero should say which half, and stop
# before the next one starts on a machine that is not ready for it.
# ── Who says "you are still root" ──
#
# Both halves end as root and both need to say so, but only the LAST one to run
# should — otherwise a full install says it twice, once in the middle where it is
# wrong, because officer-setup is about to run and still needs the privilege.
#
# So the rule is "say it if nothing follows you", and this is the only place that
# knows whether anything does.
if $RUN_MACHINE; then
$RUN_OFFICER && export OFFICER_SETUP_FOLLOWS=1
bash "$MACHINE" || die "machine setup did not finish — fix what it reported, then run this again"
unset OFFICER_SETUP_FOLLOWS
fi
if $RUN_OFFICER; then
bash "$OFFICER" || die "officer setup did not finish — fix what it reported, then run this again"
fi
say ""
say "${GREEN}Done.${NC}"
+3 -1
View File
@@ -10,7 +10,9 @@
import type { BrowsedFile } from 'officerdb';
import { eq, asc } from 'drizzle-orm';
import { db, finishSoulseekBrowse } from 'officerdb';
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/schema';
// soulseek is a plugin, so its tables are commented out of officerdb's schema aggregator —
// import them from the feature directly.
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/soulseek/schema';
import { buildTree } from '../src/servers/sidecar/slskd/browse';
const snapshots = await db
+86
View File
@@ -150,8 +150,54 @@ load_answers() {
# file — the point of asking for a single step is to run that step.
ONLY_STEP="${ONLY_STEP:-}"
# ── Steps that do not exist on macOS ──
#
# A Mac running Officer is a DEV MACHINE, never a server. That is not a
# simplification to revisit: nobody puts a laptop behind a public hostname and
# hands it a tailnet exit node, and the sections below are all about being a
# server that is on all the time.
#
# Most would fail rather than misbehave — there is no systemd, no ufw, no
# netplan, no useradd, no /etc/ssh/sshd_config.d. But a few would SUCCEED and be
# wrong, which is worse: stopping a laptop from sleeping, or freezing its address
# on a network it moves between every day.
#
# Keyed on the step title, so the sections themselves stay Linux code with no
# `if macos` branches threaded through them. The reason is printed, because a
# silent skip and a missing step look identical.
declare -A MACOS_SKIP=(
["User account"]="accounts are System Settings' business on a Mac, not a script's"
["Disk space"]="ballast and swap tuning are server concerns"
["Locale"]="macOS manages locale itself"
["Timezone"]="macOS manages the timezone itself"
["Swap"]="macOS sizes its own swap dynamically"
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
["Boot hang"]="a systemd boot ordering fix"
["SSH access"]="hardening a door a dev machine should not be opening"
["DNS"]="systemd-resolved"
["Network address"]="netplan, and a laptop moves between networks by design"
["fail2ban"]="brute-force protection for an exposed SSH port"
["Unattended upgrades"]="apt; macOS updates through Software Update"
["Firewall"]="ufw; macOS has its own application firewall"
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
)
step() {
CURRENT_STEP="$1"
# The report follows the step, rather than each section remembering to say
# which one it is. Twenty-six sections, one place.
declare -F report_section >/dev/null && report_section "$1"
if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
echo ""
echo -e "${BOLD}── $1 ──${NC}"
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
SKIP_STEP=true
return
fi
if [[ -n "$ONLY_STEP" ]]; then
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
@@ -239,6 +285,37 @@ page() {
# deliberate keystroke would train people to hold the y key down.
#
# ASSUME_YES=1 answers all of them, for an unattended run.
# A numbered menu's answer, or its own default when running unattended.
#
# menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
#
# ── Why empty, rather than a default passed in ──
#
# Every menu in this script reads its choice and then consumes it as
# `${CHOICE:-<n>}`, so the default already lives at the point of use — which is the
# right place, next to the options it selects between. Setting the variable EMPTY is
# therefore exactly what pressing Enter does, and it cannot drift from the default
# the prompt advertises the way a second copy passed in here would.
#
# `read <<<''` rather than `eval` or `declare -g`: no eval, and `declare -g` is bash
# 4.2+, which rules out the bash 3.2 that macOS still ships.
#
# The prompt is still printed, with the reason, because a transcript that silently
# skips a question reads as a question that was never asked.
menu_answer() {
local var="$1" prompt="$2"
if [[ "${UNATTENDED:-}" == "1" ]]; then
printf '%s%s\n' "$prompt" "— unattended, taking the default"
read -r "$var" <<<''
return 0
fi
read -rp "$prompt" "$var" || {
echo ""
fail "No answer."
}
}
confirm() {
local message="${1:-Proceed?}"
# Second argument flips the default. Most questions here are "do the thing you
@@ -585,6 +662,15 @@ default_iface() {
# firewall open on one. Every branch downstream is about what this machine is
# exposed to, so it is worth one deliberate keystroke rather than an Enter.
ask_machine_role() {
# Not a question on a Mac. Officer on macOS is a dev helper on a machine
# somebody sits at — there is no homelab or VPS answer that would make sense,
# and every section that branches on the role branches toward "server".
if [[ "${OS:-}" == "macos" && -z "$MACHINE_ROLE" ]]; then
MACHINE_ROLE="dev"
info "macOS — treated as a dev machine. The server-only sections are skipped."
return
fi
if [[ -n "$MACHINE_ROLE" ]]; then
case "$MACHINE_ROLE" in
homelab | vps | dev) return ;;
+20 -11
View File
@@ -92,27 +92,36 @@ oh_my_zsh_installed() { [[ -d "${USER_HOME}/.oh-my-zsh" ]]; }
install_oh_my_zsh() {
# The installer refuses to run unattended over an existing install, so this is
# only ever called when there is none.
#
# ── `|| true` is what makes this non-fatal, NOT the `return 0` below ──
#
# It used to be `return 0` alone, with a comment claiming the function returned
# zero whatever happened. It did not. Under `set -e` a failing command inside a
# function aborts the SHELL at that line when the function is called plainly —
# `return 0` is never reached. So a machine where this curl or the installer
# failed died here, silently, because the output is redirected: the run just
# stopped after apt finished installing zsh, with nothing said. Observed on a
# fresh Hetzner VPS, 2026-08-14.
sudo -H -u "$USERNAME" sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
true
# Belt and braces: `|| true` above already makes the last command succeed, and
# this states the contract for anyone adding a line beneath it.
return 0
}
# `chsh` is what actually changes the login shell. Asked separately from
# installing zsh, because having a shell available and being handed it at every
# login are different decisions.
# Reports whether chsh worked, rather than swallowing it. The same `set -e` trap as
# install_oh_my_zsh applies — a bare `chsh` that fails kills the run at this line —
# but here the answer matters: the caller announces the new login shell, and `|| true`
# would have it announce one that was never set. So the status comes back and the
# CALLER guards the call, which is also what keeps set -e out of it.
set_login_shell() {
local shell="$1"
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
chsh -s "$shell" "$USERNAME"
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
chsh -s "$shell" "$USERNAME" >/dev/null 2>&1
}
# -----------------------------------------------------------------------------
+29 -1
View File
@@ -49,6 +49,11 @@ docker_repo_distro() {
}
install_docker_engine() {
# Linux only, and never reached on macOS: the Docker step there checks for
# Docker Desktop and tells the owner to install it rather than doing it — a GUI
# app that wants opening, permissions and a running window is not a shell
# script's job, and colima/lima are not worth the evening they cost.
local distro codename
distro="$(docker_repo_distro)"
codename="$(docker_repo_codename)"
@@ -67,7 +72,30 @@ install_docker_engine() {
>/etc/apt/sources.list.d/docker.list
pkg_refresh >/dev/null
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# ── The rootless prerequisites go in HERE, not in the rootless branch ──
#
# They used to be installed only when the owner picked "[2] rootless Docker for
# me" in section 22. But the OWNER's choice is not the only one that matters:
# every Developer account the platform provisions gets its own rootless daemon,
# whatever the owner picked for themselves. So on a machine where the owner chose
# the docker group, the host never got these and every member's daemon failed
# with `rootless Docker needs these packages on the host: uidmap`.
#
# `src/servers/os-user-docker.ts` → checkDockerPrerequisites is the authority on
# this list, and it wants both:
#
# uidmap /usr/bin/newuidmap, /usr/bin/newgidmap
# docker-ce-rootless-extras /usr/bin/dockerd-rootless-setuptool.sh
#
# docker-ce only RECOMMENDS rootless-extras. That is installed by default, so it
# is usually there by luck — and is not on a host configured with
# --no-install-recommends. Named explicitly so it does not depend on that.
#
# dbus-user-session is what lets a member's systemd --user survive without a
# login session, which is how the daemon stays up.
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
docker-ce-rootless-extras uidmap dbus-user-session
}
# A shared network so containers from different compose files can reach each
+47 -1
View File
@@ -43,10 +43,17 @@ install_config() {
if [[ ! -f "$dest" ]]; then
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
# Recorded here rather than at the call site: "which files did it write" is
# the question a reviewer asks first, and a per-section report would drift
# from what this function actually did.
declare -F report_changed >/dev/null && report_changed "wrote ${dest} (0644, owner ${owner}) — did not exist"
return 0
fi
cmp -s "$src" "$dest" && return 1
if cmp -s "$src" "$dest"; then
declare -F report_kept >/dev/null && report_kept "${dest} already identical to the shipped version — not touched"
return 1
fi
echo ""
warn "${dest} already exists here, and differs from the one this script ships."
@@ -56,6 +63,7 @@ install_config() {
# was present to defend.
if [[ "${ASSUME_YES:-}" == "1" ]] || [[ ! -t 0 ]]; then
echo " keeping yours (nothing was asked, so nothing is replaced)"
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — unattended run, nothing replaced"
return 2
fi
@@ -67,17 +75,20 @@ install_config() {
if ! read -rp " Which one? (1/2/3) [1]: " answer; then
echo ""
echo " keeping yours"
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — no answer available"
return 2
fi
case "${answer:-1}" in
1)
echo " keeping yours"
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT by choice"
return 2
;;
2)
cp -a "$dest" "${dest}.before-machine-setup"
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
ok "replaced — yours is at ${dest}.before-machine-setup"
declare -F report_changed >/dev/null && report_changed "REPLACED ${dest} by choice — previous kept at ${dest}.before-machine-setup"
return 0
;;
3)
@@ -125,3 +136,38 @@ append_once() {
echo "$end"
} >>"$file"
}
# -----------------------------------------------------------------------------
# Where tmux actually reads its config
# -----------------------------------------------------------------------------
#
# tmux 3.1 added an XDG location and it takes PRECEDENCE. Verified on 3.4 by
# creating both and asking tmux which marker it ended up with:
#
# both present -> ~/.config/tmux/tmux.conf
# only ~/.tmux.conf -> ~/.tmux.conf
# only the XDG one -> the XDG one
#
# So installing to ~/.tmux.conf on a machine that has the XDG file writes a file
# tmux will never read, and the script would report success having changed
# nothing anybody can see. That is the failure this exists to prevent.
#
# Rules, in order:
# 1. an existing XDG config wins -> that is their real config, target it
# 2. an existing ~/.tmux.conf -> target it, since it is what tmux reads
# 3. neither -> ~/.tmux.conf, the path every guide names
tmux_config_target() {
local home="$1"
local xdg="${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf"
if [[ -f "$xdg" ]]; then
echo "$xdg"
else
echo "$home/.tmux.conf"
fi
}
# True when a ~/.tmux.conf would be shadowed by an XDG config that already exists.
tmux_dot_conf_is_shadowed() {
local home="$1"
[[ -f "${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf" && -f "$home/.tmux.conf" ]]
}
+49 -10
View File
@@ -84,28 +84,35 @@ LAST_SKIPPED=()
pkgs_core() {
case "$PM" in
apt)
# apt-transport-https, lsb-release and software-properties-common are not
# tools — they are what lets later steps add the Docker repository and the
# fastfetch PPA. They have no counterpart on the other systems.
# apt-transport-https and lsb-release are not tools — they are what lets a
# later step add the Docker repository. They have no counterpart on the
# other systems.
#
# software-properties-common is still here and is no longer needed by
# anything: it provides `add-apt-repository`, and the fastfetch PPA was its
# only caller until that was removed on 2026-08-14 (Docker writes its own
# sources.list.d entry by hand). Left in deliberately rather than dropped
# in the same change — it is one small package, and pulling it is a
# separate decision from removing the tool that wanted it.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools \
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools \
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools \
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula.
echo gnupg git jq wget btop htop tree tmux ripgrep fd
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
echo gnupg git jq wget btop htop tree ripgrep fd eza
;;
esac
}
@@ -211,8 +218,20 @@ pkg_install() {
LAST_INSTALLED=("${missing[@]}")
LAST_KEPT=("${present[@]}")
announce_plan "$label" present missing || return 0
pkg_install_now "${missing[@]}"
announce_plan "$label" present missing || {
# Declining is a fact a reviewer wants: it explains a package being absent
# later without having to guess whether the script failed or was refused.
declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
return 0
}
if pkg_install_now "${missing[@]}"; then
declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
else
declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
return 1
fi
}
# Print what a section is about to do and ask permission for it.
@@ -262,3 +281,23 @@ summarise_last() {
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
fi
}
# -----------------------------------------------------------------------------
# The Xcode command line tools
# -----------------------------------------------------------------------------
#
# macOS's build-essential, and not installable as a formula. It matters here for
# one specific reason: node-pty ships no prebuilt binary for any platform, so
# `bun install` always falls through to node-gyp and needs a working compiler.
# Without this the platform install fails deep inside a dependency tree with an
# error that names neither Xcode nor node-pty.
#
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
# not block until the download finishes. So this asks, and then says to come back,
# rather than pretending to have waited.
xcode_clt_installed() { xcode-select -p &>/dev/null; }
xcode_clt_install() {
xcode-select --install 2>/dev/null || true
}
+12 -1
View File
@@ -190,7 +190,18 @@ tailscale_control_url() {
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
}
tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; }
# The official install.sh is a Linux package-manager script. macOS gets the same
# daemon wrapped in a GUI app, and the cask is the version with a CLI at
# /Applications/Tailscale.app/Contents/MacOS/Tailscale — the Mac App Store build
# is sandboxed and ships no usable `tailscale` binary, which is the difference
# that matters to a script.
tailscale_install() {
if [[ "${OS:-}" == "macos" ]]; then
brew install --cask tailscale
return
fi
curl -fsSL https://tailscale.com/install.sh | sh
}
# Tailscale's own coordination server, spelled out.
#
+9 -20
View File
@@ -20,10 +20,17 @@
MACHINE_SETUP_TOOLS_LOADED=1
# The set installed on every machine, in the order they are fetched.
tools_default() { echo lazydocker lazygit starship fastfetch; }
#
# fastfetch was here until 2026-08-14 and was removed after it stopped a real
# install. It is the only one of these with no source but a third-party PPA on
# Ubuntu 24.04 and older, and the failure was in the half that was not guarded:
# a PPA that ADDS cleanly but carries no package for the running codename gets
# past the `|| skip` and dies on the install instead. A neofetch clone is not
# worth a branch in a script that has to survive on machines nobody has seen.
tools_default() { echo lazydocker lazygit starship; }
# The command that proves a tool is already here. Same as the tool name for all
# four today, but kept as a mapping because that is not a rule — a package and
# three today, but kept as a mapping because that is not a rule — a package and
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
# indirection.
tool_command() {
@@ -31,7 +38,6 @@ tool_command() {
lazydocker) echo lazydocker ;;
lazygit) echo lazygit ;;
starship) echo starship ;;
fastfetch) echo fastfetch ;;
*) echo "$1" ;;
esac
}
@@ -80,23 +86,6 @@ tool_install_starship() {
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
}
# A distribution package everywhere, but not always one the distribution ships:
# Ubuntu only picked fastfetch up in 24.10, so on noble and older the PPA is the
# only source. Checked rather than assumed, so the PPA stops being added the
# moment the archive has it.
tool_install_fastfetch() {
if [[ "$PM" == "apt" ]] && ! apt-cache policy fastfetch 2>/dev/null | grep -q 'Candidate: [0-9]'; then
info " fastfetch is not in this release's archive — adding the upstream PPA"
add-apt-repository -y ppa:zhangsongcui3371/fastfetch >/dev/null 2>&1 ||
{
warn "could not add the fastfetch PPA — skipping"
return 0
}
pkg_refresh >/dev/null 2>&1
fi
pkg_install_now fastfetch
}
# -----------------------------------------------------------------------------
# Acting
# -----------------------------------------------------------------------------
+253 -54
View File
@@ -18,6 +18,12 @@ ANSWERS_FILE="$SCRIPT_DIR/.setup-answers"
# still runs, because every section needs what it establishes — the system, the
# role, the account and its home.
ONLY_STEP=""
# Kept before the loop consumes them: this script re-executes itself through sudo
# below and was passing `"$@"`, which `shift` had already emptied — so `--only` and
# `--reask` silently stopped existing the moment it escalated.
ORIGINAL_ARGS=(${@+"$@"})
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
@@ -32,12 +38,25 @@ while [[ $# -gt 0 ]]; do
RE_ASK=1
shift
;;
# Every question that HAS a default answers itself; the ones with none still ask.
# ASSUME_YES drives confirm(), UNATTENDED drives the numbered menus and the
# free-text prompts that carry a default.
--unattended | -y)
export UNATTENDED=1 ASSUME_YES=1
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0
;;
-h | --help)
echo "usage: machine-setup.sh [--only <step>] [--reask] [--list]"
echo "usage: machine-setup.sh [--only <step>] [--reask] [--list] [--unattended]"
echo ""
echo " --unattended take the default for every question that has one (-y)."
echo " Still asks the ones with no possible default: the"
echo " username, the Tailscale control plane / login server /"
echo " auth key, an SSH public key when the account has none,"
echo " and the git identity."
exit 0
;;
*) echo "unknown option: $1" >&2 && exit 2 ;;
@@ -48,6 +67,7 @@ done
# lib/ so a step can eventually be read — or run — on its own without dragging the
# whole script in. Definitions only; nothing in there acts.
# shellcheck source=lib/base.sh
source "$SCRIPT_DIR/../report.sh"
source "$SCRIPT_DIR/lib/base.sh"
# shellcheck source=lib/packages.sh
source "$SCRIPT_DIR/lib/packages.sh"
@@ -87,6 +107,7 @@ echo -e "${BOLD}╚════════════════════
[[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE"
load_answers
trap report_flush EXIT
detect_os
echo ""
info "Machine: ${OS_NAME} (${ARCH})"
@@ -119,15 +140,58 @@ else
echo " so coming back costs nothing."
fi
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./machine-setup.sh"
# ── root on Linux, NOT root on macOS ──
#
# The two are opposites and it is not a preference. On Linux nearly every section
# needs root — apt, systemd units, useradd, netplan, ufw. On macOS Homebrew
# REFUSES to run as root and says so; running the whole script under sudo there
# would fail at the first `brew install` having already asked for a password.
#
# It works out because the macOS path skips everything that needed root in the
# first place (see MACOS_SKIP in lib/base.sh). What is left — brew, the Xcode
# command line tools, the agent CLIs, bun — is all per-user by design.
# ── Privileges: asked for, not demanded ──
#
# Run this as YOURSELF. Linux needs root for apt, systemd units, useradd, netplan
# and ufw, so it asks through sudo and re-executes itself rather than making you
# type it. Variables go to sudo by name rather than with -E: `env_reset` is the
# sudoers default and strips the environment, which is how DATA_PATH was lost
# once already.
#
# macOS never escalates — Homebrew refuses to run as root, and the sections that
# needed root are the ones the macOS path skips.
if [[ "$OS" == "macos" ]]; then
if [[ "$EUID" -eq 0 ]]; then
fail "Do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
fi
elif [[ "$EUID" -ne 0 ]]; then
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
echo ""
echo " This needs administrator rights. You will be asked for your password."
echo ""
exec sudo \
OFFICER_ROOT="${OFFICER_ROOT:-}" \
SETUP_USERNAME="${SETUP_USERNAME:-}" \
MACHINE_ROLE="${MACHINE_ROLE:-}" \
REPORT_FILE="${REPORT_FILE:-}" \
UNATTENDED="${UNATTENDED:-}" \
ASSUME_YES="${ASSUME_YES:-}" \
bash "$SCRIPT_DIR/machine-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi
ask_username
if id "$USERNAME" &>/dev/null; then
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
# On macOS the account running the script IS the account, and there is nothing to
# create — the User account step is skipped entirely.
if [[ "$OS" == "macos" ]]; then
USERNAME="$(id -un)"
USER_HOME="$HOME"
info "Account: ${USERNAME} (you — macOS creates no accounts here)"
else
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
ask_username
if id "$USERNAME" &>/dev/null; then
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
else
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
fi
fi
ask_officer_root
@@ -140,9 +204,9 @@ fi
save_answers
# Always, and outside any step: everything below reads this index — core utils,
# the fastfetch PPA, the Docker repo — and `step` skips a step whose name is
# already in the progress file. With the refresh inside one of those, a resumed
# run installed against whatever the index happened to say hours or days ago.
# the Docker repo — and `step` skips a step whose name is already in the progress
# file. With the refresh inside one of those, a resumed run installed against
# whatever the index happened to say hours or days ago.
echo ""
info "Refreshing the package index..."
pkg_refresh >/dev/null
@@ -374,6 +438,34 @@ fi
# What the distribution provides: the six this script would break without, and
# the command-line tools that make a machine worth sitting at.
# macOS's compilers, before anything that might need to build a native module.
if [[ "$OS" == "macos" ]]; then
step "Xcode command line tools"
if ! skip; then
echo ""
if xcode_clt_installed; then
ok "already installed ($(xcode-select -p))"
SUMMARY+=("Xcode CLT: already installed")
else
info "Xcode command line tools — macOS's compilers"
echo " Needed because node-pty ships no prebuilt binary and compiles"
echo " from source on every machine, so 'bun install' cannot finish"
echo " without a compiler."
echo ""
if confirm "Start the install?"; then
xcode_clt_install
warn "a macOS dialogue has opened — finish it there, then re-run this step"
echo " ./machine-setup.sh --only 'Xcode command line tools'"
SUMMARY+=("Xcode CLT: install started in a GUI dialogue — finish it, then re-run")
else
warn "skipped — 'bun install' will fail on node-pty without it"
SUMMARY+=("Xcode CLT: SKIPPED by request")
fi
fi
step_ok
fi
fi
step "Core utils"
if ! skip; then
# shellcheck disable=SC2046 # word splitting is how the list is passed
@@ -690,10 +782,9 @@ if ! skip; then
echo ""
while [[ -z "${TIMEZONE:-}" ]]; do
if ! read -rp " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " TZ_CHOICE; then
echo ""
fail "No answer. Set TIMEZONE=<zone> to answer this ahead of time."
fi
# Unattended keeps the current zone, which is what Enter does here. TIMEZONE=<zone>
# in the environment answers it ahead of time and skips this block entirely.
menu_answer TZ_CHOICE " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: "
if [[ -z "$TZ_CHOICE" ]]; then
TIMEZONE="$CURRENT_TZ"
@@ -847,10 +938,7 @@ elif ! skip; then
BALLAST_FILE=""
while [[ -z "$BALLAST_FILE" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then
echo ""
fail "No answer."
fi
menu_answer BALLAST_WHERE " Which one? (1/2/3) [1]: "
case "${BALLAST_WHERE:-1}" in
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
@@ -890,10 +978,7 @@ elif ! skip; then
BALLAST_PCT=""
while [[ -z "$BALLAST_PCT" ]]; do
if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then
echo ""
fail "No answer."
fi
menu_answer BALLAST_SIZE_CHOICE " Which one? (1/2/3) [2]: "
case "${BALLAST_SIZE_CHOICE:-2}" in
1) BALLAST_PCT=5 ;;
2) BALLAST_PCT=10 ;;
@@ -1268,10 +1353,7 @@ if ! skip; then
DNS_FALLBACK=""
DNS_CHOSEN=""
while [[ -z "$DNS_CHOSEN" ]]; do
if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then
echo ""
fail "No answer."
fi
menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
case "${DNS_CHOICE:-1}" in
1) DNS_CHOSEN="keep" ;;
2)
@@ -1378,10 +1460,7 @@ elif ! skip; then
NET_CHOICE=""
while [[ -z "$NET_CHOICE" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then
echo ""
fail "No answer."
fi
menu_answer NET_ANSWER " Which one? (1/2/3) [1]: "
case "${NET_ANSWER:-1}" in
1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
*) warn "Pick 1, 2 or 3." ;;
@@ -1644,12 +1723,47 @@ if ! skip; then
info "Docker — containers, and how ${USERNAME} is allowed to talk to them"
echo " engine: $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')"
echo " daemon: $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')"
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
if [[ "$OS" != "macos" ]]; then
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
fi
if ! docker_is_installed; then
# ── macOS: we do not install Docker, we check for it ──
#
# Docker Desktop is the only thing that works here without a fight. Lima and
# colima both technically run containers on a Mac and both cost an evening the
# first time something does not resolve, so this asks for Desktop by name
# rather than installing an alternative that will disappoint later.
#
# Not installed by the script either: it is a GUI app that wants to be opened,
# granted permissions and left running, none of which a shell script should be
# doing on somebody's laptop.
if [[ "$OS" == "macos" ]]; then
if docker_daemon_ok; then
ok "Docker Desktop is running"
SUMMARY+=("Docker: Docker Desktop running")
elif docker_is_installed; then
warn "the docker CLI is here but the daemon is not answering"
echo " Open Docker Desktop from Applications and let it finish starting."
SUMMARY+=("Docker: installed but not running — open Docker Desktop")
else
warn "Docker is not installed"
echo ""
echo " Officer needs it for Postgres and for anything the app store"
echo " installs. Get Docker Desktop:"
echo ""
echo " https://www.docker.com/products/docker-desktop/"
echo ""
echo " Open it once after installing, then run this step again:"
echo " ./machine-setup.sh --only Docker"
SUMMARY+=("Docker: NOT installed — install Docker Desktop, then re-run this step")
fi
step_ok
elif ! docker_is_installed; then
echo ""
echo " to install: docker-ce, the CLI, containerd, buildx and compose,"
echo " from Docker's own repository"
echo " from Docker's own repository — plus uidmap,"
echo " dbus-user-session and docker-ce-rootless-extras,"
echo " which every member's own rootless daemon needs"
if confirm "Install it?"; then
if install_docker_engine; then
ok "$(docker --version 2>/dev/null | cut -d, -f1) installed"
@@ -1665,7 +1779,10 @@ if ! skip; then
fi
fi
if docker_is_installed; then
# The group-vs-rootless choice below is Linux only: Docker Desktop runs
# containers in a VM owned by whoever is logged in, so there is no group to
# join and no rootless variant to pick.
if [[ "$OS" != "macos" ]] && docker_is_installed; then
# ── how this account reaches the daemon ──
if user_in_docker_group || docker_rootless_installed; then
echo ""
@@ -1710,10 +1827,7 @@ if ! skip; then
DOCKER_ACCESS=""
while [[ -z "$DOCKER_ACCESS" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then
echo ""
fail "No answer."
fi
menu_answer DOCKER_CHOICE " Which one? (1/2/3) [1]: "
case "${DOCKER_CHOICE:-1}" in
1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
*) warn "Pick 1, 2 or 3." ;;
@@ -1821,10 +1935,7 @@ if ! skip; then
NVIM_REPO=""
NVIM_PICK=""
while [[ -z "$NVIM_PICK" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " NVIM_CHOICE; then
echo ""
fail "No answer."
fi
menu_answer NVIM_CHOICE " Which one? (1/2/3) [1]: "
case "${NVIM_CHOICE:-1}" in
1)
NVIM_REPO="https://github.com/LazyVim/starter"
@@ -1984,7 +2095,7 @@ if ! skip; then
echo ""
info "Agent CLIs — the programs Officer's chat actually runs"
echo " claude $(agent_version claude || echo 'not installed')"
echo " spawned by officer-agent; chat does not work without it."
echo " spawned by officer-claude-code; chat does not work without it."
echo " opencode $(agent_version opencode || echo 'not installed')"
echo " the alternative agent, run by officer-opencode."
echo ""
@@ -2110,10 +2221,20 @@ if ! skip; then
echo ""
echo " ${USERNAME}'s login shell is ${SHELL_NOW}. Changing it to zsh takes"
echo " effect at the next login, and does not affect this session."
# Guarded, not bare: `chsh` can refuse — a PAM policy, a shell missing from
# /etc/shells, an account whose password field blocks it — and a bare call would
# end the run there under `set -e`. Reported instead, because a machine with the
# right shell installed and the wrong one at login still works.
if confirm "Make zsh the login shell?"; then
set_login_shell "$(command -v zsh)"
ok "login shell is now $(user_login_shell)"
SUMMARY+=("Shell: login shell set to zsh")
if set_login_shell "$(command -v zsh)"; then
ok "login shell is now $(user_login_shell)"
SUMMARY+=("Shell: login shell set to zsh")
else
warn "chsh refused — login shell is still $(user_login_shell)"
echo " Change it later with: chsh -s $(command -v zsh) ${USERNAME}"
ERRORS+=("Shell: chsh refused, login shell left as $(user_login_shell)")
SUMMARY+=("Shell: login shell NOT changed")
fi
else
warn "left as ${SHELL_NOW}"
SUMMARY+=("Shell: login shell left as ${SHELL_NOW}")
@@ -2137,11 +2258,29 @@ if ! skip; then
esac
fi
if [[ -r "$SCRIPT_DIR/.tmux.conf" ]]; then
install_config "$SCRIPT_DIR/.tmux.conf" "${USER_HOME}/.tmux.conf" "$USERNAME" && RC=0 || RC=$?
# scripts/setup/tmux.conf, one level up — the shell templates live together
# beside starship.toml, which has to be there because the PLATFORM reads it
# too (os-user-shell.ts, for every member's Linux account). Keeping them in
# one directory means "where do the dotfile templates live" has one answer.
#
# No leading dot on any of them: they are templates in a repository, not
# dotfiles in a home directory, and tmux's destination is increasingly
# ~/.config/tmux/tmux.conf, which has no dot either.
if [[ -r "$SCRIPT_DIR/../tmux.conf" ]]; then
# Not always ~/.tmux.conf — see tmux_config_target. tmux 3.1+ prefers
# ~/.config/tmux/tmux.conf, so writing the old path on a machine that has
# the new one produces a file tmux never reads and a success message that
# means nothing.
TMUX_TARGET="$(tmux_config_target "$USER_HOME")"
if tmux_dot_conf_is_shadowed "$USER_HOME"; then
warn "you have BOTH ~/.tmux.conf and ~/.config/tmux/tmux.conf — tmux reads the second"
echo " Targeting the one it actually reads: ${TMUX_TARGET}"
fi
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$(dirname "$TMUX_TARGET")"
install_config "$SCRIPT_DIR/../tmux.conf" "$TMUX_TARGET" "$USERNAME" && RC=0 || RC=$?
case $RC in
0) ok "tmux config installed" ;;
1) echo " tmux config already matches" ;;
0) ok "tmux config installed${TMUX_TARGET}" ;;
1) echo " tmux config already matches (${TMUX_TARGET})" ;;
esac
fi
@@ -2172,8 +2311,41 @@ EOF
ok "~/.local/bin and ~/.opencode/bin added to PATH"
fi
# The eza aliases are GUARDED and the rest are not, for one reason: these
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
# failed to install leaves the owner with no working `ls` at all, in every new
# shell, which reads as a broken machine rather than a missing package. The
# others degrade honestly — `alias ld=lazydocker` without lazydocker is one
# command-not-found when you type it, not a core utility gone.
#
# Same principle shell-skel/zshrc already holds to: every optional tool is used
# only if present, so one file works on a minimal VPS and a full workstation.
if append_once "$ZSHRC" aliases <<'EOF'
if command -v eza >/dev/null 2>&1; then
alias ls='eza --icons'
alias la='eza --icons -la'
alias ll='eza --icons -l'
alias lll='eza --icons -lA'
alias lh='eza --icons -lhA'
alias ltr='eza --icons -ltr'
alias l='eza --icons -la'
fi
alias grep='grep --color=auto'
alias less='less -R'
alias diff='diff --color=auto'
alias cp='cp -iv'
alias mv='mv -iv'
alias rm='rm -i'
alias mkdir='mkdir -p'
alias which='which -a'
alias history='fc -l 1'
alias n="nvim"
alias vim="n"
alias sz="source ~/.zshrc"
alias ld="lazydocker"
alias httpserver="python3 -m http.server 8888"
EOF
then
ok "shell aliases added"
@@ -2202,10 +2374,7 @@ EOF
EDITOR_PICK=""
while [[ -z "$EDITOR_PICK" ]]; do
if ! read -rp " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE; then
echo ""
fail "No answer."
fi
menu_answer EDITOR_CHOICE " Which one? (1-${#EDITORS[@]}) [1]: "
EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
@@ -2397,5 +2566,35 @@ echo " Officer: $OFFICER_ROOT"
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
echo ""
# ── Who you are when this exits ──
#
# Root. This script never becomes ${USERNAME} — it cannot, since a process cannot
# change its own uid — so it stays root and drops privileges per command instead.
# Everything written into their home was written that way.
#
# Worth saying out loud because the two things a fresh session fixes are both
# invisible until they bite: group membership is fixed at LOGIN, so the `docker`
# group just granted does not exist in this session, and their shell configuration
# lives in their home and is not loaded in root's.
#
# Suppressed when officer-setup is about to run — install.sh sets the variable. It
# would be wrong advice in the middle of an install, because the half that follows
# still needs the root session this would tell you to leave.
if [[ "$EUID" -eq 0 && -z "${OFFICER_SETUP_FOLLOWS:-}" ]]; then
echo -e "${BOLD} You are still root.${NC}"
echo ""
echo " This machine is set up for ${USERNAME}. To carry on as them:"
echo ""
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
echo ""
echo " A new session is what makes their docker group membership and their"
echo " shell configuration take effect — neither applies to the session you"
echo " are in now."
echo ""
fi
# Clean up progress file on success
rm -f "$PROGRESS_FILE"
report_mark_complete
+465 -13
View File
@@ -14,6 +14,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
ONLY_STEP=""
# Kept before the loop consumes them. This script re-executes itself through sudo
# further down and was passing `"$@"`, which `shift` had already emptied — so
# `officer-setup.sh --only build` run as a normal user silently became a FULL run
# the moment it escalated. Nothing said so; the flag just stopped existing.
ORIGINAL_ARGS=(${@+"$@"})
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
@@ -24,19 +31,46 @@ while [[ $# -gt 0 ]]; do
ONLY_STEP="${1#*=}"
shift
;;
# Set before lib/repo.sh is sourced below, which reads it as
# `${OFFICER_REPO:-<default>}` — so this wins and an absent flag still defaults.
--repo)
[[ -n "${2:-}" ]] || {
echo "--repo needs a URL" >&2
exit 2
}
OFFICER_REPO="$2"
shift 2
;;
--repo=*)
OFFICER_REPO="${1#*=}"
shift
;;
--unattended | -y)
export UNATTENDED=1 ASSUME_YES=1
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0
;;
-h | --help)
echo "usage: officer-setup.sh [--only <step>] [--list]"
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>] [--unattended]"
echo ""
echo " --only <step> run one step; --list names them"
echo " --unattended take the default for every question that has one (-y)"
echo " --repo <url> clone from here instead of the default, which is a"
echo " private Gitea over SSH and only authenticates on a"
echo " machine whose key it already knows. Same as exporting"
echo " OFFICER_REPO. Ignored once the repo is checked out."
exit 0
;;
*) echo "unknown option: $1" >&2 && exit 2 ;;
esac
done
export OFFICER_REPO="${OFFICER_REPO:-}"
# shellcheck source=officer-setup/lib/base.sh
source "$SCRIPT_DIR/report.sh"
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
# shellcheck source=officer-setup/lib/preflight.sh
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
@@ -48,6 +82,14 @@ source "$SCRIPT_DIR/officer-setup/lib/layout.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"
# shellcheck source=officer-setup/lib/build.sh
source "$SCRIPT_DIR/officer-setup/lib/build.sh"
# shellcheck source=officer-setup/lib/services.sh
source "$SCRIPT_DIR/officer-setup/lib/services.sh"
# shellcheck source=officer-setup/lib/proxy.sh
source "$SCRIPT_DIR/officer-setup/lib/proxy.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
@@ -60,10 +102,37 @@ echo -e "${BOLD}╔════════════════════
echo -e "${BOLD}║ Officer Setup ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./officer-setup.sh"
# ── Privileges: asked for, not demanded ──
#
# Run this as YOURSELF. It needs root on Linux, so it asks through sudo and
# re-executes itself rather than making you type it. Variables are passed to sudo
# by name rather than with -E, because `env_reset` is the sudoers default and
# strips the environment — which is how DATA_PATH was lost once already.
#
# macOS never escalates: Homebrew refuses to run as root, and the account running
# this IS the owner, so there is nothing to chown and nothing to drop to.
if [[ "$(uname -s)" == "Darwin" ]]; then
if [[ "$EUID" -eq 0 ]]; then
fail "Do not run this with sudo on macOS — run it as yourself."
fi
elif [[ "$EUID" -ne 0 ]]; then
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
echo ""
echo " This needs administrator rights. You will be asked for your password."
echo ""
exec sudo \
OFFICER_ROOT="${OFFICER_ROOT:-}" \
SETUP_USERNAME="${SETUP_USERNAME:-}" \
MACHINE_ROLE="${MACHINE_ROLE:-}" \
REPORT_FILE="${REPORT_FILE:-}" \
UNATTENDED="${UNATTENDED:-}" \
ASSUME_YES="${ASSUME_YES:-}" \
OFFICER_REPO="${OFFICER_REPO:-}" \
bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi
trap report_flush EXIT
# ── what machine-setup already established ──
echo ""
if load_machine_answers; then
@@ -114,6 +183,26 @@ info "Account: ${USERNAME} (home ${USER_HOME})"
info "Officer: ${OFFICER_ROOT}"
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
# ── recover what earlier runs already decided ──
#
# A skipped section leaves its variables unset, and later sections read them. On
# a resume that is every section before the one it stopped at, so Build announced
# "PUBLIC_URL <not set — run the Environment section first>" on a machine whose
# .env had been written twenty minutes earlier.
#
# Read back here, once, from the file that already holds the answers, rather than
# per-section — three variables cross a section boundary (ENV_PORT and
# ENV_PUBLIC_URL from Environment, POSTGRES_URL from Database) and the next one
# added would have to remember to do this again.
#
# Only fills what is EMPTY, so a variable passed in on the command line still
# wins, and a section that runs for real still overwrites it with its own answer.
if [[ -f "$(env_file)" ]]; then
ENV_PORT="${ENV_PORT:-$(env_get PORT)}"
ENV_PUBLIC_URL="${ENV_PUBLIC_URL:-$(env_get PUBLIC_URL)}"
POSTGRES_URL="${POSTGRES_URL:-$(env_get POSTGRES_URL)}"
fi
# ── is the machine actually ready ──
#
# Checked and reported together. Finding out about a missing bun three sections
@@ -167,6 +256,7 @@ fi
#
# Before the repository, because the repository is cloned into it.
report_section "Layout"
step "Layout"
if ! skip; then
echo ""
@@ -210,6 +300,7 @@ fi
# 3. Repository
# =============================================================================
report_section "Repository"
step "Repository"
if ! skip; then
PLATFORM_DIR="$(platform_dir)"
@@ -282,6 +373,7 @@ fi
# 4. Dependencies
# =============================================================================
report_section "Dependencies"
step "Dependencies"
if ! skip; then
echo ""
@@ -336,6 +428,7 @@ fi
#
# POSTGRES_URL is set here and written by the environment section below.
report_section "Database"
step "Database"
if ! skip; then
echo ""
@@ -453,6 +546,7 @@ fi
# 6. Environment
# =============================================================================
report_section "Environment"
step "Environment"
if ! skip; then
echo ""
@@ -460,7 +554,7 @@ if ! skip; then
# Read back before anything is asked; existing values become the defaults.
ENV_PORT="$(env_get PORT)"
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
if env_exists; then
echo " exists — its values are the defaults below"
@@ -471,11 +565,23 @@ if ! skip; then
# ── 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. It is the one"
echo " thing this machine cannot work out for itself, and three things need"
echo " it: the OpenGraph tags baked into the page by 'bun gen:index', the"
echo " host the task API hands to scripts, and the CalDAV profile an iPhone"
echo " installs — that last one requires https."
echo ""
echo " Defaulting to this machine's tailnet address, not localhost: the"
echo " tailnet is where Officer is actually reached from, and localhost"
echo " works from here and nowhere else."
ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-$(default_public_url "$ENV_PORT")}"
echo ""
echo " to write:"
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
echo " PORT=${ENV_PORT}"
echo " PUBLIC_URL=${ENV_PUBLIC_URL}"
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
echo ""
echo " the install root is not written here — the platform derives it as the"
@@ -486,6 +592,7 @@ if ! skip; then
if confirm "Write it?"; then
write_env
ok "written, 0600, owned by ${USERNAME}"
report_changed "wrote $(env_file) (0600, owner ${USERNAME}) — PORT, PUBLIC_URL, POSTGRES_URL. No secrets: every key lives in the secret store."
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
SUMMARY+=("Environment: $(env_file)")
else
@@ -496,13 +603,358 @@ if ! skip; then
fi
# =============================================================================
# NOT BUILT YET
# 7. Secrets
# =============================================================================
# 6 Schema db:push
# 7 Build gen:index
# 8 Services pm2 startOrRestart · save · startup
# 9 Verify are the processes actually up
#
# 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.
report_section "Secrets"
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}"
report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt, headscale. Generated locally, never transmitted."
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
# =============================================================================
# 8. Schema
# =============================================================================
report_section "Schema"
step "Schema"
if ! skip; then
echo ""
# Counted from the aggregator rather than hardcoded, so the number is the truth
# even when a plugin line is uncommented. It was written as ${SCHEMA_TABLES:-?}
# and never assigned, so the section said "? tables" — a placeholder that looked
# like the count could not be determined rather than like nobody had set it.
SCHEMA_TABLES="$(schema_table_count)"
info "Database schema"
echo " ${SCHEMA_TABLES:-?} tables, applied with 'bun db:push' — drizzle-kit"
echo " diffs the schema code against Postgres and alters it directly. There"
echo " are no migration files and no migration table; the code is the source"
echo " of truth."
echo ""
echo " Only the CORE tables. Every plugin's tables are commented out in"
echo " src/databases/officer_db/src/schema.ts and get created when the"
echo " plugin is installed."
echo ""
if confirm "Push it?"; then
if OUT="$(push_schema)"; then
ok "schema applied"
report_changed "applied ${SCHEMA_TABLES} tables to Postgres with 'bun db:push' (drizzle-kit; no migration files)"
SUMMARY+=("Schema: ${SCHEMA_TABLES:-?} tables pushed")
else
warn "db:push failed"
echo "$OUT" | tail -12 | sed 's/^/ /'
SUMMARY+=("Schema: FAILED — see the output above")
fi
else
warn "skipped by request — the platform will not start without it"
SUMMARY+=("Schema: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 9. Build
# =============================================================================
report_section "Build"
step "Build"
if ! skip; then
echo ""
info "index.gen.html"
echo " 'bun gen:index' substitutes your public URL into index.html and"
echo " writes index.gen.html, which is the file the server imports. It is"
echo " gitignored, so a fresh clone never has one and the server has no page"
echo " to serve until this runs."
echo ""
echo " URL: ${ENV_PUBLIC_URL:-<not set — run the Environment section first>}"
echo ""
echo " To change it later: bun gen:index https://your.new.url"
echo ""
if [[ -z "$ENV_PUBLIC_URL" ]]; then
warn "PUBLIC_URL is not in $(env_file) — run the Environment section, then this one"
SUMMARY+=("Build: SKIPPED — no PUBLIC_URL")
elif confirm "Generate it?"; then
if OUT="$(gen_index)"; then
ok "$(gen_index_output)"
report_changed "generated $(gen_index_output) from index.html, substituting PUBLIC_URL=${ENV_PUBLIC_URL}"
SUMMARY+=("Build: index.gen.html for ${ENV_PUBLIC_URL}")
else
warn "gen:index failed"
echo "$OUT" | tail -8 | sed 's/^/ /'
SUMMARY+=("Build: FAILED — see the output above")
fi
else
warn "skipped by request — the server has no page to serve without it"
SUMMARY+=("Build: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 10. Services
# =============================================================================
report_section "Services"
step "Services"
if ! skip; then
echo ""
info "pm2 — $(ecosystem_file)"
echo " The ecosystem file is GENERATED, not checked in. It describes this"
echo " install and nothing else, so nothing in git can drift from it."
echo ""
echo " six processes:"
for entry in "${CORE_PROCESSES[@]}"; do
IFS='|' read -r _name _script _args <<<"$entry"
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
done
echo ""
echo " Nothing else. Every plugin adds its own entry when it is installed."
echo ""
if confirm "Write it and start them?"; then
write_ecosystem
ok "written — $(ecosystem_file)"
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
# Starting against a database that is not answering is not fatal — the server
# waits and the agent retries forever — but it makes the Verify section below
# report a failure that is really just a race, and that is the kind of noise
# that teaches people to ignore a red line.
if pg_container_running && ! pg_wait_ready 30; then
warn "Postgres is not answering — starting anyway, but Verify may report failures"
fi
if OUT="$(pm2_start)"; then
ok "processes started"
report_started "pm2 startOrRestart: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
pm2_save >/dev/null 2>&1 && ok "process list saved (survives a pm2 restart)"
echo ""
if confirm "Start them on boot too?"; then
if pm2_enable_startup; then
ok "pm2 will resurrect them at boot"
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
SUMMARY+=("Services: 6 processes started, enabled at boot")
else
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
fi
else
SUMMARY+=("Services: 6 processes started; not enabled at boot")
fi
else
warn "pm2 did not start cleanly"
echo "$OUT" | tail -12 | sed 's/^/ /'
SUMMARY+=("Services: FAILED to start — see the output above")
fi
else
warn "skipped by request"
SUMMARY+=("Services: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 11. Verify
# =============================================================================
report_section "Verify"
step "Verify"
if ! skip; then
echo ""
info "Are the processes actually up?"
echo ""
VERIFY_BAD=0
while IFS='|' read -r vname vstatus vrestarts; do
[[ -z "$vname" ]] && continue
if [[ "$vstatus" == "online" ]]; then
if (( vrestarts > 3 )); then
warn "$(printf '%-24s online, but restarted %s times — check: pm2 logs %s' "$vname" "$vrestarts" "$vname")"
VERIFY_BAD=$((VERIFY_BAD + 1))
else
ok "$(printf '%-24s online' "$vname")"
fi
else
warn "$(printf '%-24s %s — check: pm2 logs %s' "$vname" "$vstatus" "$vname")"
VERIFY_BAD=$((VERIFY_BAD + 1))
fi
done < <(pm2_status_lines)
echo ""
# A process can be `online` and still be failing to serve — a restart loop takes
# a few seconds to show up in the counter, and the app can be up with a broken
# database. So the port is asked directly.
if curl -fsS --max-time 5 "http://127.0.0.1:${ENV_PORT:-9000}/api" >/dev/null 2>&1; then
ok "the API answers on 127.0.0.1:${ENV_PORT:-9000}"
SUMMARY+=("Verify: API answering on port ${ENV_PORT:-9000}")
else
warn "nothing answered on 127.0.0.1:${ENV_PORT:-9000}/api"
echo " pm2 logs officer is where the reason will be."
VERIFY_BAD=$((VERIFY_BAD + 1))
SUMMARY+=("Verify: the API did NOT answer on port ${ENV_PORT:-9000}")
fi
if (( VERIFY_BAD == 0 )); then
echo ""
ok "Officer is running. Open ${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT:-9000}} and the"
echo " first-run screen will create the owner account."
fi
step_ok
fi
# =============================================================================
# 12. Proxy
# =============================================================================
#
# Optional, and last, because it is the only step that needs Officer to be already
# running: NPM proxies to it, and the gate below checks the bind address rather than
# taking a curl to loopback as proof.
#
# ── Why this section ignores --unattended ──
#
# Every other question in this script has a defensible default. None of these do — a
# domain name, a DNS provider and that provider's API credentials cannot be guessed —
# and the step is opt-in besides. So its prompts read stdin directly instead of going
# through confirm()/ask_required(), which honour ASSUME_YES.
#
# The valve is a TTY check, not the flag: with no terminal there is nobody to ask, so
# it skips and prints the manual instructions. That keeps a cron-driven install working
# without letting --unattended silently agree to publishing a public hostname.
report_section "Proxy"
step "Proxy"
if ! skip; then
echo ""
info "Reverse proxy — a real hostname and an HTTPS certificate"
echo " Optional. Skip it if you already run a proxy elsewhere, or if you"
echo " reach this instance over the tailnet and are happy with that."
echo ""
PROXY_PORT="${ENV_PORT:-9000}"
if [[ ! -t 0 ]]; then
warn "no terminal — skipping the proxy, which cannot be answered unattended"
proxy_skip_instructions "$PROXY_PORT"
SUMMARY+=("Proxy: skipped (no terminal)")
elif ! proxy_require_listening "$PROXY_PORT"; then
warn "skipping the proxy — Officer is not reachable the way NPM would reach it"
echo " Fix the bind address, then: officer-setup.sh --only Proxy"
SUMMARY+=("Proxy: skipped (Officer not listening on 0.0.0.0)")
elif ! proxy_confirm "Set up Nginx Proxy Manager now?"; then
proxy_skip_instructions "$PROXY_PORT"
SUMMARY+=("Proxy: skipped by request")
else
# One failure path for all of it: every function warns and returns non-zero rather
# than exiting, so a proxy that does not come up leaves a finished Officer install
# behind rather than a failed one. It is the last section for that reason.
PROXY_DOMAIN="$(proxy_ask 'Domain for this instance (e.g. officer.example.com)')"
if [[ -z "$PROXY_DOMAIN" ]]; then
warn "no domain given — skipping"
SUMMARY+=("Proxy: skipped (no domain)")
elif
proxy_detect_target &&
proxy_ensure_network &&
proxy_order_docker_after_tailscaled &&
proxy_write_compose &&
proxy_start &&
proxy_claim_admin &&
proxy_get_token &&
{ [[ "$CHALLENGE" != "dns" ]] || proxy_prompt_dns_credentials; } &&
proxy_wait_for_dns "$PROXY_DOMAIN" "$TARGET_IP" &&
proxy_allow_bridge_to_host "$PROXY_PORT" &&
proxy_create_host "$PROXY_DOMAIN" "$PROXY_PORT" &&
proxy_issue_certificate "$PROXY_DOMAIN" &&
proxy_attach_certificate
then
proxy_verify "$PROXY_DOMAIN"
echo ""
ok "Officer is published at https://${PROXY_DOMAIN}"
echo " NPM admin: http://127.0.0.1:81$([[ "$CHALLENGE" == "dns" ]] && echo " or http://${TARGET_IP}:81")"
SUMMARY+=("Proxy: https://${PROXY_DOMAIN}")
else
warn "the proxy did not finish — Officer itself is unaffected and still running"
echo " Retry just this part with: officer-setup.sh --only Proxy"
ERRORS+=("Proxy: did not finish")
SUMMARY+=("Proxy: FAILED — retry with --only Proxy")
fi
fi
step_ok
fi
# ── Who you are when this exits ──
#
# Root, and that surprises people — reasonably, because everything this script just
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
# the installer's privilege, never the platform's.
#
# Saying so matters for two things that are invisible until they bite:
#
# - group membership is fixed at login. ${USERNAME} was added to `docker` during
# machine setup, and a session that started before that does not have it — so
# `docker ps` fails for a reason that has nothing to do with docker.
# - the shell config was written into THEIR home. Staying as root means none of it
# is loaded, and the machine looks unconfigured.
if [[ "$EUID" -eq 0 ]]; then
echo ""
echo -e "${BOLD} One more thing — you are still root.${NC}"
echo ""
echo " Officer runs as ${USERNAME}, and everything it installed is theirs."
echo " Nothing here needs root any more. To carry on as them:"
echo ""
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
echo ""
echo " Either gives a new session, which is what makes their docker group"
echo " membership and their shell configuration take effect. Staying as root"
echo " means neither does, and the machine will look half-configured."
fi
echo ""
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
echo ""
report_mark_complete
+8
View File
@@ -113,6 +113,14 @@ confirm() {
ask_required() {
local __var="$1" message="$2" default="$3" answer=""
# Unattended takes the default where there IS one. Where there is not — the owning
# account on a machine that machine-setup never ran on — it still asks, because
# there is nothing to fall back to and a guess would install as the wrong user.
if [[ "${UNATTENDED:-}" == "1" && -n "$default" ]]; then
printf ' %s [%s] — unattended, taking the default\n' "$message" "$default"
printf -v "$__var" '%s' "$default"
return 0
fi
while [[ -z "$answer" ]]; do
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
echo ""
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# =============================================================================
# officer-setup — schema and build
# =============================================================================
#
# Definitions only.
#
# Both run AS the owner, from the repo. Neither is idempotent in the sense of
# "does nothing the second time" — both are safe to repeat, which is not the same
# thing and is the property that matters for a script people re-run.
[[ -n "${OFFICER_SETUP_BUILD_LOADED:-}" ]] && return 0
OFFICER_SETUP_BUILD_LOADED=1
# `bun db:push` — drizzle-kit diffs the schema code against the live database.
#
# No migrations here and no __drizzle_migrations table: the schema code IS the
# source of truth (src/databases/CLAUDE.md). On the empty database section 5 just
# created there is nothing to drop, so the prompt drizzle-kit shows for a
# destructive change cannot appear.
#
# It can still appear on a RE-RUN against a database with data, and a prompt
# nobody sees would hang the script forever — so stdin is closed rather than left
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
# want at 3am.
push_schema() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
}
# What tables the schema will create, read from the aggregator rather than
# guessed. This is what makes the section able to say what it is about to do.
schema_table_count() {
local dir
dir="$(platform_dir)/src/databases/officer_db/src"
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
done | awk '{s+=$1} END {print s+0}'
}
# `bun gen:index` — substitutes PUBLIC_URL into index.html and writes
# index.gen.html, which is what the server actually imports.
#
# Not optional and not cosmetic: without it the server has no page to serve. It
# is gitignored, so a fresh clone never has one.
gen_index() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
}
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
+54 -10
View File
@@ -5,16 +5,14 @@
#
# Definitions only.
#
# ── What is NOT here ──
# ── No secrets are written here ──
#
# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite
# key store (docs/secret-store.md), and writing them here in the meantime would
# mean generating a value that the store then has to be reconciled with — two
# origins for one secret, which is the failure the store exists to end.
# 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.
#
# The consequence is honest and deliberate: jwt.ts throws at module load without
# JWT_SECRET, so an install made by this script does not boot until the store
# lands. That sequencing was chosen rather than stumbled into.
# So this file holds no credential except POSTGRES_URL, which is a connection
# string to a database bound to loopback.
#
# ── Derived, not asked ──
#
@@ -65,8 +63,8 @@ write_env() {
PORT="${ENV_PORT}"
# The browser relay listens on its own port, separate from the app.
BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
# Where Officer is reached from a browser. Not derivable — see the section.
PUBLIC_URL="${ENV_PUBLIC_URL}"
POSTGRES_URL="${POSTGRES_URL}"
@@ -78,3 +76,49 @@ ENVF
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
dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)"
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
}
+492
View File
@@ -0,0 +1,492 @@
#!/bin/bash
# officer-setup — Nginx Proxy Manager, the optional last step.
#
# Publishes the running instance on a real hostname with a Let's Encrypt certificate.
# Entirely optional: someone with a proxy elsewhere declines and is printed the values
# they need instead.
#
# PRECONDITION: Officer is running and bound to 0.0.0.0. Checked, not assumed — see
# proxy_require_listening.
#
# ── Why this section ignores --unattended ──
#
# Every other question in this script has a defensible default. None of these do: a
# domain name, a DNS provider and that provider's API credentials cannot be guessed,
# and the whole step is opt-in besides. So the prompts here read stdin directly rather
# than going through confirm()/ask_required(), which honour ASSUME_YES.
#
# The safety valve is a TTY check rather than the flag: with no terminal there is
# nobody to ask, so the section skips itself and prints the manual instructions. That
# covers a cron-driven install without making --unattended silently agree to a proxy.
[[ -n "${OFFICER_SETUP_PROXY_LOADED:-}" ]] && return 0
OFFICER_SETUP_PROXY_LOADED=1
# `${OFFICER_ROOT}/dockers`, matching src/servers/data-path.ts, which derives that
# directory from the install root. The draft used $HOME/dockers, which is a different
# place on every machine and not the one the app store provisions into.
proxy_dir() { echo "${OFFICER_ROOT}/dockers/nginx-proxy-manager"; }
# The network machine-setup already created. It defaults to `services` there, so a
# second name would leave two bridges on the same box with containers unable to see
# each other by name.
PROXY_NET="${SETUP_DOCKER_NETWORK:-services}"
PROXY_API="http://127.0.0.1:81/api"
# ── prompts that always ask ──
#
# Deliberately not confirm()/ask_required(): see the header. Named apart so nobody
# later "fixes" them into the shared helpers and quietly makes --unattended agree to
# provisioning a public hostname.
proxy_confirm() {
local answer
read -rp " $1 [y/N]: " answer || return 1
[[ "$answer" =~ ^[Yy] ]]
}
proxy_ask() {
local answer
read -rp " $1: " answer || return 1
printf '%s' "$answer"
}
# ── 0. is Officer reachable the way NPM will reach it? ──
#
# `curl 127.0.0.1:$PORT` succeeds even when the process binds loopback ONLY, which is
# exactly the case NPM cannot reach: it dials from inside a container, where 127.0.0.1
# is the container itself. Passing this gate on a curl check produces a 504 later that
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
proxy_require_listening() {
local port="$1" listen
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
[[ -n "$listen" ]] || {
warn "nothing is listening on port ${port} — start Officer first"
return 1
}
if ! grep -qE '(^|\s)(0\.0\.0\.0|\*):'"$port"'$' <<<"$listen"; then
warn "Officer is listening on: ${listen}"
info "NPM runs in a container, so 127.0.0.1 there is the container itself."
info "A loopback-only listener is invisible to it and yields a 504."
return 1
fi
ok "Officer is listening on 0.0.0.0:${port}"
}
# ── 1. where will the hostname point? ──
#
# Tailnet DNS-01 is mandatory. Let's Encrypt cannot reach 100.64.0.0/10, so HTTP-01
# always fails. The A record is not needed to ISSUE (validation is a TXT
# record) but is needed to USE the name.
# Public HTTP-01 works with no API keys, but the A record must already resolve here.
proxy_detect_target() {
local ts=""
command -v tailscale >/dev/null 2>&1 && ts="$(tailscale ip -4 2>/dev/null | head -1 || true)"
if [[ -n "$ts" ]]; then
TARGET_IP="$ts"
CHALLENGE="dns"
ok "Tailscale detected — ${TARGET_IP}"
info "Tailnet addresses are unreachable from Let's Encrypt, so the certificate"
info "needs a DNS-01 challenge, which needs your DNS provider's API credentials."
else
TARGET_IP="$(curl -sf --max-time 10 https://api.ipify.org || true)"
[[ -n "$TARGET_IP" ]] || {
warn "could not determine this machine's public IP"
return 1
}
CHALLENGE="http"
ok "No Tailscale — public IP ${TARGET_IP} (HTTP-01, no API keys needed)"
fi
}
# Read by indirect expansion — `${!hint}` where hint is "DNS_HINT_${DNS_PROVIDER}" —
# which shellcheck cannot follow, hence the disable rather than a rewrite. Naming them
# this way is what lets a provider with no hint simply not have one.
# shellcheck disable=SC2034
DNS_HINT_godaddy="Create an API key at https://developer.godaddy.com/keys (Production).
You need both the Key and the Secret. Scope it to DNS only if offered."
# shellcheck disable=SC2034
DNS_HINT_cloudflare="Create a token at https://dash.cloudflare.com/profile/api-tokens
Use template 'Edit zone DNS'. Permissions: Zone:DNS:Edit for the zone."
# shellcheck disable=SC2034
DNS_HINT_digitalocean="Create a Personal Access Token with WRITE scope at
https://cloud.digitalocean.com/account/api/tokens"
# The exact credential file format per provider ships INSIDE the NPM image, so it is
# read from there rather than hardcoded — that keeps working as certbot plugins change.
proxy_prompt_dns_credentials() {
echo ""
info "Supported providers include: cloudflare, godaddy, digitalocean, route53,"
info "namecheap, ovh, linode, vultr, hetzner, gandi, google, azure …"
DNS_PROVIDER="$(proxy_ask 'DNS provider')"
[[ -n "$DNS_PROVIDER" ]] || {
warn "no provider given"
return 1
}
local hint="DNS_HINT_${DNS_PROVIDER}"
[[ -n "${!hint:-}" ]] && {
echo ""
info "${!hint}"
}
echo ""
info "Credential format this provider expects:"
docker exec npm python3 -c \
"import json;d=json.load(open('/app/certbot/dns-plugins.json'));print(d['${DNS_PROVIDER}']['credentials'])" \
2>/dev/null | sed 's/^/ /' ||
warn "could not read the template — check the provider name is spelled correctly"
echo ""
info "Paste the credential lines exactly as shown above (blank line to finish):"
DNS_CREDENTIALS=""
local line
while IFS= read -r line; do
[[ -z "$line" ]] && break
DNS_CREDENTIALS+="$line"$'\n'
done
[[ -n "$DNS_CREDENTIALS" ]] || {
warn "no credentials entered"
return 1
}
}
# ── 2. wait for DNS ──
#
# `getent hosts` rather than `dig`: dig comes from dnsutils, which this platform does
# not install, so the draft's version was command-not-found on a fresh VPS — and since
# an empty answer is indistinguishable from "not resolving yet", it waited the full
# thirty minutes before failing. getent is in libc and always there.
#
# The cost is that it reads the system resolver rather than a public one, so a stale
# local cache can satisfy it. Worth it against a check that cannot run at all.
proxy_wait_for_dns() {
local domain="$1" want="$2" got elapsed=0 interval=15 timeout=1800
echo ""
info "Point this DNS record at the machine now:"
echo ""
info " ${domain}. A ${want}"
echo ""
[[ "$CHALLENGE" == "dns" ]] &&
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
while ((elapsed < timeout)); do
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
if [[ "$got" == "$want" ]]; then
ok "${domain} resolves to ${want}"
return 0
fi
printf '\r waiting — %s (%ss) ' "${got:-not resolving yet}" "$elapsed"
sleep "$interval"
elapsed=$((elapsed + interval))
done
echo ""
warn "${domain} still does not resolve to ${want} after $((timeout / 60)) minutes"
[[ "$CHALLENGE" == "dns" ]] && proxy_confirm "Continue anyway and issue the certificate?" && return 0
warn "cannot issue an HTTP-01 certificate until DNS resolves here"
return 1
}
proxy_ensure_network() {
docker network inspect "$PROXY_NET" >/dev/null 2>&1 && return 0
docker network create "$PROXY_NET" >/dev/null && ok "created docker network ${PROXY_NET}"
}
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
# address does not exist yet and the WHOLE container fails to start, not just that port.
proxy_order_docker_after_tailscaled() {
[[ "$CHALLENGE" == "dns" ]] || return 0
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
[[ -f "$f" ]] && return 0
mkdir -p "$(dirname "$f")"
cat >"$f" <<'EOF'
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled, that
# address does not exist and the container fails to start entirely.
[Unit]
After=tailscaled.service
Wants=tailscaled.service
EOF
systemctl daemon-reload
ok "docker ordered after tailscaled"
report_changed "$f" "docker ordered after tailscaled so NPM can bind the tailnet IP"
}
# Admin UI (81) is NEVER published on 0.0.0.0. Until it is claimed, anyone who reaches
# it can take the instance; afterwards it can issue certificates and re-point every
# proxied service on the box. 80/443 are public only when they need to be.
proxy_write_compose() {
local dir admin_binds public_binds
dir="$(proxy_dir)"
install -d -o "$USERNAME" -g "$(user_group)" "$dir" "$dir/npm_data" "$dir/letsencrypt"
admin_binds=" - \"127.0.0.1:81:81\""
if [[ "$CHALLENGE" == "dns" ]]; then
admin_binds+=$'\n'" - \"${TARGET_IP}:81:81\""
public_binds=" - \"${TARGET_IP}:80:80\""$'\n'" - \"${TARGET_IP}:443:443\""
else
public_binds=" - \"80:80\""$'\n'" - \"443:443\""
fi
cat >"${dir}/docker-compose.yaml" <<EOF
# Generated by officer-setup. Reverse proxy for this Officer instance.
#
# The admin UI (81) is bound to loopback$([[ "$CHALLENGE" == "dns" ]] && echo " and the tailnet") only, never
# 0.0.0.0 — it can issue certificates and re-point every proxied service on this box.
#
# NOTE: ufw does NOT filter docker-published ports. Exposure is decided by the bind
# addresses below and by the DOCKER-USER chain in /etc/ufw/after.rules.
name: npm
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: npm
restart: always
networks: [${PROXY_NET}]
ports:
${public_binds}
${admin_binds}
volumes:
- ./npm_data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
${PROXY_NET}:
external: true
EOF
chown "${USERNAME}:$(user_group)" "${dir}/docker-compose.yaml"
ok "wrote ${dir}/docker-compose.yaml"
report_changed "${dir}/docker-compose.yaml" "nginx-proxy-manager compose file"
}
proxy_start() {
as_owner "docker compose --project-directory '$(proxy_dir)' up -d" / >/dev/null
local i
for i in $(seq 1 60); do
curl -sf "$PROXY_API/" >/dev/null 2>&1 && {
ok "NPM answered after ${i}s"
report_started "npm" "nginx-proxy-manager container"
return 0
}
sleep 1
done
warn "NPM did not become ready — check: docker logs npm"
return 1
}
# ── claim the admin account immediately ──
#
# NPM 2.15 replaced the fixed default login with a first-run wizard: while the user
# count is zero, ANYONE who reaches port 81 can claim admin. Done in the same breath as
# starting the container. The bind addresses above already make that window unreachable
# from outside, but this does not rely on that alone.
#
# The re-run path is the half the draft was missing: it returned early on an already
# claimed instance WITHOUT setting NPM_EMAIL/NPM_PASSWORD, and the next function
# dereferenced both under `set -u`. So the second run of a "re-runnable" script died on
# an unbound variable. An existing instance asks for the credentials instead.
proxy_claim_admin() {
if curl -sf "$PROXY_API/" | grep -q '"setup":true'; then
ok "NPM admin is already claimed"
echo ""
info "This instance already has an admin account. Its credentials are needed to"
info "add the proxy host below."
NPM_EMAIL="$(proxy_ask 'NPM admin email')"
NPM_PASSWORD="$(proxy_ask 'NPM admin password')"
[[ -n "$NPM_EMAIL" && -n "$NPM_PASSWORD" ]] || {
warn "both are needed to continue"
return 1
}
return 0
fi
echo ""
info "Create the NPM admin account."
NPM_EMAIL="$(proxy_ask 'Admin email')"
[[ -n "$NPM_EMAIL" ]] || {
warn "no email given"
return 1
}
NPM_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)"
curl -sf -X POST "$PROXY_API/users" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg e "$NPM_EMAIL" --arg p "$NPM_PASSWORD" \
'{name:"Admin",nickname:"Admin",email:$e,roles:["admin"],is_disabled:false,auth:{type:"password",secret:$p}}')" \
>/dev/null || {
warn "failed to create the NPM admin user"
return 1
}
curl -sf "$PROXY_API/" | grep -q '"setup":true' || {
warn "admin creation did not take"
return 1
}
ok "NPM admin claimed: ${NPM_EMAIL}"
# ── the admin password ──
#
# Deliberately NOT written to a file. The platform's shape is that
# secrets/officer-keys.db holds ENCRYPTION KEYS, one per purpose, and the credential
# itself lives encrypted in Postgres. A third plaintext location is the pattern
# headscale/schema.ts calls "debt to avoid copying, not a precedent to follow".
#
# Nothing programmatic needs this after setup — only a human logging into the admin
# UI — so not storing it is a legitimate outcome rather than a gap.
#
# The DNS API credentials are deliberately never handled either: NPM must keep a
# plaintext copy in npm_data/database.sqlite for certbot to auto-renew, so copying
# them anywhere else adds exposure without adding protection.
echo ""
warn "This password is shown ONCE and is not stored anywhere:"
echo ""
echo " ${NPM_EMAIL}"
echo " ${NPM_PASSWORD}"
echo ""
info "Put it in your password manager now."
proxy_confirm "Saved it?" || {
warn "stopping so the password is not lost — the container is running and claimed"
return 1
}
}
proxy_api() {
local method="$1" path="$2" body="${3:-}"
if [[ -n "$body" ]]; then
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d "$body"
else
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN"
fi
}
proxy_get_token() {
TOKEN="$(curl -sf -X POST "$PROXY_API/tokens" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg i "$NPM_EMAIL" --arg s "$NPM_PASSWORD" '{identity:$i,secret:$s}')" |
jq -r '.token')" || {
warn "could not authenticate to the NPM API"
return 1
}
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || {
warn "NPM rejected those admin credentials"
return 1
}
}
# ── let the bridge reach the host process ──
#
# Officer runs on the HOST under pm2, not in a container. Bridge → host traffic DOES
# traverse INPUT, so ufw's default-deny drops it — unlike docker-published ports, which
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
# upstream would need none of this, which is why container upstreams are preferable when
# there is a choice.
proxy_allow_bridge_to_host() {
local port="$1" subnet
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
ok "ufw already allows the bridge to reach port ${port}"
else
ufw allow from "$subnet" to any port "$port" proto tcp >/dev/null
ok "ufw: allowed ${subnet} → :${port}"
report_changed "ufw" "allowed ${subnet} to reach port ${port} (bridge to host)"
fi
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
}
# Created WITHOUT ssl first, deliberately. Enabling force-SSL before a certificate
# exists gives a host that 301s to https and then fails the handshake — curl reports
# 000, which reads like a network fault rather than a config mistake.
proxy_create_host() {
local domain="$1" port="$2" existing
existing="$(proxy_api GET /nginx/proxy-hosts | jq -r --arg d "$domain" \
'map(select(.domain_names | index($d))) | .[0].id // empty')"
if [[ -n "$existing" ]]; then
HOST_ID="$existing"
ok "proxy host already exists (id ${HOST_ID})"
return 0
fi
HOST_ID="$(proxy_api POST /nginx/proxy-hosts "$(jq -nc \
--arg d "$domain" --arg h "$BRIDGE_GATEWAY" --argjson p "$port" \
'{domain_names:[$d],forward_scheme:"http",forward_host:$h,forward_port:$p,
access_list_id:0,certificate_id:0,block_exploits:true,caching_enabled:false,
allow_websocket_upgrade:true,ssl_forced:false,http2_support:false,
hsts_enabled:false,hsts_subdomains:false,meta:{},advanced_config:"",locations:[]}')" |
jq -r '.id')"
[[ -n "$HOST_ID" && "$HOST_ID" != "null" ]] || {
warn "could not create the proxy host"
return 1
}
ok "proxy host created (id ${HOST_ID}) → ${BRIDGE_GATEWAY}:${port}"
}
# NPM 2.15 REMOVED letsencrypt_email and letsencrypt_agree from the certificate schema.
# Sending them returns: 400 data/meta must NOT have additional properties.
proxy_issue_certificate() {
local domain="$1" meta
CERT_ID="$(proxy_api GET /nginx/certificates | jq -r --arg d "$domain" \
'map(select(.domain_names | index($d))) | .[0].id // empty')"
[[ -n "$CERT_ID" ]] && {
ok "certificate already exists (id ${CERT_ID})"
return 0
}
if [[ "$CHALLENGE" == "dns" ]]; then
meta="$(jq -nc --arg p "$DNS_PROVIDER" --arg c "$DNS_CREDENTIALS" \
'{dns_challenge:true,dns_provider:$p,dns_provider_credentials:$c,propagation_seconds:120}')"
info "Requesting the certificate via DNS-01 — about two minutes, for the plugin"
info "install and DNS propagation."
else
meta='{"dns_challenge":false}'
info "Requesting the certificate via HTTP-01"
fi
CERT_ID="$(proxy_api POST /nginx/certificates "$(jq -nc \
--arg d "$domain" --argjson m "$meta" \
'{provider:"letsencrypt",nice_name:$d,domain_names:[$d],meta:$m}')" | jq -r '.id')"
[[ -n "$CERT_ID" && "$CERT_ID" != "null" ]] || {
warn "the certificate request failed — see: docker logs npm"
return 1
}
ok "certificate issued (id ${CERT_ID})"
}
proxy_attach_certificate() {
proxy_api PUT "/nginx/proxy-hosts/${HOST_ID}" "$(jq -nc --argjson c "$CERT_ID" \
'{certificate_id:$c,ssl_forced:true,http2_support:true,hsts_enabled:false,hsts_subdomains:false}')" \
>/dev/null || {
warn "could not attach the certificate"
return 1
}
ok "certificate attached, force-SSL and HTTP/2 on"
}
proxy_verify() {
local domain="$1" code
code="$(curl -so /dev/null -w '%{http_code}' --max-time 20 \
--resolve "${domain}:443:${TARGET_IP}" "https://${domain}/" || echo 000)"
case "$code" in
200 | 30[0-9]) ok "https://${domain}${code}" ;;
000) warn "TLS handshake failed — certificate not attached, or force-SSL set before it existed" ;;
502) warn "502 — nothing listening on the upstream port" ;;
504) warn "504 — upstream unreachable: ufw dropping bridge→host, or the wrong forward_host" ;;
*) warn "unexpected response: ${code}" ;;
esac
}
proxy_skip_instructions() {
local port="$1" gw
gw="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || echo '<bridge-gateway>')"
cat <<EOF
To put Officer behind your own proxy, point it at:
http://<this-machine>:${port}
If that proxy runs in a container ON this machine, use ${gw}:${port} — inside a
container 127.0.0.1 is the container itself — and let it through ufw:
ufw allow from <container-subnet> to any port ${port} proto tcp
Officer must bind 0.0.0.0, not 127.0.0.1, or the proxy cannot reach it.
EOF
}
+16
View File
@@ -14,6 +14,22 @@
[[ -n "${OFFICER_SETUP_REPO_LOADED:-}" ]] && return 0
OFFICER_SETUP_REPO_LOADED=1
# Public HTTPS, which is what this needed all along.
#
# It was ssh://git@gitea.pastilhas.dev:2222/... until 2026-08-14, and the reason was
# that the repository was private: an HTTPS clone of a private repo prompts for a
# username, and under sudo with no interactive terminal that hangs or dies with
# "could not read Username". The note here said "back to HTTPS when the repository is
# public", and it now is — verified with an anonymous `git ls-remote`.
#
# The change matters more than a URL swap. An SSH default cannot clone on a genuinely
# fresh machine: the key machine-setup generates there is brand new and Gitea has
# never seen it, so `--repo` was effectively mandatory on a first install. HTTPS needs
# no key and no agent, so the default now works on a blank box.
#
# If this ever goes private again, SSH is the answer and the constraint above is the
# reason — plus one more: the clone runs as the OWNER, and sudo drops SSH_AUTH_SOCK,
# so a passphrase-protected key has no agent to answer it.
OFFICER_REPO="${OFFICER_REPO:-https://gitea.officer.dev/officerdev/platform.git}"
platform_dir() { echo "${OFFICER_ROOT}/platform"; }
@@ -0,0 +1,40 @@
#!/bin/bash
# =============================================================================
# officer-setup — the secret store
# =============================================================================
#
# Definitions only.
#
# The store is $OFFICER_ROOT/secrets/officer-keys.db, deliberately a sibling of
# the repo and NOT under data/ — that directory holds the managed homes and
# attachments people back up, and a key store travelling in the same tarball as a
# database dump rebuilds the exact problem it exists to avoid.
#
# Bootstrapping runs the platform's own module rather than reimplementing the
# schema in bash. There is exactly one writer of this file's format, and a second
# one in shell would drift the first time a column is added.
[[ -n "${OFFICER_SETUP_SECRETS_LOADED:-}" ]] && return 0
OFFICER_SETUP_SECRETS_LOADED=1
secret_store_dir() { echo "${OFFICER_ROOT}/secrets"; }
secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; }
# Create the store and the two purposes a core install needs.
#
# Run AS the owner, not as root: the platform runs as them, and a store root
# created would be a store they cannot write. `install -d -o` sets the owner in
# one step rather than mkdir-then-chown, so it is never briefly root's.
bootstrap_secret_store() {
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(secret_store_dir)" || return 1
# From the repo, because the module derives the install root as the parent of
# the working directory — the same rule as src/servers/data-path.ts.
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
const { getKey } = await import('officerdb/secret-store');
getKey('jwt');
getKey('headscale');
\"" >/dev/null 2>&1 || return 1
[[ -f "$(secret_store_path)" ]]
}
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# =============================================================================
# officer-setup — the pm2 ecosystem file, and starting the processes
# =============================================================================
#
# Definitions only.
#
# ── The ecosystem file is GENERATED, and is not in git ──
#
# There used to be four of them — ecosystem.config.cjs, .light., .mac.light. and
# a .profile. that the others derived from. A profile deriving from a full list
# means the full list has to exist, which means every plugin's process is
# described in the repository whether or not anybody installed it, and a test had
# to assert that the two files still agreed with each other.
#
# One generated file removes all of that. It describes exactly the processes this
# install runs, it is written once at setup, and nothing in git can drift from
# it. A plugin adds its own entry when it is installed.
#
# ── Why .cjs and not .js ──
#
# PM2's own convention is ecosystem.config.js, and it would be wrong here:
# package.json declares "type": "module", so a .js file in this directory is ESM
# and `module.exports` throws "module is not defined in ES module scope". PM2
# require()s the config, so the extension has to say CommonJS out loud.
[[ -n "${OFFICER_SETUP_SERVICES_LOADED:-}" ]] && return 0
OFFICER_SETUP_SERVICES_LOADED=1
ecosystem_file() { echo "$(platform_dir)/ecosystem.config.cjs"; }
# The processes a core install runs. Everything else is a plugin.
#
# `officer-pty` is node rather than bun, and that is not an oversight: it loads
# node-pty, a native module built against Node's ABI. Everything else is bun.
#
# `officer-claude-code` was `officer-agent` until 2026-08-13. The old name said
# nothing about what it runs, and it sits beside officer-anthropic-proxy — which
# is a different process doing a different job — so "the agent" was ambiguous
# exactly where it mattered. It spawns `claude`; the name says so now.
CORE_PROCESSES=(
"officer|bun|start"
"officer-anthropic-proxy|bun|run src/servers/sidecar/claude/index.ts"
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
)
write_ecosystem() {
local dest entry name script args
dest="$(ecosystem_file)"
{
cat <<'HEADER'
// Generated by officer-setup. Not in git, and not meant to be — it describes THIS
// install, and the next machine generates its own.
//
// `cwd` is pinned on every app for two reasons. Bun auto-loads .env from the
// working directory (and the pty sidecar does `import 'dotenv/config'`), so
// without it a process started from anywhere else comes up with no POSTGRES_URL.
// And src/servers/data-path.ts derives the install root as the PARENT of the
// working directory, so a wrong cwd does not fail — it relocates data/,
// capabilities/ and dockers/ somewhere else entirely. `assertInstallLayout`
// refuses to boot when that happens.
//
// To add a plugin later, add its entry here. Nothing derives this file from
// anything, so there is no second list to keep it agreeing with.
module.exports = {
apps: [
HEADER
for entry in "${CORE_PROCESSES[@]}"; do
IFS='|' read -r name script args <<<"$entry"
printf " { name: '%s', script: '%s', args: '%s', cwd: '%s', watch: false },\n" \
"$name" "$script" "$args" "$(platform_dir)"
done
cat <<'FOOTER'
],
};
FOOTER
} >"$dest"
chown "${USERNAME}:$(user_group)" "$dest"
return 0
}
pm2_start() {
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
}
pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; }
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
# doing it — so this runs what it prints, which is the whole point of already
# being root here.
pm2_enable_startup() {
local cmd
cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
[[ -z "$cmd" ]] && return 1
eval "${cmd#sudo }"
}
# One line per process: name, status, restarts.
pm2_status_lines() {
sudo -u "$USERNAME" pm2 jlist 2>/dev/null |
node -e '
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
let apps = []; try { apps = JSON.parse(s); } catch { }
for (const a of apps) {
const st = a.pm2_env?.status ?? "?";
console.log(`${a.name}|${st}|${a.pm2_env?.restart_time ?? 0}`);
}
});'
}
+184
View File
@@ -0,0 +1,184 @@
#!/bin/bash
# =============================================================================
# The install report
# =============================================================================
#
# Every run writes a timestamped markdown file recording what it installed, what
# it changed, what it left alone, and what it ran as root.
#
# ── Who it is for ──
#
# Not us. It exists so the person who just ran a setup script off the internet
# can hand the result to an agent of THEIR choosing and ask "did this do anything
# it should not have". That is an adversarial read by someone who does not trust
# us, which decides almost every choice below:
#
# Facts, not narration. "installed docker-ce" is checkable. "set up Docker" is
# a claim. Every entry names the thing precisely enough to verify against the
# machine afterwards.
#
# Recorded by the HELPERS, not by the sections. A section that has to remember
# to report is a section that will forget, and an incomplete report is worse
# than none — it reads as a full account. `pkg_install` and `install_config`
# record themselves, so anything installed or written through them appears
# whether or not the section author thought about it.
#
# Kept and skipped are recorded too. "Left your .zshrc alone" is the claim a
# reviewer most wants substantiated, and it is invisible unless stated.
#
# NO SECRETS. The whole point is that this file gets shared. Passwords, keys
# and connection strings are redacted at the moment of recording rather than
# filtered later — see `report_redact`.
#
# ── Shape ──
#
# Facts accumulate in an array during the run and the file is rendered at the
# end, so a crash halfway leaves no half-written report claiming to be complete.
# `report_flush` is called by the exit trap, which marks it INCOMPLETE and says
# where it stopped.
[[ -n "${OFFICER_REPORT_LOADED:-}" ]] && return 0
OFFICER_REPORT_LOADED=1
REPORT_FACTS=()
REPORT_SECTION="(start)"
REPORT_STARTED="$(date '+%Y-%m-%d %H:%M:%S %Z')"
REPORT_COMPLETE=false
# Where it goes. install.sh exports REPORT_FILE so both halves land in ONE file;
# a half run on its own makes its own.
report_path() {
if [[ -n "${REPORT_FILE:-}" ]]; then
echo "$REPORT_FILE"
return
fi
local base="${OFFICER_ROOT:-${USER_HOME:-$HOME}}"
[[ -d "$base" ]] || base="${USER_HOME:-$HOME}"
echo "${base}/install-report-$(date '+%Y%m%d-%H%M%S').md"
}
# Redact anything that looks like a credential.
#
# Applied when the fact is RECORDED, not when it is rendered, so a secret never
# sits in memory formatted for printing and cannot be leaked by a future change
# to the renderer. Deliberately blunt: a password that survives is a leak, a URL
# over-redacted is an inconvenience.
report_redact() {
sed -E \
-e 's#(://[^:/@[:space:]]+):[^@[:space:]]+@#\1:REDACTED@#g' \
-e 's#((password|passwd|secret|token|key|apikey|api_key)[[:space:]]*[=:][[:space:]]*)[^[:space:]]+#\1REDACTED#gI'
}
report_section() { REPORT_SECTION="$1"; }
# One fact. `kind` is what a reviewer scans for: installed, kept, changed,
# skipped, ran, started, failed.
report_fact() {
local kind="$1" text="$2"
REPORT_FACTS+=("${REPORT_SECTION}|${kind}|$(printf '%s' "$text" | report_redact | tr '\n' ' ')")
}
report_installed() { report_fact installed "$1"; }
report_kept() { report_fact kept "$1"; }
report_changed() { report_fact changed "$1"; }
report_skipped() { report_fact skipped "$1"; }
report_started() { report_fact started "$1"; }
report_failed() { report_fact failed "$1"; }
# A command run with privilege. The reviewer's first question is "what did it run
# as root", and the honest answer is a list rather than a promise.
report_ran() { report_fact ran "$1"; }
report_mark_complete() { REPORT_COMPLETE=true; }
# Render. Safe to call twice; the trap and a normal finish both reach it.
report_flush() {
local dest kinds k
dest="$(report_path)"
[[ -n "${REPORT_WRITTEN:-}" ]] && return 0
REPORT_WRITTEN=1
{
echo "# Officer install report"
echo ""
if $REPORT_COMPLETE; then
echo "**Status:** finished."
else
echo "**Status: INCOMPLETE — the run stopped during \`${REPORT_SECTION}\`.**"
echo "Everything below still happened; what comes after it did not."
fi
echo ""
echo "| | |"
echo "| --- | --- |"
echo "| started | ${REPORT_STARTED} |"
echo "| finished | $(date '+%Y-%m-%d %H:%M:%S %Z') |"
echo "| host | $(hostname 2>/dev/null || echo unknown) |"
echo "| system | $(uname -srm) |"
echo "| account | ${USERNAME:-$(id -un)} |"
echo "| script commit | $(git -C "${SCRIPT_DIR:-.}" rev-parse --short HEAD 2>/dev/null || echo 'not a git checkout') |"
echo ""
echo "---"
echo ""
echo "## How to review this"
echo ""
echo "This file exists so you can hand it to someone — or something — that does"
echo "not trust the script that wrote it. It is a list of facts, each meant to be"
echo "checkable against the machine rather than taken on faith."
echo ""
echo "Worth asking of it:"
echo ""
echo "- Does anything under **installed** come from somewhere other than your"
echo " distribution's repositories, Homebrew, or a vendor's documented installer?"
echo "- Does anything under **changed** touch a file outside this install, your"
echo " home directory, or the system configuration a setup script would be"
echo " expected to touch?"
echo "- Does anything under **ran** do more than the section it sits under claims?"
echo "- Is anything **started** that you did not ask for?"
echo ""
echo "Credentials are redacted where they were recorded. If you find one that is"
echo "not, that is a bug worth reporting — this file is meant to be shareable."
echo ""
echo "What this report does NOT cover: anything a package's own post-install"
echo "script did. Reviewing \`docker-ce\` itself is a different exercise from"
echo "reviewing the script that installed it."
echo ""
echo "---"
echo ""
if ((${#REPORT_FACTS[@]} == 0)); then
echo "_Nothing was recorded — no section made a change._"
else
local last=""
local line section kind text
for line in "${REPORT_FACTS[@]}"; do
section="${line%%|*}"
kind="${line#*|}"; kind="${kind%%|*}"
text="${line#*|*|}"
if [[ "$section" != "$last" ]]; then
[[ -n "$last" ]] && echo ""
echo "## ${section}"
echo ""
last="$section"
fi
printf -- '- **%s** — %s\n' "$kind" "$text"
done
fi
echo ""
echo "---"
echo ""
echo "## Summary by kind"
echo ""
for k in installed changed kept skipped started ran failed; do
local n
n="$(printf '%s\n' "${REPORT_FACTS[@]}" | grep -c "|${k}|" || true)"
printf -- '- %-10s %s\n' "$k" "$n"
done
} >"$dest" 2>/dev/null
[[ -n "${USERNAME:-}" ]] && chown "${USERNAME}:$(id -gn "$USERNAME" 2>/dev/null || echo "$USERNAME")" "$dest" 2>/dev/null || true
chmod 0644 "$dest" 2>/dev/null || true
echo ""
echo " Install report: ${dest}"
}
+46
View File
@@ -0,0 +1,46 @@
# Officer — the owner's shell configuration.
#
# EMPTY ON PURPOSE, for now. Created 2026-08-13 so there is somewhere to put the
# things the owner actually wants, and it is not wired into the Shell section yet.
#
# ── What this replaces, and the decision still to make ──
#
# The Shell section does not install a .zshrc today. It APPENDS four
# marker-wrapped blocks to whatever is already there — `starship`, `agent`,
# `aliases` and `editor` — via `append_once`, which recognises its own work so a
# second run does not duplicate it. That was the right call for a machine whose
# .zshrc already belongs to somebody.
#
# Installing a whole file is a different promise, and the two do not compose: a
# template that gets installed AND appended to ends up with the same lines twice,
# once from the file and once from a block. So when this is wired in, the four
# append_once blocks either move INTO this file or stay out of it — not both.
#
# `install_config` already handles the careful half: it writes only when the
# destination is missing or still byte-for-byte the template, and offers a diff
# otherwise, so an owner's own edits are never overwritten.
#
# ── Where the shell templates live ──
#
# scripts/setup/{starship.toml, tmux.conf, zshrc}, together. starship.toml has to
# be here rather than inside machine-setup/, because the PLATFORM reads it too —
# os-user-shell.ts:34 deploys it to every member's Linux account — so it is not
# machine-setup's private file. The other two joined it so there is one answer to
# "where do the dotfile templates live".
#
# No leading dot on any of them: templates in a repository, not dotfiles in a
# home directory. src/servers/shell-skel/zshrc has been spelled that way all
# along.
#
# `[open]` TOMORROW. There are now two zshrc templates — this one for the owner
# and shell-skel/zshrc for members — while starship.toml is deliberately ONE file
# for both audiences. Either the owner genuinely needs different shell config
# from a member, or these should be the same file the way starship is. The tmux
# config has the same question waiting, since it is going into provisioning too.
#
# ── The one thing worth keeping when this is filled in ──
#
# shell-skel/zshrc depends on nothing but zsh: starship, eza, nvim and bun are
# each used only if present, so the same file works on a minimal VPS and on a
# fully equipped workstation. Worth holding to here, since this file will be read
# on machines that have had none of the optional sections run.
-2
View File
@@ -91,8 +91,6 @@ export function App() {
<Route path="/tasks/:dirName" element={<Dashboard.Tasks />} />
<Route path="/processes" element={<Dashboard.Processes />} />
<Route path="/processes/:dirName" element={<Dashboard.Processes />} />
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
<Route path="/task-logs/:id" element={<Dashboard.TaskLogs />} />
<Route path="/jobs" element={<Dashboard.JobsPage />} />
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
@@ -9,6 +9,7 @@ import { Card } from '@/components/Card';
import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { randomId } from 'helpers/random-id';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
@@ -571,7 +572,7 @@ export const PipelineJobDetail = () => {
const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || '';
if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
appendOutput(key, { id: randomId(), type: 'text', text });
}
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
@@ -583,7 +584,7 @@ export const PipelineJobDetail = () => {
// Flush any streaming text before the tool call
flushStreamBuffer(key);
appendOutput(key, {
id: crypto.randomUUID(),
id: randomId(),
type: 'tool',
toolCallId: msg.toolCallId,
toolName: msg.toolName,
@@ -630,7 +631,7 @@ export const PipelineJobDetail = () => {
const flushStreamBuffer = useCallback((key: string) => {
const text = streamBuffers.current.get(key);
if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
}
@@ -130,7 +130,6 @@ import {
FolderOpen,
Code,
LayoutGrid,
ScrollText,
FolderKanban,
Monitor,
Mail,
@@ -170,12 +169,15 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { copyToClipboard } from 'helpers/clipboard';
// Your own API keys: one per app or device, so a phone holds a credential you can revoke on its own
// instead of a session everything shares.
@@ -39,7 +40,7 @@ const formatDate = (value: string | null) =>
const copy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
await copyToClipboard(text);
toast.success('Key copied');
} catch {
toast.error('Could not copy — select and copy manually');
@@ -4,6 +4,7 @@ import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-r
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
type RelayToken = {
token: string;
@@ -46,7 +47,7 @@ export const BrowserRelay = () => {
const handleCopy = async (value: string, field: string) => {
try {
await navigator.clipboard.writeText(value);
await copyToClipboard(value);
setCopiedField(field);
toast.success('Copied to clipboard');
setTimeout(() => setCopiedField(null), 2000);
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
//
@@ -29,7 +30,7 @@ const formatDate = (value: string | null) =>
const copy = async (text: string, what: string) => {
try {
await navigator.clipboard.writeText(text);
await copyToClipboard(text);
toast.success(`${what} copied`);
} catch {
toast.error('Could not copy — select and copy manually');
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { copyToClipboard } from 'helpers/clipboard';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
//
@@ -104,7 +105,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
};
const copy = (value: string, what: string) => {
void navigator.clipboard.writeText(value);
void copyToClipboard(value);
toast.success(`${what} copied`);
};
@@ -283,7 +284,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
size="icon"
disabled={!form.password}
onClick={() => {
void navigator.clipboard.writeText(form.password);
void copyToClipboard(form.password);
toast.success('Password copied');
}}
>
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Crown, Trash2, Loader2, KeyRound, SquareTerminal as TerminalIcon } from 'lucide-react';
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -16,6 +16,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm';
import { copyToClipboard } from 'helpers/clipboard';
type ManagedUser = {
id: number;
@@ -46,6 +47,9 @@ export const UsersSection = () => {
const queryClient = useQueryClient();
const [pendingId, setPendingId] = useState<number | null>(null);
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | null>(null);
const [confirmReset, setConfirmReset] = useState<ManagedUser | null>(null);
/** The one and only sighting of a generated password. Cleared when the dialog closes, and gone for good. */
const [newPassword, setNewPassword] = useState<{ email: string; password: string } | null>(null);
const { data, isLoading, isError } = useQuery<UsersResponse>({
queryKey: USERS_KEY,
@@ -105,6 +109,27 @@ export const UsersSection = () => {
}
};
/**
* A new platform password, generated by the server and shown once.
*
* Generated rather than typed because the failure this exists for is "I forgot to copy it down", and an
* owner typing a replacement can lose it the same way twice. Only the argon2 hash is stored, so the
* dialog below really is the only time anyone sees it — which is why it is a dialog and not a toast.
*/
const resetPassword = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmReset(null);
try {
const result = await client.post<{ email: string; password: string }>(`/users/${user.id}/password`, {});
setNewPassword(result);
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not reset the password');
} finally {
setPendingId(null);
}
};
const remove = async (user: ManagedUser) => {
setPendingId(user.id);
setConfirmDelete(null);
@@ -209,7 +234,7 @@ export const UsersSection = () => {
aria-label={`Copy ${user.email}'s SSH public key`}
title="Copy their SSH public key (add it to their Gitea account)"
onClick={() => {
void navigator.clipboard.writeText(user.osSshPublicKey!);
void copyToClipboard(user.osSshPublicKey!);
toast.success('Public key copied');
}}
>
@@ -217,6 +242,22 @@ export const UsersSection = () => {
</Button>
)}
{/* The owner is excluded because they have change-password, which asks for the current one —
and resetting themselves from here would sign them out of the session doing it. */}
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
disabled={busy}
aria-label={`Reset ${user.email}'s password`}
title="Generate a new password — shown once, and signs them out everywhere"
onClick={() => setConfirmReset(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />}
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -254,6 +295,69 @@ export const UsersSection = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Confirmed rather than immediate: this ends every session the account has, including one they may
be in the middle of using. Not destructive enough for the red button, so it keeps the default. */}
<AlertDialog open={!!confirmReset} onOpenChange={(open) => !open && setConfirmReset(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the password for {confirmReset?.email}?</AlertDialogTitle>
<AlertDialogDescription>
A new password is generated and shown to you once it is not stored anywhere and cannot be looked up
afterwards. Their existing password stops working immediately, and they are signed out everywhere.
{confirmReset?.osUser ? (
<>
{' '}
Their Linux account ({confirmReset.osUser}) is not affected: it has no password, and SSH keys are
unchanged.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => confirmReset && void resetPassword(confirmReset)}>
Generate a new password
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* The only time this password is ever visible. A dialog rather than a toast for exactly that reason:
a toast that times out while somebody is finding a pen loses the thing they came for. */}
<AlertDialog open={!!newPassword} onOpenChange={(open) => !open && setNewPassword(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>New password for {newPassword?.email}</AlertDialogTitle>
<AlertDialogDescription>
Copy it now and give it to them. Only its hash is stored, so closing this dialog is the last anyone sees
of it if it is lost, generate another one.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex items-center gap-2 rounded-md border bg-muted/50 p-3">
<code className="flex-1 select-all break-all font-mono text-sm">{newPassword?.password}</code>
<Button
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Copy the new password"
onClick={() => {
if (!newPassword) return;
void copyToClipboard(newPassword.password).then((ok) =>
ok ? toast.success('Password copied') : toast.error('Could not copy — select it and copy by hand'),
);
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setNewPassword(null)}>Done</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -1,175 +0,0 @@
import { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router';
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
import { MessageBubble, type ChatMessage } from 'officerdev';
type LogMetadata = {
id: number;
taskName: string;
taskDirName: string;
entryName: string;
entryType: string;
provider: string;
model: string;
isError: boolean;
startedAt: string;
completedAt: string | null;
};
type FullLog = LogMetadata & {
messages: ChatMessage[];
};
const formatDate = (iso: string) => {
const d = new Date(iso);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const ProviderBadge = ({ provider }: { provider: string }) => (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${provider === 'claude' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300' : 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'}`}
>
{provider}
</span>
);
// Which run is open is `/task-logs/:id`. No redirect guard — the bare route is the list with nothing
// open, and an id that no longer exists gets the empty pane rather than a rewritten address.
export const TaskLogs = () => {
const client = useClient();
const [logs, setLogs] = useState<LogMetadata[]>([]);
const selectedId = useParams<{ id: string }>().id ?? null;
const [selectedLog, setSelectedLog] = useState<FullLog | null>(null);
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
client
.get<LogMetadata[]>('/task-logs')
.then((data) => {
setLogs(data);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}, []);
useEffect(() => {
if (!selectedId) {
setSelectedLog(null);
return;
}
client
.get<FullLog>(`/task-logs/${selectedId}`)
.then(setSelectedLog)
.catch(() => setSelectedLog(null));
}, [selectedId]);
const filtered = search
? logs.filter((l) => {
const q = search.toLowerCase();
return (
l.taskName.toLowerCase().includes(q) ||
l.entryName.toLowerCase().includes(q) ||
l.provider.toLowerCase().includes(q)
);
})
: logs;
return (
<div className="flex h-full p-3 md:p-6 gap-4">
{/* Left panel: list */}
<Card
className={`md:w-80 shrink-0 flex flex-col overflow-hidden ${selectedId ? 'hidden md:flex' : 'flex-1 md:flex-none'}`}
>
<div className="p-3 border-b border-duck-dark/10">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/40" />
<input
type="text"
placeholder="Search logs..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-duck-dark/15 bg-background/60 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-1 focus:ring-duck-teal/40"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{isLoading && (
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">Loading...</div>
)}
{!isLoading && filtered.length === 0 && (
<div className="flex items-center justify-center h-32 text-duck-dark/30 text-sm">No logs found</div>
)}
{filtered.map((log) => (
<Link
key={log.id}
to={`/task-logs/${log.id}`}
className={`block w-full text-left px-3 py-2.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${selectedId === String(log.id) ? 'bg-duck-teal/10' : ''}`}
>
<div className="flex items-center gap-2 mb-0.5">
{log.isError ? (
<AlertCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
) : log.completedAt ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : (
<Clock className="h-3.5 w-3.5 text-amber-500 shrink-0" />
)}
<span className="text-sm font-medium text-duck-dark truncate">{log.taskName}</span>
</div>
<div className="flex items-center gap-2 ml-5.5">
<span className="text-xs text-duck-dark/50 truncate">{log.entryName}</span>
<ProviderBadge provider={log.provider} />
</div>
<div className="text-[10px] text-duck-dark/40 ml-5.5 mt-0.5">{formatDate(log.startedAt)}</div>
</Link>
))}
</div>
</Card>
{/* Right panel: log viewer */}
<Card className={`flex-1 min-w-0 flex flex-col overflow-hidden ${selectedId ? 'flex' : 'hidden md:flex'}`}>
{!selectedLog && (
<div className="flex flex-col items-center justify-center h-full text-duck-dark/30 text-sm gap-2">
Select a log to view
<Link to="/task-logs" className="md:hidden text-duck-teal text-xs cursor-pointer">
<ArrowLeft className="h-4 w-4 inline mr-1" />
Back to list
</Link>
</div>
)}
{selectedLog && (
<>
<div className="shrink-0 px-4 py-3 border-b border-duck-dark/10 flex items-center gap-3">
<Link to="/task-logs" className="md:hidden p-1 -ml-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
</Link>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-duck-dark">{selectedLog.taskName}</span>
<ProviderBadge provider={selectedLog.provider} />
</div>
<div className="text-xs text-duck-dark/50 mt-0.5">
{selectedLog.entryName} &middot; {selectedLog.model} &middot; {formatDate(selectedLog.startedAt)}
{selectedLog.completedAt && `${formatDate(selectedLog.completedAt)}`}
</div>
</div>
{selectedLog.isError && (
<span className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 px-2 py-0.5 rounded-full">
Error
</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{selectedLog.messages.map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
</div>
</>
)}
</Card>
</div>
);
};
@@ -6,7 +6,6 @@ export * from './Processes';
export * from './CapabilityPage';
export * from './Settings';
export * from './Skills';
export * from './TaskLogs';
export * from './Tasks';
export * from './Files';
+2 -2
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router';
import type { PageTitleOverride } from 'officerdev';
import { usePageTitleOverride } from 'officerdev';
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
import { randomId } from 'helpers/random-id';
type TitleRule = { match: (p: string) => boolean; title: string };
@@ -34,7 +35,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
{ match: (p) => p.startsWith('/activity'), title: 'Activity' },
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
{ match: (p) => p.startsWith('/skills'), title: 'Skills' },
@@ -139,7 +139,7 @@ function claimTabIdentity(): void {
/** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
function newTabId(): string {
return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return randomId();
}
// Once per document, before React reads the stored name.
+32 -12
View File
@@ -9,18 +9,38 @@ describing a different codebase.
```
src/databases/officer_db/
├── src/
│ ├── db.ts # the connection
│ ├── index.ts # public surface: re-exports queries, schema and drizzle helpers
│ ├── types.ts # every type export (Select / Insert / extended)
── schema/
├── index.ts # re-exports all schema files
└── *.ts # table definitions, grouped by domain
└── package.json # exports "." and "./types"
│ ├── db.ts # the connection
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
│ ├── schema.ts # what db:push creates — see below
── types.ts # every type export (Select / Insert / extended)
├── crypto.ts # at-rest encryption, one key per purpose
├── secret-store.ts # the key store itself (SQLite, outside Postgres)
│ └── <feature>/
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
│ ├── schema.ts # its tables
│ └── queries.ts # everything that reads or writes them
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
```
Schema files are grouped by domain, not by table: `auth`, `chat-events`, `dashboards`, `email`,
`headscale`, `music`, `operations`, `pipeline-jobs`, `server`, `soulseek`, `user-data`, `vault`,
`wallet`.
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
query function is one file in one directory, rather than that file plus a hand-written list of every
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
`queries/` trees, where the two sides had drifted: four features were named differently on each side
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
query file at all, and `integrations` had no schema file.
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
Task Logs feature — see below.)
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
commented line there removes a table from the DATABASE without removing a line of code — every query
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
create only the core tables, with the plugin ones commented out until their plugin is installed.
## Schema changes use `push`, not migrations
@@ -114,8 +134,8 @@ Organise `types.ts` by domain with section comments, mirroring the schema files.
## Queries
Hand-written, one file per domain under `src/queries/`, importing tables from `../schema` and types from
`../types`:
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
types from `../types`:
```ts
import { eq, and } from 'drizzle-orm';
+1 -1
View File
@@ -15,7 +15,7 @@ try {
} catch {}
export default defineConfig({
schema: './src/schema/index.ts',
schema: './src/schema.ts',
out: './migrations',
dialect: 'postgresql',
dbCredentials: {
+3 -1
View File
@@ -7,7 +7,9 @@
".": "./src/index.ts",
"./types": "./src/types.ts",
"./db": "./src/db.ts",
"./schema": "./src/schema/index.ts"
"./schema": "./src/schema.ts",
"./secret-store": "./src/secret-store.ts",
"./*": "./src/*.ts"
},
"scripts": {
"generate": "drizzle-kit generate --config=drizzle.config.ts",
@@ -0,0 +1,13 @@
export {
listAgentPanels,
getAgentPanelByPanelId,
getAgentPanelByName,
getAgentPanelByHandoffToken,
createAgentPanel,
updateAgentPanel,
markAgentPanelIntroduced,
deleteAgentPanel,
toAgentPanelView,
} from './queries';
export type { AgentPanel, AgentPanelView, CreateAgentPanelInput, UpdateAgentPanelInput } from './queries';
@@ -1,8 +1,8 @@
import { randomUUID } from 'crypto';
import { and, asc, eq } from 'drizzle-orm';
import { db } from '../db';
import { agentPanels } from '../schema';
import type { AgentPanelRow } from '../schema/agent-panels';
import { agentPanels } from './schema';
import type { AgentPanelRow } from './schema';
export type AgentPanel = AgentPanelRow;
@@ -1,5 +1,5 @@
import { pgTable, serial, text, integer, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { users } from '../auth/schema';
/**
* One named agent living in one dashboard panel the address book that lets two chat panels on the
@@ -0,0 +1,8 @@
export {
findLiveApiKeyByHash,
createApiKey,
listApiKeys,
revokeApiKey,
touchApiKey,
type ApiKeyIdentity,
} from './queries';
@@ -1,6 +1,7 @@
import { eq, and, isNull, sql } from 'drizzle-orm';
import { db } from '../db';
import { apiKeys, users } from '../schema';
import { apiKeys } from './schema';
import { users } from '../auth/schema';
import type { ApiKeySelect } from '../types';
// Every read here is scoped by userId except `findLiveApiKeyByHash`, which cannot be: authentication is
@@ -1,5 +1,5 @@
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { users } from '../auth/schema';
// Long-lived credentials a user mints for themselves, so a native app can hold one instead of a password.
//
@@ -0,0 +1,13 @@
// App store — what the owner has installed, and whether it should be running.
export {
listSidecarInstalls,
getSidecarInstall,
beginInstall,
recordSteps,
markInstalled,
markFailed,
markBlocked,
setEnabled,
removeInstall,
type SidecarInstall,
} from './queries';
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { sidecarInstalls } from '../schema';
import { sidecarInstalls } from './schema';
// What the owner has installed from the app store. See ../schema/app-store.ts for why there is no
// userId and why `installed` and `enabled` are separate.
@@ -0,0 +1,27 @@
export {
getUsers,
getUserById,
getUserByEmail,
getUserByUsername,
getOwnerUser,
getUserCount,
createUser,
updateUser,
deleteUser,
getPasskeysByUserId,
getPasskeysByUserIdAndOrigin,
getPasskeyByCredentialId,
createPasskey,
updatePasskey,
storeChallenge,
consumeChallenge,
blacklistToken,
isTokenBlacklisted,
cleanupExpiredTokens,
} from './queries';
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
// column definition is the only place that list should exist.
export { USER_ROLES, OWNER_USER_ID } from './schema';
export type { UserRole } from './schema';
@@ -1,6 +1,6 @@
import { eq, and, lt, sql } from 'drizzle-orm';
import { db } from '../db';
import { users, passkeys, passkeyChallenges, tokenBlacklist, OWNER_USER_ID } from '../schema';
import { users, passkeys, passkeyChallenges, tokenBlacklist, OWNER_USER_ID } from './schema';
import type { UserSelect, UserInsert, PasskeySelect, PasskeyInsert } from '../types';
// ── Users ──
@@ -0,0 +1,11 @@
export {
getAllRoleGrants,
getRoleGrants,
setRoleGrant,
revokeRoleGrant,
replaceRoleGrants,
} from './queries';
export type { RoleGrant } from './queries';
export type { CapabilityLevelValue } from './schema';
@@ -1,8 +1,8 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { roleCapabilities } from '../schema';
import type { UserRole } from '../schema/auth';
import type { CapabilityLevelValue } from '../schema/capabilities';
import { roleCapabilities } from './schema';
import type { UserRole } from '../auth/schema';
import type { CapabilityLevelValue } from './schema';
// Grants, keyed on role. Absence denies — see the table comment.
@@ -1,6 +1,6 @@
import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { USER_ROLES } from './auth';
import { USER_ROLES } from '../auth/schema';
// What a ROLE may reach. The subject of a grant is a role, never a user.
//
@@ -0,0 +1,6 @@
export {
appendChatEvent,
getChatEventsSince,
getLastChatEventSeq,
pruneChatEventsOlderThan,
} from './queries';
@@ -1,6 +1,6 @@
import { eq, and, gt, asc, desc, lt } from 'drizzle-orm';
import { db } from '../db';
import { chatSessionEvents } from '../schema';
import { chatSessionEvents } from './schema';
/** Append one outbound event to a session's durable log; returns its global cursor id. */
export async function appendChatEvent(sessionId: string, event: unknown): Promise<number> {
+31 -20
View File
@@ -1,40 +1,51 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { getKey } from './secret-store';
// AES-256-GCM at-rest encryption for vault secrets (the brokered Vaultwarden token set + the Officer-app
// protector key). The whole point of the vault store is that a DB dump must not hand over the keys to the
// vault, so these columns are never stored plaintext.
// AES-256-GCM at-rest encryption for every secret column in Postgres. The property this exists to hold is
// that a database dump must not hand over the credentials in it, so these columns are never plaintext.
//
// Key = SHA-256(VAULT_STORE_KEY) so any sufficiently strong secret works (mirrors the JWT_SECRET style).
// Format = base64(iv[12] | authTag[16] | ciphertext). The key is read LAZILY so the platform still boots
// without a vault configured — vault storage ops then throw a clear error instead of crashing startup.
// Format = base64(iv[12] | authTag[16] | ciphertext).
//
// ── One key per purpose ──
//
// This took a single VAULT_STORE_KEY from the environment until 2026-08-13. That key encrypted seven
// unrelated things — the Headscale admin credential, the wallet seed, Vaultwarden's token set, Jellyfin,
// Immich, InvoiceShelf, and every app-store upstream secret — so one leak opened all of them, and its
// name pointed at whichever plugin happened to need it first.
//
// `purpose` is now the first argument everywhere, and the caller passes the one that owns the data. A
// plugin's key is created on first use and cannot decrypt another plugin's column, because the AES key
// derives from a different stored secret. See ./secret-store.ts and docs/secret-store.md.
//
// SHA-256 over the stored key rather than using its bytes directly, so the store is free to change how it
// represents a key without every ciphertext in the database becoming unreadable.
let cachedKey: Buffer | null = null;
function key(): Buffer {
if (cachedKey) return cachedKey;
const secret = process.env.VAULT_STORE_KEY;
if (!secret || secret.length < 16) {
throw new Error('VAULT_STORE_KEY must be set (>=16 chars) to store vault secrets');
}
cachedKey = createHash('sha256').update(secret).digest();
return cachedKey;
const cache = new Map<string, Buffer>();
function key(purpose: string): Buffer {
const hit = cache.get(purpose);
if (hit) return hit;
const derived = createHash('sha256').update(getKey(purpose)).digest();
cache.set(purpose, derived);
return derived;
}
/** Encrypt a UTF-8 secret for at-rest storage → base64(iv|tag|ciphertext). */
export function encryptSecret(plaintext: string): string {
export function encryptSecret(purpose: string, plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key(), iv);
const cipher = createCipheriv('aes-256-gcm', key(purpose), iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, ct]).toString('base64');
}
/** Decrypt a value produced by encryptSecret. Throws if the ciphertext/tag/key don't verify. */
export function decryptSecret(blob: string): string {
/** Decrypt a value produced by encryptSecret under the SAME purpose. Throws if it does not verify. */
export function decryptSecret(purpose: string, blob: string): string {
const buf = Buffer.from(blob, 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const ct = buf.subarray(28);
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
const decipher = createDecipheriv('aes-256-gcm', key(purpose), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
}
@@ -0,0 +1,11 @@
export {
getAllDashboardState,
upsertDashboard,
updateDashboard,
deleteDashboard,
setDashboardPanelState,
upsertScreen,
deleteScreen,
upsertDefaults,
setDefaultsPanelState,
} from './queries';
@@ -1,6 +1,6 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { dashboards, screens, dashboardDefaults } from '../schema';
import { dashboards, screens, dashboardDefaults } from './schema';
// ── Full state read ──
@@ -1,7 +1,7 @@
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { users } from '../auth/schema';
/**
* A `LayoutNode` is an object, and the whole write path to these columns is `unknown` the PATCH body is
@@ -0,0 +1,9 @@
export {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
verifyDavAppPassword,
} from './queries';
export type { DavAppPassword, DavAppPasswordView } from './queries';
@@ -2,7 +2,7 @@ import { and, desc, eq, isNull } from 'drizzle-orm';
import argon2 from 'argon2';
import { randomBytes } from 'node:crypto';
import { db } from '../db';
import { davAppPasswords } from '../schema/dav';
import { davAppPasswords } from './schema';
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
@@ -1,5 +1,5 @@
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { users } from '../auth/schema';
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
//
+44
View File
@@ -10,3 +10,47 @@ if (!POSTGRES_URL) {
const client = postgres(POSTGRES_URL);
export const db = drizzle(client, { schema });
/**
* Block until Postgres answers, or give up after `timeoutMs`.
*
* `postgres()` above is LAZY — it opens no socket until the first query — so nothing here fails at
* import time when the database is not up yet. That is the right default and it has a cost: startup
* work that queries fires, fails once, and is swallowed by whatever `.catch()` it was written with.
*
* That is not hypothetical. `server.tsx` runs `initQueue()` and `cleanupOnStartup()` as
* fire-and-forget promises, and the second marks jobs that were interrupted by the last restart and
* promotes the queued backlog. If Postgres is a few seconds behind — which is exactly what happens on
* a reboot, when pm2's resurrect races Docker starting the container — both log a line and do nothing.
* Interrupted jobs then stay marked running forever, because the only thing that would have corrected
* them already ran.
*
* So: wait, rather than try once. Callers that genuinely cannot proceed without the database await
* this first; request handlers do not, since by then it is either up or the request fails honestly.
*
* Bounded, and it resolves false rather than throwing on timeout. An unbounded wait here would hold a
* process open with no way to tell whether it is starting or hung, and the caller is better placed to
* decide what "gave up" means than this function is.
*/
export async function waitForDatabase(timeoutMs = 60_000): Promise<boolean> {
const started = Date.now();
let announced = false;
for (;;) {
try {
await client`select 1`;
if (announced) console.log('[db] Postgres is up');
return true;
} catch (err) {
if (Date.now() - started >= timeoutMs) {
console.error(`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`, err instanceof Error ? err.message : err);
return false;
}
if (!announced) {
console.log('[db] waiting for Postgres…');
announced = true;
}
await new Promise((r) => setTimeout(r, 1_000));
}
}
}
@@ -0,0 +1,9 @@
export {
getEmailAccounts,
getEmailAccount,
createEmailAccount,
deleteEmailAccount,
updateEmailAccountStatus,
updateEmailAccountSyncMeta,
getAllSyncedAccounts,
} from './queries';
@@ -1,6 +1,6 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { emailAccounts } from '../schema';
import { emailAccounts } from './schema';
import type { EmailAccountInsert, EmailAccountSelect } from '../types';
export async function getEmailAccounts(userId: number): Promise<EmailAccountSelect[]> {
@@ -1,5 +1,5 @@
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { users } from '../auth/schema';
export const emailAccounts = pgTable(
'email_accounts',
@@ -0,0 +1,12 @@
export {
listHeadscaleServers,
getActiveHeadscaleCredentials,
getHeadscaleCredentials,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
recordHeadscaleProbe,
} from './queries';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries';
@@ -1,6 +1,6 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { headscaleServers } from '../schema';
import { headscaleServers } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
@@ -54,7 +54,7 @@ export async function getActiveHeadscaleCredentials(userId: number): Promise<Hea
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
}
/** One server's credentials by id — for probing a specific server rather than the active one. */
@@ -64,7 +64,7 @@ export async function getHeadscaleCredentials(userId: number, id: number): Promi
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
}
type CreateHeadscaleServerParams = {
@@ -95,7 +95,7 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
userId,
name,
url,
apiKey: encryptSecret(apiKey),
apiKey: encryptSecret('headscale', apiKey),
version,
sshHost,
isActive: activate,
@@ -119,7 +119,7 @@ export async function updateHeadscaleServer(
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.name !== undefined) set.name = params.name;
if (params.url !== undefined) set.url = params.url;
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey);
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
const [row] = await db
@@ -1,6 +1,6 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
import { users } from '../auth/schema';
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
+55 -293
View File
@@ -1,297 +1,59 @@
export {
getUsers,
getUserById,
getUserByEmail,
getUserByUsername,
getOwnerUser,
getUserCount,
createUser,
updateUser,
deleteUser,
getPasskeysByUserId,
getPasskeysByUserIdAndOrigin,
getPasskeyByCredentialId,
createPasskey,
updatePasskey,
storeChallenge,
consumeChallenge,
blacklistToken,
isTokenBlacklisted,
cleanupExpiredTokens,
} from './queries/auth';
// The package's public surface.
//
// One line per feature, and nothing else. What each feature exports is stated in its own index.ts,
// beside the schema and queries it exports — so adding a query function means editing one file in one
// directory, not that file plus a list three levels up that nobody remembers to update.
//
// This was 297 lines of hand-written named exports until 2026-08-13. Every symbol was listed here, twice
// for most features (values, then types, repeating the path), and `db` and `schema` sat at line 270 with
// three feature blocks appended after them.
//
// The surface is unchanged: the same names are exported, they are just declared next to what they
// describe. 107 files import from 'officerdb' and none of them notice.
//
// ── Core above, plugins below ──
//
// The split mirrors schema.ts, where the plugin TABLES are commented out so db:push does not create
// them. These lines are NOT commented, and the difference is worth stating: commenting them would break
// nothing at runtime — hono.ts no longer mounts a single plugin router, so none of this is ever
// reached — but `bunx tsgo` checks every file under src/ whether it runs or not, and the plugin
// sidecars still import these symbols. So they stay exported until each plugin is extracted, and the
// blank line below is the only thing marking the boundary.
export {
findLiveApiKeyByHash,
createApiKey,
listApiKeys,
revokeApiKey,
touchApiKey,
type ApiKeyIdentity,
} from './queries/api-keys';
export { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config';
export {
getUserSettings,
setUserSettings,
getUserState,
patchUserState,
getDockPaths,
setDockPaths,
} from './queries/user-data';
export {
getServerIntegrations,
getServerIntegration,
upsertServerIntegration,
deleteServerIntegration,
getUserIntegrations,
getUserIntegration,
getIntegrationsByProvider,
upsertUserIntegration,
deleteUserIntegration,
findUserByIntegrationConfig,
} from './queries/integrations';
export {
getEmailAccounts,
getEmailAccount,
createEmailAccount,
deleteEmailAccount,
updateEmailAccountStatus,
updateEmailAccountSyncMeta,
getAllSyncedAccounts,
} from './queries/email-accounts';
export {
getAllDashboardState,
upsertDashboard,
updateDashboard,
deleteDashboard,
setDashboardPanelState,
upsertScreen,
deleteScreen,
upsertDefaults,
setDefaultsPanelState,
} from './queries/dashboards';
export {
createPipelineJob,
getPipelineJob,
updatePipelineJob,
getPipelineJobsForUser,
getOldestPendingJob,
getPendingJobs,
countPendingJobs,
deletePipelineJob,
deleteTerminalJobsForUser,
markInterruptedJobs,
} from './queries/pipeline-jobs';
export {
appendChatEvent,
getChatEventsSince,
getLastChatEventSeq,
pruneChatEventsOlderThan,
} from './queries/chat-events';
export {
listAgentPanels,
getAgentPanelByPanelId,
getAgentPanelByName,
getAgentPanelByHandoffToken,
createAgentPanel,
updateAgentPanel,
markAgentPanelIntroduced,
deleteAgentPanel,
toAgentPanelView,
} from './queries/agent-panels';
export type { AgentPanel, AgentPanelView, CreateAgentPanelInput, UpdateAgentPanelInput } from './queries/agent-panels';
export {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
} from './queries/music';
export type {
FavoriteKind,
GroupedFavorites,
NowPlaying,
NowPlayingInput,
PlaylistSummary,
Playlist,
} from './queries/music';
export { getSoulseekFavorites, addSoulseekFavorite, removeSoulseekFavorite } from './queries/soulseek';
export {
getSoulseekBrowseSnapshots,
getSoulseekBrowseSnapshot,
startSoulseekBrowse,
finishSoulseekBrowse,
failSoulseekBrowse,
resetStaleSoulseekBrowses,
getSoulseekBrowseLevel,
searchSoulseekBrowseTree,
getSoulseekBrowseDirFiles,
getSoulseekBrowseDownload,
deleteSoulseekBrowse,
} from './queries/soulseek';
export type {
BrowseDownloadFile,
BrowsedFile,
BrowseDirInput,
BrowseDirRow,
BrowseTreeNode,
BrowseLevel,
BrowseTreeSearch,
SoulseekBrowseSnapshot,
} from './queries/soulseek';
export {
listHeadscaleServers,
getActiveHeadscaleCredentials,
getHeadscaleCredentials,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
recordHeadscaleProbe,
} from './queries/headscale';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
export {
listInvoiceshelfAccounts,
getActiveInvoiceshelfCredentials,
getInvoiceshelfCredentials,
createInvoiceshelfAccount,
updateInvoiceshelfAccount,
setActiveInvoiceshelfAccount,
deleteInvoiceshelfAccount,
recordInvoiceshelfProbe,
} from './queries/invoiceshelf';
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf';
export {
listJellyfinServers,
getActiveJellyfinCredentials,
getJellyfinCredentials,
createJellyfinServer,
updateJellyfinServer,
setActiveJellyfinServer,
deleteJellyfinServer,
recordJellyfinProbe,
} from './queries/jellyfin';
export type { JellyfinServer, JellyfinCredentials } from './queries/jellyfin';
export {
listPhotosAccounts,
getActivePhotosCredentials,
getPhotosCredentials,
createPhotosAccount,
updatePhotosAccount,
setActivePhotosAccount,
deletePhotosAccount,
recordPhotosProbe,
} from './queries/photos';
export type { PhotosAccount, PhotosCredentials } from './queries/photos';
export {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
verifyDavAppPassword,
} from './queries/dav';
export type { DavAppPassword, DavAppPasswordView } from './queries/dav';
export {
getServiceConnection,
getServiceCredentials,
saveServiceConnection,
deleteServiceConnection,
recordServiceProbe,
getServiceInstanceUrl,
getResolvedServiceCredentials,
} from './queries/service-connections';
export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections';
export {
getAllRoleGrants,
getRoleGrants,
setRoleGrant,
revokeRoleGrant,
replaceRoleGrants,
} from './queries/capabilities';
export type { RoleGrant } from './queries/capabilities';
export type { CapabilityLevelValue } from './schema/capabilities';
export {
getVaultTokens,
setVaultTokens,
updateVaultAccess,
clearVaultTokens,
getVaultUnlockKey,
setVaultUnlockKey,
clearVaultUnlockKey,
} from './queries/vault';
export type { VaultTokenSet } from './queries/vault';
export {
listWallets,
getWallet,
getActiveWallet,
getWalletSecrets,
getSealedSeed,
createWallet,
updateWallet,
replaceSealedSeed,
setActiveWallet,
deleteWallet,
getWalletLabels,
setWalletLabel,
getFrozenOutpoints,
setUtxoFrozen,
getWalletChainCache,
saveWalletChainCache,
recordWalletChainError,
} from './queries/wallet';
export type {
WalletKind,
WalletSummary,
WalletSecrets,
WalletLabel,
CreateWalletParams,
WalletChainCache,
} from './queries/wallet';
export type { WalletChainSnapshot } from './schema/wallet';
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
// column definition is the only place that list should exist.
export { USER_ROLES, OWNER_USER_ID } from './schema/auth';
export type { UserRole } from './schema/auth';
export { db } from './db';
// The connection, and drizzle-kit's view of the schema. See ./schema.ts for the core/plugin split.
export { db, waitForDatabase } from './db';
export * as schema from './schema';
export {
upsertPushDevice,
getPushDevices,
deletePushDevice,
recordPushFailure,
markPushDeviceSeen,
} from './queries/notify';
export type { PushDeviceSelect, PushDeviceInsert } from './types';
// ── Core ─────────────────────────────────────────────────────────────────────────────────────────
// App store — what the owner has installed, and whether it should be running.
export {
listSidecarInstalls,
getSidecarInstall,
beginInstall,
recordSteps,
markInstalled,
markFailed,
markBlocked,
setEnabled,
removeInstall,
type SidecarInstall,
} from './queries/sidecar-installs';
export * from './agent-panels';
export * from './api-keys';
export * from './app-store';
export * from './auth';
export * from './capabilities';
export * from './chat-events';
export * from './dashboards';
export * from './headscale';
export * from './integrations';
export * from './pipeline-jobs';
export * from './server';
export * from './service-connections';
export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
export * from './email';
export * from './invoiceshelf';
export * from './jellyfin';
export * from './music';
export * from './notify';
export * from './photos';
export * from './soulseek';
export * from './vault';
export * from './wallet';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
// deleted end to end on 2026-08-13 after `task-logger.ts` turned out to have no callers — a full read
// path over a table nothing could write to.
@@ -0,0 +1,12 @@
export {
getServerIntegrations,
getServerIntegration,
upsertServerIntegration,
deleteServerIntegration,
getUserIntegrations,
getUserIntegration,
getIntegrationsByProvider,
upsertUserIntegration,
deleteUserIntegration,
findUserByIntegrationConfig,
} from './queries';
@@ -1,7 +1,8 @@
import { eq, and, sql } from 'drizzle-orm';
import { db } from '../db';
import { serverIntegrations, userIntegrations } from '../schema';
import { users } from '../schema/auth';
import { serverIntegrations } from '../server/schema';
import { userIntegrations } from '../user-data/schema';
import { users } from '../auth/schema';
import type { ServerIntegrationSelect, UserIntegrationSelect } from '../types';
// ── Server Integrations ──
@@ -0,0 +1,12 @@
export {
listInvoiceshelfAccounts,
getActiveInvoiceshelfCredentials,
getInvoiceshelfCredentials,
createInvoiceshelfAccount,
updateInvoiceshelfAccount,
setActiveInvoiceshelfAccount,
deleteInvoiceshelfAccount,
recordInvoiceshelfProbe,
} from './queries';
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries';
@@ -1,6 +1,6 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { invoiceshelfAccounts } from '../schema';
import { invoiceshelfAccounts } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
// InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption
@@ -60,7 +60,7 @@ export async function getActiveInvoiceshelfCredentials(userId: number): Promise<
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
}
/** One account's credentials by id — for probing a specific account rather than the active one. */
@@ -70,7 +70,7 @@ export async function getInvoiceshelfCredentials(userId: number, id: number): Pr
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
}
type CreateInvoiceshelfAccountParams = {
@@ -101,7 +101,7 @@ export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccoun
userId,
label,
url,
token: encryptSecret(token),
token: encryptSecret('invoiceshelf', token),
companyId,
companyName,
version,
@@ -131,7 +131,7 @@ export async function updateInvoiceshelfAccount(
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.label !== undefined) set.label = params.label;
if (params.url !== undefined) set.url = params.url;
if (params.token !== undefined) set.token = encryptSecret(params.token);
if (params.token !== undefined) set.token = encryptSecret('invoiceshelf', params.token);
if (params.companyId !== undefined) set.companyId = params.companyId;
if (params.companyName !== undefined) set.companyName = params.companyName;
if (params.version !== undefined) set.version = params.version;
@@ -1,6 +1,6 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
import { users } from '../auth/schema';
// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar.
//
@@ -0,0 +1,12 @@
export {
listJellyfinServers,
getActiveJellyfinCredentials,
getJellyfinCredentials,
createJellyfinServer,
updateJellyfinServer,
setActiveJellyfinServer,
deleteJellyfinServer,
recordJellyfinProbe,
} from './queries';
export type { JellyfinServer, JellyfinCredentials } from './queries';
@@ -1,6 +1,6 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { jellyfinServers } from '../schema';
import { jellyfinServers } from './schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and
@@ -51,7 +51,7 @@ const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCreden
id: row.id,
label: row.label,
url: row.url,
accessToken: decryptSecret(row.accessToken),
accessToken: decryptSecret('jellyfin', row.accessToken),
jellyfinUserId: row.jellyfinUserId,
deviceId: row.deviceId,
});
@@ -112,7 +112,7 @@ export async function createJellyfinServer(params: CreateJellyfinServerParams):
.values({
userId,
...rest,
accessToken: encryptSecret(accessToken),
accessToken: encryptSecret('jellyfin', accessToken),
isActive: activate,
lastSeenAt: rest.version ? new Date() : null,
})
@@ -142,7 +142,7 @@ export async function updateJellyfinServer(
.update(jellyfinServers)
.set({
...rest,
...(accessToken ? { accessToken: encryptSecret(accessToken) } : {}),
...(accessToken ? { accessToken: encryptSecret('jellyfin', accessToken) } : {}),
updatedAt: new Date(),
})
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
@@ -1,6 +1,6 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
import { users } from '../auth/schema';
// The Jellyfin servers behind /jellyfin, for the officer-jellyfin sidecar.
//
@@ -0,0 +1,24 @@
export {
getMusicFavorites,
addMusicFavorite,
removeMusicFavorite,
getNowPlaying,
setNowPlaying,
clearNowPlaying,
getPlaylists,
getPlaylist,
createPlaylist,
renamePlaylist,
deletePlaylist,
addPlaylistItems,
setPlaylistItems,
} from './queries';
export type {
FavoriteKind,
GroupedFavorites,
NowPlaying,
NowPlayingInput,
PlaylistSummary,
Playlist,
} from './queries';
@@ -1,6 +1,6 @@
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { db } from '../db';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from '../schema';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
export type FavoriteKind = 'track' | 'album' | 'artist';
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
@@ -1,5 +1,5 @@
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { users } from '../auth/schema';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
@@ -0,0 +1,9 @@
export {
upsertPushDevice,
getPushDevices,
deletePushDevice,
recordPushFailure,
markPushDeviceSeen,
} from './queries';
export type { PushDeviceSelect, PushDeviceInsert } from '../types';
@@ -1,6 +1,6 @@
import { eq, and, sql } from 'drizzle-orm';
import { db } from '../db';
import { pushDevices } from '../schema';
import { pushDevices } from './schema';
import type { PushDeviceSelect, PushDeviceInsert } from '../types';
// The push device registry. Only the officer-notify sidecar uses these.
@@ -1,6 +1,6 @@
import { pgTable, serial, integer, text, timestamp, index, check, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
import { users } from '../auth/schema';
// Devices that can receive a push, for the officer-notify sidecar.
//

Some files were not shown because too many files have changed in this diff Show More