Commit Graph
1254 Commits
Author SHA1 Message Date
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