Author SHA1 Message Date
pastilhasandClaude Opus 5 ed195e0904 record what got built tonight
the plugin system works end to end for a plugin with an api/router.ts, at
runtime, with no restart. what is wired, what is not (schema push, the sidecar's
pm2 entry, websocket providers, totality across plugin routes), and what was
deliberately left: offscale is not extracted, because moving it deletes working
code across ~50 files and that wants someone watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:28:18 +00:00
pastilhasandClaude Opus 5 98c400bf33 a /plugins screen to install, enable, disable and uninstall
the management surface for what the last commit made possible. two panels either
side of a selection that lives in ?selected= and is read by both independently,
so neither can be telling the other something stale — rows are real Links, not
buttons holding the name in a closure.

the detail panel shows what the tree declared (api, schema, sidecar, web),
because "installed and nothing happened" is otherwise a mystery, and it names
what uninstall does NOT do: neither disable nor uninstall deletes anything the
plugin stored, and the screen says so rather than leaving someone to guess
whether a button destroys their data.

a directory whose manifest will not parse is listed with its error rather than
skipped. a malformed plugin that simply does not appear is indistinguishable
from one nobody wrote.

`outdated` is surfaced as an Update button: the version on disk moving after an
install is the normal state on a developer's machine, and it should be visible
rather than inferred.

the four mutations are written out rather than generated in a loop — useMutation
is a hook, and a hook called from inside a helper is a rules-of-hooks violation
even when the call order happens to be stable. caught before it shipped.

verified against a running server: the spa builds (19.8 MB bundle containing the
new screen), / serves 200, /api/plugins answers authenticated and 401s without a
token. full suite 719 pass, same 10 pre-existing failures. live server and
plugin_installs left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:27:58 +00:00
pastilhasandClaude Opus 5 282a64a637 plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:

  /api/example/ping BEFORE install   404
  AFTER install                      200  {"plugin":"example","ok":true}
  AFTER disable                      404
  AFTER enable                       200
  AFTER uninstall                    404
  core route throughout              200

plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.

the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.

mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.

/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.

plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.

not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.

full suite: 719 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:22:18 +00:00
pastilhasandClaude Opus 5 0701aba902 brotli in the core package set
installed on this host already; verified round-tripping from a member shell.
same package name on apt, pacman and dnf. on brew it is there because macOS
ships the library but not the CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:56 +00:00
pastilhasandClaude Opus 5 2e6c263751 the hono app is built, not assembled once
first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.

hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.

so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.

buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.

alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.

discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.

a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.

verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:11 +00:00
pastilhasandClaude Opus 5 b2349b5480 install the psql client, matching the server it talks to
there was no psql on this machine. the server runs in a container, so nothing
ever put a client on the host, and `docker exec officer-postgres psql` is the
owner's tool — a member has their own Postgres role and no access to the owner's
Docker socket.

the version is derived from PG_IMAGE rather than typed again, because the
pairing is load-bearing: pg_dump refuses a server newer than itself, and Ubuntu
24.04 ships client 16 against this 18 server. so the archive package is not
merely old, it is unusable for dumps. that is also why this sits beside the
server definition instead of in machine-setup's package list — one constant, one
place to bump.

PGDG added the same way docker.sh adds Docker's: key in its own file, one
sources.list.d entry, no add-apt-repository. non-fatal, and the exit status is
not the gate — apt can succeed while holding an older client back, so the check
is that psql is present AND is the major we asked for.

installed by hand on this host already: psql/pg_dump 18.6, verified as green
connecting with their own role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:13:09 +00:00
pastilhasandClaude Opus 5 0ae0a5dc58 music is where the richer permission model gets designed
offscale is deliberately the simple case — one shared resource, read or write.
music is the next extraction and the right place to build the in-plugin
visibility system, because it has real per-user data (favourites, playlists,
now-playing) on top of a real shared one (a single global library index). so
'whose is this row' has a non-uniform answer there, where offscale's is just
'the owner's'.

not designed yet and deliberately not designed here. recorded so the intent
survives the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:06:55 +00:00
pastilhasandClaude Opus 5 acd51c969c the platform grants read or write; richer rules belong to the plugin
the platform's contract is what it already has: a role holds read or write on a
capability, stored in role_capabilities and enforced by the gate. anything
beyond — who sees whose rows, per-user isolation, record ownership, visibility
of any kind — is the plugin author's job, inside the plugin. the platform should
not grow machinery for it. a plugin knows what its data means; the platform only
knows whether this account got through the door.

offscale v1 uses that exactly. one shared resource: read sees what the owner
sees, write can change it including deleting a server the owner registered. that
is dangerous on purpose — the stored credential is a headscale admin key with no
read-only equivalent, so write is close to full control of the tailnet, and that
is the owner's call. expected use is read for most roles.

two consequences, both inside the plugin. the queries stop scoping by the caller
and resolve to the owner's id, leaving the per-user shape in the table unused as
the seam if isolation is ever wanted. and two POSTs are really reads —
/ssh-test probes and /policy/assist explicitly never saves — so they need
readOnlyWrites, or a read-level account finds a broken feature where a withheld
permission should be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:04:18 +00:00
pastilhasandClaude Opus 5 4c3682dae6 let a member read the two directories above their own home
`bun run` from anywhere inside a member's home died with

    error loading current directory
    error: An internal error occurred (CouldntReadCurrentDirectory)

before it looked at package.json, bun.lock or .git — all of which were present.

it is not walking up looking for a workspace root. it primes its resolver cache
by walking DOWN from / and opening every component of the cwd for READING:

    openat("/home/pastilhas/officerdev/")              = 6
    openat(".../officerdev/data/")                     = -1 EACCES
    openat(".../officerdev/data/<email>/")             = -1 EACCES

those two are 711 — traversable, not listable — which is enough to cd into a
home and not enough for a program that reads its ancestors. `getcwd` succeeds;
the ancestor read is what fails. `O_PATH` would need only `x`, so this is
arguably bun's bug, but it presents as a member's project being mysteriously
unbuildable and nothing here can fix it from the other side.

so DATA_PATH and the account dir now carry a named ACL entry per member. that
gives up the property the old comment named — a member can now `ls` DATA_PATH
and learn the other accounts' email addresses — and keeps everything that
matters: every home is still 700 and owned by its member, every platform
sibling still 700 and owned by the service user. verified as green: the account
list is visible, and email_accounts, another home, the repo .env, the owner's
ssh key, .pgpass and ~/.claude/.credentials.json are all still denied.

the mask is set explicitly to rx alongside the entry. chmod recomputes the mask
from the group bits, which for 711 is --x, so without that the next member's
provisioning would silently clamp every earlier member back to traverse-only.

applied by hand to the one existing account; provisioning covers new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:00:29 +00:00
pastilhasandClaude Opus 5 56bb383c6d a plugin installs with no questions unless it says otherwise
offscale needs none of the install fields the catalogue carries — no modes, no
existingFields, no configFields, no composeTemplate, no members. nothing to
provision, nothing to point at. install is put the code there, push the schema,
start the sidecar, swap the routes, and it is available.

configuration happens afterwards inside the app, which is already how headscale
works: a server is registered at runtime and lands in offscale_servers.

so no-questions is the default rather than offscale's special case, and the
prompting machinery gets designed against the first extracted plugin that
actually needs docker or a remote instance. part of why this was the right
pilot — it exercises mounting, schema and sidecar without install being a
variable at the same time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:15 +00:00
pastilhasandClaude Opus 5 327783532e reach a member's transcripts by listing them, not just by reading them
yesterday's fix routed transcript CONTENT through the member's identity and
stopped there, on the strength of a comment in ChatIdentity saying enumeration
never needed it — "their directories are 775 and the platform holds an ACL
entry, so readdirSync and statSync have always worked".

there are no 775 directories on this path. claude creates ~/.claude/projects/
and every project group at mode 700, and a 700 directory clamps the ACL mask to
--- exactly as a 600 file does:

    user:officer:rwx    #effective:---
    mask::---

measured against a real member home:

    existsSync(projects)        -> true     (stat only needs traverse on .claude)
    readdirSync(projects)       -> EACCES
    existsSync(projects/<slug>) -> false
    statSync(<transcript>)      -> EACCES

existsSync answering false rather than throwing is why this was invisible: every
caller read it as "no such session". one root cause, three reported symptoms —
an empty conversation list, no title on a new chat, and a /chat/<id> deep link
that never restored the conversation. a fourth nobody had reported yet: delete
removed nothing and still answered ok, because unlink needs w+x on the group
directory too.

so enumeration goes through the same door as content, as ONE call rather than a
spawn per entry: listTranscriptsAs runs a single `find` as the member and
returns every transcript with its mtime, which readdir+stat could not do without
dozens of setpriv forks per request and a matching pile of auth.log lines. the
owner keeps a fork-free path — that process already IS the owner. removeAs does
the same for unlink, and readTailAs no longer stats a file it cannot stat.

summarizeTranscript now takes the mtime it is given instead of stat'ing again,
which is both the fix and one less syscall per file.

verified against jg@pertento.ai on this machine: 6 conversations listed with
titles from their first prompts, and a deep link by id alone loads 71 messages.
owner path re-checked and unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:06 +00:00
pastilhasandClaude Opus 5 9f903479ce websocket routes reload too, so nothing needs a restart
the last gap. six ws providers live in bun's route table rather than hono's, so
the app swap does not reach them — but server.reload({routes}) does, and in both
directions: refused before, connected after install, refused again after
uninstall, with core routes untouched throughout.

so a plugin can own a socket from the start, and no part of an install needs the
process restarted.

still untested: whether connections already open across a reload survive it.
that matters before an install is allowed to interrupt somebody's terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:46:07 +00:00
pastilhasandClaude Opus 5 6ab838c77f mounting at runtime after all: rebuild the app and swap it
this went round twice — runtime dynamic, then generated-plus-restart on the
belief that hono could not mount after serving, then back once that was actually
tested. the doc keeps the route rather than just the destination.

tested: SmartRouter (hono's default) and RegExpRouter both throw 'Can not add a
route since the matcher is already built'. TrieRouter and PatternRouter accept
it. so runtime adding is possible but costs the fast matcher, and hono has no
remove-route api at all, which uninstall needs.

what solves both is not adding routes but rebuilding: construct a fresh app from
the current plugin set and reassign the variable. the fetch closure reads it per
request, so the reassignment is the swap — atomic, no dropped connections, no
server.reload, and the default SmartRouter is kept. verified 404 before install,
200 after, 404 again after uninstall, with core routes unaffected throughout.

the mechanical cost is one line: server.tsx:322 is '/api/*': honoServer.fetch, a
bound method evaluated once at serve(), and has to become a closure or the swap
does nothing.

websockets stay open: six providers live in bun's route table rather than
hono's, so a plugin owning a socket needs server.reload({routes}), untested.
offscale has none.

and totality stops being a boot check — buildApp() is now the single place
routes are mounted, so it is where the assertion belongs, refusing the swap
rather than refusing the boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:45:22 +00:00
pastilhasandClaude Opus 5 13437e0e48 mounting: generated then restart, reversing the call for runtime dynamic
this reverses the earlier decision for C (mount and unmount at runtime) and says
so rather than quietly overwriting it.

the requirement behind C was that the platform must not need to know a plugin in
advance. that is met either way: what it reads is a generated file listing the
installed routers, analogous to Plugins.tsx on the frontend — nothing hardcoded,
nothing read from a table at boot, the imports made concrete at install. C would
have bought only the absence of a restart.

and a restart is close to free here, because sidecars are pm2 peers rather than
children — a property that was fought for, since officer used to spawn the agent
and pm2's tree-kill took the owner's chat down on every restart. what a restart
costs is websockets, which reconnect, and in-memory session records, which
claude:list already recovers.

the happy consequence is that assertCapabilityTotality stays a boot check
instead of becoming a per-mount transaction. it does need to be fed the route
table rather than Object.keys(handlers) first — generated mounts widen that gap
rather than closing it, so that is a prerequisite and not a tidy-up beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:35:55 +00:00
pastilhasandClaude Opus 5 7befaf032a the manifest holds only what the tree cannot say
lean it to identity facts and human choices: publisher, version, platform range,
the four presentation fields, and permissions. everything structural becomes
convention, where presence is the declaration — sidecar/, api/router.ts,
db/schema.ts, web/Router.tsx, web/panels.ts. appName comes from the directory
name, so the id cannot disagree with where the code sits.

the dock tile and page title needed no fields at all: the tile is label + icon +
color + mountPrefix, and the title is label. writing them again was duplication
that could only drift.

runtime is the file extension. index.mjs is node, index.ts is bun — implicit,
but already the rule here, since officer-pty runs under node for node-pty's abi
and everything else is bun. better than a field that can contradict the file.

dependsOn is gone; nothing read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:32:07 +00:00
pastilhasandClaude Opus 5 7ebc4d0ccd note the totality/route-table drift for later
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 1292a5c5ab opencode is owner-only until it carries an identity
a turn on the opencode harness ran as the owner, in the owner's home, whoever
asked. handleOpenCodeChat resolves its cwd against getOwnerHomeDir(email), which
discards the email it is given, and the sidecar runs one shared `opencode serve`
as the service user — sendOpenCodeStreaming accepts userId/email/username and
forwards none of them. it carried a comment calling itself owner-only; nothing
enforced it.

reachable by any account with the `chat` grant, which every role holds by
default (DEFAULT_ROLE_CAPABILITIES), and isClaudeModel is a startsWith, so a
typo'd model string landed there too. the model is client-supplied and never
checked against the catalogue.

the same gap on the read side: opencode's session store has no per-user scoping
at all, so loadOpenCodeSession/delete/rename take an id and no identity, and the
list and live routes returned other people's conversations.

so: ChatIdentity carries isOwner as its own fact (not inferred from
osUser === null, which holds only while resolveHomeDir refuses a member without
one), and every opencode door in chat.ts checks it — list, load, live, delete,
rename — plus a refusal on the execution path in handleChat. /chat/models hides
opencode from non-owners as a courtesy; the socket refuses regardless.

a stopgap, not a design. the fix is to thread identity through the opencode
sidecar the way spawnClaudeAsMember does, and TODO.md has been saying so.

not fixed here, and worth knowing: a member's session list is still empty and
/chat/pwds still 500s, because readdirSync on their ~/.claude/projects is EACCES
— claude creates it at mode 700, which zeroes the ACL mask. visible in
officer-error.log right now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 4dc7cd90c2 a plugin declares permissions, not capabilities, and has no kind
'capabilities' already means three things in this codebase — the permission
registry, the officer-items store, and the sidecar's routing keys. a fourth
would be one too many, and the field is really just permissions. the name is
free: the old permissions table went in 044aacf4.

and the kind enum is gone with it. the first draft handed a plugin the
platform's own five-value CapabilityKind and then forbade three of them. those
five exist because the platform has five sorts of surface; a plugin has two —
grantable to members, or owner-only. a boolean says it, and says it without
needing a prohibition: a plugin cannot claim core if core is not a word it can
say.

offscale is ownerOnly: true, which is what kind: 'admin' meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:25:54 +00:00
pastilhasandClaude Opus 5 b18601530f a manifest for offscale, and the rule it immediately broke
written against the real plugin rather than invented as a field list, on the
theory that an abstract one includes what nothing needs and misses what is
awkward. that paid off on the first field that mattered.

the rule here said a plugin may declare `app` and nothing else. offscale's
capability is `admin` — owner only — and should stay that way, so the rule was
wrong. the distinction is direction, not privilege: `core` means every account
and not deniable, so claiming it grants yourself to everyone; `admin` means
owner only, which is a plugin restricting itself. corrected table in the doc.
core, execution and confined stay the platform's to assign.

`publisher` is the only input to the mount prefix, through one function, so
first-party and third-party cannot drift into two code paths.

sidecar.runtime is a field because officer-pty needs node for node-pty's abi
while everything else is bun — one plugin already needs it, so not speculative.

dependsOn is informational and unenforced. code dependencies need no declaration
now that a plugin builds inside the workspace, and service dependencies already
degrade; this exists so the store can say the console section wants the terminal
plugin, rather than the section silently doing nothing.

health is marked deferred rather than open, with the reasoning, so it does not
get re-raised. migrations likewise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:05:15 +00:00
pastilhasandClaude Opus 5 f6b2905cc7 how the frontend ships, and what plugins may depend on
everything moves to the plugin, frontend included, so federation stopped being
a later problem and had to be answered. it is answered by not needing it: bun
builds the spa into build/ at start and rebuilds it on install, serving from
that directory instead of compiling through the html import. Bun.build is a
runtime call, so an install needs no restart — just a refresh. same origin
throughout, which is why there is no cors work and no rewrite of useClient.

App.tsx keeps core routes and gains one map over `plugins`, each mounted at a
wildcard delegating to the plugin's own router. that list comes from a generated
Plugins.tsx, because a bundler cannot follow import(runtimeString) — the
specifier has to be concrete before the build. the six places the shell
currently hardcodes headscale collapse into that one file, dock included; the
runtime dockItemsFromPlugins path follows rather than competing with it.
presentation moves to build time, permission stays runtime.

dependencies turned out to be two different problems wearing one word. a service
dependency (assist → anthropic-proxy) is a wire call and already degrades. a
code dependency (ConsoleView → TerminalView) is in the bundle and cannot. rule:
may depend, must degrade. service calls go through the api carrying the user's
token, with the user's own permissions, which also deletes the state-file read
claude-proxy uses today to lift the proxy's secret.

no per-plugin permission list: a plugin is part of the app and bounded by the
account calling it. that makes marketplace review a security boundary rather
than a naming one, which is worth knowing rather than discovering.

and the developer environment is a platform checkout — clone it, run dev, build
the plugin inside. the 13 workspace packages resolve by name because bun links
them, so `import { useClient } from 'hooks/useClient'` just works with no
registry and no versioning. dev-time and build-time become the same mechanism.

also writes down the headscale inventory now that it has been read end to end,
including that assist.ts travels unwired as a marker and must not be tidied away
as dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:58:22 +00:00
pastilhasandClaude Opus 5 7f26f0b4b8 offscale is headscale plus the companion, not a rename
the name looks like branding on someone else's project, which is exactly how it
gets 'corrected' back later. it is not: offscale is the stock headscale server
plus the companion that ships beside it, and the invite flow is the first thing
that only exists there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:44:28 +00:00
pastilhasandClaude Opus 5 01a20fff4e the invite flow replaced device enrolment; it is not a gap
closes the one open item left by deleting /api/vpn. removing the vpn capability
leaves no member-grantable headscale surface and that is correct: the owner
mints an invite from the headscale app, the companion turns it into the redirect
the phone claims, and the device joins. no per-member permission on officer is
involved at any step.

recorded as decided rather than open so nobody reintroduces a member-facing
enrolment route believing something was lost. nothing was — /api/vpn/enroll was
the design the invite flow replaced, and it never had a UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:43:42 +00:00
pastilhasandClaude Opus 5 88a44ec4a7 delete /api/vpn
it had no caller. verified three ways before removing: nothing in the mobile
monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and
the one app rendering VpnScreen never passes embedded), nothing in the officer
web app references it, and the live database holds no vpn grants. the companion
was checked separately by its own author — zero references there either.

and it will not come back. offscale is permanently standalone: the thing that
gets you to the platform cannot itself need the platform, or a broken tailnet
locks you out of both.

gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps
a comment where the capability was, because its removal has a cost worth
recording — headscale is admin-only, so no member-grantable headscale surface
remains, and reintroducing one is a deliberate act rather than an oversight.

kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now
unreachable, but the file is also the dispatcher for /enroll/invites, which is
live and fundamental. the header comment now says so, so nobody deletes it
looking for dead code.

also records the third component in the doc. two of the three have an "enroll"
surface and only one is ours: /api/v1/enroll/* belongs to the companion, is
where the phone actually goes, and must not be collapsed into /api/offscale/*.

capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the
8 are pre-existing, in totality and path-to-capability, which is precisely the
machinery dynamic mounting will rework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:40:44 +00:00
pastilhasandClaude Opus 5 bbc60b34ac headscale leaves the baseline, and offscale gets a design doc
first step of extracting headscale into a plugin. CORE_PROCESSES is five now,
and the catalogue.test CORE[] mirror follows it — not optional, since that list
asserts "the catalogue must not offer a core process" and would have blocked
adding offscale to the catalogue later.

the local generated ecosystem file lost its entry too, and officer-headscale was
stopped and deleted from pm2 by hand. the platform still mounts /api/headscale
and still declares the headscale and vpn capabilities, so the feature is
present-but-unavailable rather than gone.

docs/offscale-plugin.md is a live document for the rest of it. what it records
that nothing else does: core is now `officer` alone and everything else is a
plugin; routes are /api/<app-name> for ours and /api/p/<creator>/<app-name> for
third parties, derived by one function so the two can never become two systems;
tables stay in public with an app-name prefix; mounting becomes genuinely
dynamic, which retires the "every route stays mounted" premise and relocates
assertCapabilityTotality from a boot check to a per-mount transaction.

it also records a rejected experiment with evidence — a postgres schema per
plugin works completely, including cross-schema FK, idempotent push and
DROP SCHEMA CASCADE as uninstall — and the reason not to: drizzle-kit 0.31.8
needs schemaFilter naming every schema, contradicting its own docs, and without
it push reports "No changes detected" and creates nothing. a plugin install that
reports success and makes no tables is the exact failure shape we have hit three
times this week.

and /api/vpn is dead: no caller in the mobile monorepo, none in the web app, no
grants in the database. offscale is permanently standalone, so it never comes
back. the invite flow is unaffected — the phone claims from the Companion, not
from officer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:37:55 +00:00
pastilhasandClaude Opus 5 d000cedf2f let anyone toggle hidden files again
the show/hide dotfiles button was dead for members. they open the browser at
their own home, that home is `/`, and the toggle is disabled at `/`.

it was never meant to apply to them. 74894b0c wrote it as

    user?.role === 'Super Admin' && currentPath === '/'

to keep the OWNER's home root readable — it is all .bashrc and .ssh and
.claude. then 044aacf4 removed the multi-user surface, dropped users.role, and
noted in its own message that "every role === 'Super Admin' check was
permanently true". so it folded the conjunct away and left `currentPath === '/'`.

correct on a single-user server. multi-user came back on 2026-08-07 and this
line did not come back with it, so a rule about one account's home quietly
became a rule about everyone's — and members feel it constantly, because
members are always at their root.

removed rather than restored to owner-only: the point was a tidy default, not a
prohibition, and `files/showHidden` already defaults to false. so dotfiles stay
hidden until asked for, everywhere, for everyone — and the asking now works.

the server never filtered dotfiles; readdir returns them and always did. this
was only ever the client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:11:05 +00:00
pastilhasandClaude Opus 5 336e718463 read a member's transcripts as the member
a provisioned member could chat normally and had no conversation list. every
refresh came back empty, so nothing could be resumed, and a new chat never
became a saved one.

nothing was wrong with the logic. the turn runs as them, writes its transcript
into their home, and the platform looks in exactly the right place — it just
cannot read what it finds.

confineUserTree grants the service user a named acl entry on every member home,
with d: defaults so anything created later inherits it. that entry is real and
getfacl shows it. it does not survive a file created at mode 600, because posix
derives the acl mask from the group bits of the creation mode:

    user:officer:rwx    #effective:---
    mask::---

claude writes every transcript at exactly that mode — .claude and projects/ are
775, every *.jsonl is 600. so readdir and stat worked, every read raised eacces,
and summarizeTranscript catches eacces and returns null. the sessions did not
fail, they vanished.

no acl can fix this. the creation mode ands the mask down, so d: defaults cannot
raise it, and the only way up is through `other`, which is every account on the
box. a 600 file has two readers: its owner, and root.

so read as the owner of the file, through the same runAsArgv the terminal and
the agent already use. spawnSync keeps it synchronous, which is what lets it
drop into a 914-line synchronous parser reached from five modules instead of
rippling await through all of it.

the privileged surface turned out to be seven call sites, not the file: stat
needs traverse and readdir needs read, and the 775 directories give both. only
content needed identity.

also fixes a 500. parseClaudeTranscript read the file uncaught after an
existsSync that passes, so deep-linking /chat/<id> as a member threw rather than
404ing. it returns null now, like the list path always did.

verified against a throwaway linux account provisioned the same way a member is
— 700 home, named acl, transcript written as them at 600. before: 0 sessions and
loadClaudeSession null. after: the session, its title, its messages, and a
rename that leaves the file owned by the member at 600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:58:36 +00:00
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
pastilhasandClaude Opus 5 571d0a62ff delete the bug-report discord webhook, and the last dead HOME_DIR reads
DISCORD_BUG_REPORT_WEBHOOK is gone, with sendToDiscord and its helpers. Reports
still land in DATA_PATH/bug-reports — the disk write always happened first and
the webhook was only a ping about it, so nothing about the report is lost.

It was a personal notification channel living in deployment config, on a platform
whose owner is the only person who files reports. It was also never in
.env.example: the setup script wrote a variable nothing documented, which is the
same drift as PORT, in the other direction.

Note DISCORD_WEBHOOK_URL is a DIFFERENT variable — the notify sidecar's own
channel — and is untouched.

Then a parity sweep of setup / .env.example / what the code reads, which turned
up two leftovers from earlier today:

HOME_DIR was still read in six files, each with its own `?? homedir()` fallback.
Dead since nothing sets it, but a dead read is worse than none — it reads as a
supported override. They take homedir() directly now. user-instance.ts gets a
comment on why its line stays where it is: it sits above `process.env.HOME =
homeDir`, and homedir() reads $HOME, so a read moved below that assignment would
return whichever member was last spawned into. Two of the six had fallback chains
ending in process.cwd() and '' — the second would have silently disabled whatever
consumed it rather than failing.

VAULTWARDEN_URL was uncommented in .env.example among the variables setup writes,
though it is a plugin variable setup has never written. Commented out with the
other plugin entries.

The three files now agree: setup writes PORT, BROWSER_RELAY_PORT and
POSTGRES_URL; .env.example lists those plus JWT_SECRET and VAULT_STORE_KEY, which
are required by code and deliberately unwritten until the secret store lands.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:23:32 +00:00
pastilhasandClaude Opus 5 f063fc0c08 remove origin validation
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag
defaulted to ON, so none of it ran on a real install — what comes out is
documented defence in depth that was already switched off. The file said so
itself: "Both flags and their call sites come out once the tailnet is the
perimeter."

Origin was never authentication here in any case. An app's `officer://<hex>`
origin is chosen by the client, forgeable outside a browser, and extractable from
a shipped binary.

Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt,
originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN
scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which
existed only to pin them. CORS now echoes whatever Origin it is given, which is
what every install already did.

What SURVIVES is the reason this needed care. origin-validation.ts held two
unrelated things, and the second was the global authorization gate — a valid
non-owner token reaches only what its role grants, deliberately NOT under the
flag because it is account-based rather than origin-based. Its own comment called
it "the airtight half". Deleting the file wholesale would have deleted
authorization.

So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with
the name matching what it does: nothing in it reads an Origin header any more.
hono.ts mounts it in the same position, ahead of every router.

origin-middleware.ts stays and is untouched — it extracts the Origin for six auth
handlers that log it, and for passkeys. Extraction, not validation.

Also updates every claim that rested on the old model: CLAUDE.md's security
section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five
messages in machine-setup's Tailscale section which told the owner to set
ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to
follow, and the honest version is different: with no tailnet the token is the
whole lock, so put a proxy in front and restrict who can reach it.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:15:25 +00:00
pastilhasandClaude Opus 5 c5adb4aa08 the anthropic proxy binds PORT + 1
ANTHROPIC_PROXY_PORT is gone. It was 5051 hardcoded in four files: proxy.ts,
which binds it, and three others that guessed the same constant to find it.

It is the only sidecar that binds a fixed port, and that part is a real
constraint rather than an oversight. Every other one binds `port: 0`, lets the
kernel choose and reports back over the registration socket — which works because
their consumer is the platform. The proxy's consumer is `claude`, spawned by a
different pm2 process that needs ANTHROPIC_BASE_URL at spawn time and has no
channel to ask what port the proxy landed on. Two processes with nothing between
them have to agree in advance.

So the number must be predictable, but it need not be 5051 — a value chosen
against nothing, in the registered range, free to collide with anything the owner
installs later. The symptom of that collision would have been chat failing while
the rest of the platform looked healthy.

PORT + 1 keeps the predictability and drops both the constant and the variable.
Nothing to set, no second number to keep in agreement with the first, and the
pair moves together when the install moves.

Also corrects .env.example, which said the proxy "holds the API credential, which
lives in the host env". It does not. The upstream credential is the OAuth token
claude writes to ~/.claude/.credentials.json, and the ANTHROPIC_API_KEY the agent
presents is the proxy's own generated secret.

Verified the derivation at PORT=9000 and PORT=10000; all four consumers now import
it; every edited file parses. Still not typechecked — empty node_modules, frozen
installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:51:03 +00:00
pastilhasandClaude Opus 5 3c7f52ab77 PORT is read in one place, and it has no default
officer-url.mjs is now the only file in the tree that touches process.env.PORT.
Twenty-two others read it and supplied their own default; a value with
twenty-two sources is not configuration, it is twenty-two things to keep in sync,
and they had already drifted three ways.

It throws when PORT is unset rather than guessing. A default only covers the case
where .env was never loaded — which is not a machine anyone wants running,
because POSTGRES_URL is missing in the same breath. What the default bought was a
process that starts, binds somewhere unexpected, and fails later for a reason
that does not name the cause. Same posture as jwt.ts with JWT_SECRET.

It is .mjs, not .ts, and that is the whole reason this could be one file. pm2
launches officer-pty with node (ecosystem.config.cjs) and everything else with
bun; node cannot import TypeScript, so a .ts module would have left the pty
sidecar holding the only surviving copy of the default — precisely the thing
being removed. allowJs is already on, so the TS callers still get types. Verified
both runtimes import it, and that PUBLIC_URL-style overrides still work.

It also exports API_URL and OFFICER_API_URL, because nineteen sidecars were
independently building `ws://127.0.0.1:${PORT}` and two more were building the
http form. Those are one listener described in two protocols — no sidecar binds
anything — so they belong beside the port rather than being rediscovered per
file.

server.tsx now takes PORT as a number, so Number(PORT) at the serve site is gone.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; node and bun both load the new module; the unset
and non-numeric paths were exercised; the pm2 profile still loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:43:39 +00:00
pastilhasandClaude Opus 5 e72cae4830 one default port, and it is 9000
Every PORT fallback in the tree now says 9000. There were three answers to one
question, each defensible where it was written and none of them visible from the
others:

  server.tsx and 19 sidecars   5000    a default from before there was an installer
  user-instance.ts             9010    what scripts/setup-old/setup.sh really wrote
  .env.example                 9000    what we told people to write

5000 goes first because macOS binds it — AirPlay Receiver has owned it since
Monterey, so a dev server there fails to bind or gets shadowed by something that
answers.

All 22 sites moved together, which is the point. Changing the app alone would have
turned a consistent-but-wrong default into a split one: the app on 9000 while
nineteen sidecars still dialled 5000.

9010 was the interesting one. It was the only value that ever matched a real
machine, because it is what the old installer wrote — and it was in the single
file whose disagreement would have broken chat alone, with nothing else looking
wrong. Its own comment records the same bug being fixed once already, within the
file, by a change that left it disagreeing with everything outside it.

Note what these defaults actually are: the sidecars bind nothing. user-instance.ts
has no listener at all — it builds ws:// and http:// URLs that both address the
app's single listener. So every one of these numbers is a guess at where the app
is, for a value that .env always supplies. Worth removing rather than aligning,
which is a separate change.

Not typechecked (empty node_modules, frozen installs). Every edited file parses
under `bun build --no-bundle`; the pm2 profile loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:38:15 +00:00
pastilhasandClaude Opus 5 3bc06bba3c spell the root derivation as resolve rather than dirname
resolve(process.cwd(), '..') instead of dirname(process.cwd()). Identical on
every input — checked including trailing slash and filesystem root — and it reads
as the path arithmetic it is. resolve was already imported here for SEED_PATH.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:23:10 +00:00
pastilhasandClaude Opus 5 3f071c0b24 the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone
from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and
VAULT_STORE_KEY are no longer written by the setup script.

data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/,
capabilities/ and dockers/ as fixed names under it. The direction used to run the
other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in
app-store/paths.ts — which meant three environment variables that had to agree
with each other and with the tree on disk.

Eight files re-read process.env.DATA_PATH independently, each with its own
`?? cwd()/data` fallback. They import the one value now, which is what made
removing it safe: otherwise each would have derived its own and drifted.

Three things this turned up.

The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a
comment asserting "__dirname is the repo root — this file sits beside
ecosystem.config.cjs", which stopped being true when these files moved into
ecosystem-files/. It walks up to the platform's package.json now, which holds
wherever the file lives. That was a live bug before this change and a load-bearing
one after it, since cwd now decides where the install is.

assertInstallLayout joins the other two boot assertions. A wrong cwd does not
error — it computes a plausible root somewhere else and writes managed homes and
agent runs into it, so the install looks empty and the data looks lost with
nothing naming the cause. It throws before serve(), first of the three, because a
wrong answer there makes the other two check the wrong files.

getOwnerHomeDir captures homedir() once at module load rather than per call.
Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME
when set rather than reading passwd, and user-instance.ts assigns process.env.HOME
on its way to spawning an agent. A lazy read would have returned the owner's home
on the first call and a member's afterwards. data-path.ts imports only node
builtins, so it is evaluated before any of that runs.

JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script
does not boot — jwt.ts throws at module load without one. That is the agreed
sequencing: they move to the SQLite store (docs/secret-store.md), and writing them
here meanwhile would create a second origin for a secret the store then has to be
reconciled with. Said plainly in .env.example and in lib/env.sh rather than left
to be discovered.

Not typechecked: node_modules is empty here and installs are frozen. Every edited
file parses under `bun build --no-bundle`; the profile loads and pins the right
cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup
section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx
is not the pinned resolution and reformatted unrelated unions and line wraps in
six files, so those were reverted and the edits re-applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:18:48 +00:00
pastilhasandClaude Opus 5 86079adb9a mail transport is configured in the app, not in .env
MAIL_TRANSPORT was a fallback left from the old registration flow that sent
confirmation mail. That flow is gone; the variable outlived it.

It was never the primary source anyway. getTransport reads server_config
('server-settings' → smtp) first, which already backs a full UI at Settings →
Server → SMTP and its API in api/server-settings/smtp.ts, supporting resend,
smtp and mailhog. The env var only answered when that was absent — which is a
second source of truth for something the owner can already set, with the failure
mode that a stale URL in .env silently answers for a server whose settings row
is simply empty.

Removed from transport.ts, .env.example and the setup script's Environment
section, which no longer asks for it. setup-old/ still mentions it; that is the
archive and is left alone.

Also split the try. It wrapped the read AND the transport construction and
swallowed both, so three different problems produced one message. Unreachable
database, nothing configured, and stored settings that do not build a transport
now say different things, because the fix for each is different and this message
is all the caller ever sees.

The two consumers — queue/engine.ts and auth/forgot-password.ts — now raise
until SMTP is set in the UI, which is the honest answer rather than a regression.

Not typechecked: node_modules is empty here and installs are frozen. transport.ts
parses under `bun build --no-bundle`; the setup script was run and no longer
prompts for or writes the variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:01:17 +00:00
pastilhasandClaude Opus 5 040ea41dbc per-user linux accounts are not optional any more
OFFICER_OS_USERS is gone. The platform behaves as it always would have with the
flag on, and there is nothing to enable.

Six conditionals, five of which were dead weight — provisionOsAccount,
deprovisionOsAccount and the create/delete paths each opened with an early
"not enabled on this server" return, and the API told the frontend whether to
render the Linux controls at all. Those go, along with the 'disabled'
DeprovisionResult stage, which nothing can produce now.

The sixth is the one with teeth. assertSecretsClosed opened with
`if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when
the feature is off, so an existing install is unaffected until the owner opts
in". It is now unconditional: the server refuses to boot while any .env in the
project root is group- or world-readable. A member's shell reading .env and
printing JWT_SECRET was confirmed exploitable when this check was written, and a
prerequisite that only holds when somebody remembers to set a variable is not a
prerequisite.

Nothing to remove on the environment side — the flag was never in .env.example
or in the setup script.

Not typechecked: node_modules is empty in this tree and installs are frozen, so
tsgo could not run. All six files parse under `bun build --no-bundle`, and the
changes are deletions of dead branches plus one removed early return. Formatted
with prettier 3.9.6 via bunx rather than the pinned resolution, for the same
reason; its one unrelated reformat was reverted by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:58:35 +00:00
pastilhasandClaude Opus 5 32af97e260 secret-store: the anthropic proxy secret moves in too
Third value for the store, agreed in conversation. Neither an encryption nor a
signing key — a bearer credential the agent presents to the proxy on localhost —
but it qualifies on the same properties: generated once, shared between two core
processes, fatal to regenerate silently.

It makes the case better than the other two, because it is not in .env. It is in
$DATA_PATH/sidecar/claude-state.json, which is the exact location decision 3
rules out by name: DATA_PATH is what gets backed up.

Also records the rename. ANTHROPIC_API_KEY is wrong in both halves — not
Anthropic's, not an API key; Anthropic's real credential is the OAuth token in
~/.claude/.credentials.json that the proxy swaps this one for. It is
anthropic-proxy-secret everywhere we control, and keeps the CLI's name only on
the assignment `claude` itself reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:52:23 +00:00
pastilhasandClaude Opus 5 cb67b22f80 officer-setup: the environment section
Writes .env, and the whole point of the section is the two values it must not
write twice.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:41:49 +00:00
pastilhasandClaude Opus 5 9eabc3ee4c design: the secret store
Written from the conversation of 2026-08-12. Nothing implemented; every claim
about the current tree was checked on that date.

The short version: secrets stay in Postgres, the keys move out of .env into a
small SQLite store, and rotation becomes an operation instead of data loss.

What is actually wrong today is narrower than "secrets in .env" and worth stating
precisely, because the separation that exists is correct and must survive a
refactor: secrets live in Postgres and the key that opens them does not. The
problem is blast radius across processes — Bun auto-loads .env, so
VAULT_STORE_KEY sits in the environment of all twenty pm2 processes, and
officer-music holds the key that decrypts wallet seed envelopes for no reason.

The document records the decisions and, more usefully, what was ruled out:

  Keys cannot go in Postgres. A dump would carry the ciphertext and the thing
  that opens it. Encrypting the key with a second key only moves the question —
  one secret has to be readable without any other, and the only decision is where
  it lives.

  The store is not encrypted at rest, and this was tested rather than assumed:
  stock SQLite silently ignores unknown pragmas, so `PRAGMA key` succeeds,
  encrypts nothing, and the value is readable with `strings`. bun:sqlite ships
  stock SQLite 3.53.0. Whole-file encryption needs SQLCipher, which is a second
  native dependency, and this project already knows what one of those costs.

  SQLite rather than a flat file for rotation, not secrecy: rotation needs key
  VERSIONS, since an interrupted rotation needs the old key and the new one to
  both exist.

  The file must not live in $OFFICER_ROOT/data/ — that is what people back up,
  and a key store in the same tarball as a database dump rebuilds the problem.

It also records the core/plugin split the design assumes: light plus
officer-headscale is the core, because CLAUDE.md rests the security model on the
tailnet and a model that rests on the tailnet cannot treat administering it as
optional. Vaultwarden and the wallet become plugins. Moving headscale into light
removes it from the app store automatically, since catalogue.test.ts asserts the
catalogue equals full minus light.

Five open questions are left open rather than guessed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:36:14 +00:00
pastilhasandClaude Opus 5 0bceace6f1 an officerdev docker network, and the reasoning next to the port binding
One network for everything Officer provisions, created before anything joins it
and declared external in the compose file. Postgres needs nothing from it today —
the platform is a host process reaching it over loopback — but a reverse proxy in
front of the web UI does, and so does any app-store service that talks to
another. Creating it now means the later ones do not have to be migrated onto it.

Two things written next to the line they explain, rather than assumed:

Why loopback. Publishing a port makes Docker write its own DNAT and ACCEPT rules
into iptables, and those are evaluated BEFORE ufw sees the packet — so
`ports: "5432:5432"` is reachable from the internet while `ufw status` reports
everything denied. That is the same mechanism the machine-setup firewall section
hooks DOCKER-USER to close. Binding to 127.0.0.1 sidesteps it: the DNAT rule only
matches traffic arriving on loopback.

Why the password is not decoration. Loopback means nothing off this machine, but
every account ON it can open 127.0.0.1:5432 — including the per-user Linux
accounts Officer gives its members. What stops them is that they cannot
authenticate. The password is the boundary between the platform and anyone with a
login here, which is why it stays random and why both files holding it are 0600.

A unix socket would remove even that, and was ruled out for a specific reason:
postgres.js only treats a host as a socket path when the host FIELD contains a
slash (src/index.js:468), and officer_db/src/db.ts passes a bare URL string. It
would take a change to db.ts, which is not a setup-script change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:17:07 +00:00
pastilhasandClaude Opus 5 a64d5610e6 officer-setup section 5: Postgres, and only Postgres
The original offered five containers. Of those:

  Postgres    the only database Officer has — account, passkeys, settings,
              dashboards, email accounts, the queue. Required.
  Redis       not referenced anywhere in the platform. No import of the client,
              no environment variable, no mention; the only "redis" string in
              src/ is the word "rediscover" in a comment. Dropped. (It is still
              in package.json and comes out in the dependency pass.)
  SearXNG     zero references anywhere. Dropped. If it ever arrives it brings
              its own compose file and its own Redis with it.
  Mailhog     a development convenience, offered separately rather than here.
  Nginx PM    a deployment choice — Caddy, Traefik, nginx or the tailnet — and
              not something a setup script should pick.

Provisioned into $OFFICER_ROOT/dockers/postgres/, the same convention the app
store uses: one directory per service, the compose file in it, relative bind
mounts so the data sits beside the compose file.

Bound to 127.0.0.1, deliberately and with the reason in the compose file itself.
Docker publishes ports by writing iptables rules underneath ufw, so "5432:5432"
is reachable from the internet whatever the firewall reports — the same mechanism
the machine-setup firewall section exists to close. The platform runs on this
machine, so loopback is all it needs.

The password lives in a 0600 .env beside the compose file rather than inside it,
so the compose file can be read or copied without carrying a credential. A second
run reuses it rather than minting a new one, which would leave the container and
the URL disagreeing.

Readiness is waited for rather than assumed: Postgres initialises its data
directory on first start, and db:push against a database that is still starting
fails in a way that reads as a schema problem.

Choosing an existing database checks the URL but does not insist on it — the URL
may be right and the database not yet started, and refusing to continue over that
would be worse than saying so.

Also carries the whitespace fix for the comment removed in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:12:10 +00:00
pastilhasandClaude Opus 5 911b79b6a2 drop a comment describing a machine that no longer exists
paths.ts carried a parenthetical explaining that the development machine had the
layout inverted — the project inside ~/dockers/officer.dev/, so the root derived
to officer.dev and the app store's directory came out as a dockers inside a
dockers.

That machine is gone. The project sits at ~/officerdev/platform, which is the
clean shape the comment said new installs would get. Anyone reading it now goes
looking for a directory that is not there and comes away unsure whether the
derivation can be trusted.

The rule above it is unchanged and is the whole contract: data/ is a direct child
of the root, and OFFICER_ROOT is dirname(DATA_PATH).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:07:19 +00:00
pastilhasandClaude Opus 5 a0b083db8f officer-setup section 2: one root, and nothing configurable underneath it
$OFFICER_ROOT/
    platform/       the app
    data/           managed homes, attachments, job logs
    dockers/        anything the app store provisions
    capabilities/   skills, tools, tasks, processes

The original asked separately for DATA_PATH and OFFICER_ITEMS_DIR and left the
app store's directory implicit — three answers that had to agree with each other,
given by somebody with no reason to know they had to. One question now, at the
top of the run, and the rest follows from it.

This is also what the code already assumes rather than a new convention:
app-store/paths.ts derives OFFICER_ROOT as dirname(DATA_PATH) and DOCKERS_DIR as
OFFICER_ROOT/dockers, so writing DATA_PATH=<root>/data is the whole of what makes
the layout correct. No code changes.

Anybody who wants data/ on a bigger volume can symlink it. That is a decision
about storage, not about how Officer is laid out, and it does not need a prompt.

The one check worth having: a directory that exists but belongs to somebody else.
That happens when an earlier run created it as root, and everything written into
it afterwards fails in a way that reads as a permissions bug in the platform
rather than as a bad directory. Reported with what writes there and offered as a
chown.

Placed before the repository, because the checkout lands inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 22:02:21 +00:00
pastilhasandClaude Opus 5 8a0946ea39 officer-setup section 3: dependencies
bun install, as the account, in the checkout.

Two things stated because a failure here is otherwise opaque.

The lockfile is frozen — bunfig.toml sets [install] frozenLockfile = true — so
bun resolves from bun.lock and nothing else. A package.json that disagrees with
it is a hard failure rather than a quiet resolution, which is deliberate: the
friction exists so an unexplained lockfile change shows up in a diff. If the
install fails complaining about the lockfile, the section says that is the
frozen lockfile working and that it wants a human to read the diff, rather than
reporting a generic failure.

node-pty has no Linux prebuild, so this compiles it from source on every machine.
That is what build-essential and python3 are in machine-setup's core utils for,
and the section says so — the failure would otherwise surface much later as a
terminal that never starts.

Success is checked by the artefact rather than by the exit status: bun can
complete while the native module is not built, because it skips a dependency's
lifecycle scripts unless it trusts the package. So the section looks for
node_modules/node-pty/build/Release/*.node and, when it is missing, names the
consequence and the command that fixes it instead of reporting success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:55:04 +00:00
pastilhasandClaude Opus 5 ee21fa16f0 officer-setup: the repository URL is https
https://gitea.officer.dev/officerdev/platform.git, not the ssh form.

The reachability check is now one test for either scheme: `git ls-remote` with
both prompts disabled. That is the real question — not whether the host answers
but whether this account can read the repository — and neither prompt fails
cleanly on its own. Over https git asks for a username nobody is there to type;
over ssh it asks for a password or stops on host-key verification. With
GIT_TERMINAL_PROMPT=0 and BatchMode both off, an unreadable repository is an
immediate non-zero rather than a hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:52:53 +00:00
pastilhasandClaude Opus 5 a5a8cd4e9c officer-setup section 2: the repository
Clones from ssh://git@gitea.officer.dev:2222/officerdev/platform.git, or uses the
checkout already at $OFFICER_ROOT/platform.

Cloned as the account, never as root. A repository owned by root is one the owner
cannot pull, cannot commit in, and whose node_modules they cannot write — and
every later section in this script writes into that directory as them.

Three things it refuses to do quietly:

  It does not repoint an existing remote. This checkout points at
  gitea.pastilhas.dev rather than the new gitea.officer.dev; that is reported
  with the command to change it, because where somebody's work pushes to is
  their decision.

  It does not pull over uncommitted changes. A dirty tree means the pull is
  skipped and said so, rather than failing halfway or burying the work.

  It pulls with --ff-only, so a failure means the branch has diverged rather
  than that the network was down, and the message says which.

SSH reachability is checked before the clone, not after. An ssh URL with no
usable key does not fail cleanly: git prompts for a password nobody is there to
type, or stops on host-key verification. BatchMode turns both into an immediate
answer, and the check reads the server's response rather than the exit code —
Gitea greets a successful authentication and then exits 1, so exit status alone
reports success as failure.

When the key is missing it offers the https form of the same URL, which works
without a key if the repository is readable anonymously, and otherwise stops and
says to add the key. Verified against the new host: ssh authentication from this
account already works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:48:35 +00:00
pastilhasandClaude Opus 5 b724e3ffbe start officer-setup: pre-flight, inheriting what machine-setup already asked
The second half of the install, and a much smaller script than the original: of
the old setup.sh's thirteen sections, seven are machine-setup's job now and two
more were already removed. What is left is the repository, dependencies, the
database, .env, the schema, the build and pm2.

Pre-flight asks nothing on a normal run. machine-setup saves the account, the
Officer path and the role beside itself, and this reads the same file — so
machine-setup then officer-setup is two scripts and one set of answers. It
prompts only where that file is absent, which is a supported case rather than an
error: somebody may have provisioned the box their own way.

It then checks the machine is actually ready — git, node, bun and pm2 required,
docker optional — and reports all of them together with what each is for. Finding
out about a missing bun three sections in, after a repository has been cloned and
a database started, is a worse way to learn it. A missing required tool stops the
run and names machine-setup.

Found by running it: a remembered answer can go stale. My own earlier testing had
left SETUP_USERNAME=gitfresh in that file, for a throwaway account I then deleted,
and the run dead-ended on it. A remembered account that no longer exists is a
reason to ask again, not a reason to stop — so it is checked before it is
trusted, reported, and replaced.

Docker being absent is a warning rather than a failure: Postgres can be one you
already run, and the app store simply cannot provision until Docker is there.

Sections 2 to 9 are listed and not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:45:02 +00:00
pastilhasandClaude Opus 5 e120dfa36e full read: fix the set -e footguns a full run would have hit
Read the whole thing — 2392 lines of entry point and 2700 of libraries — looking
for what shellcheck cannot see. shellcheck itself is clean at error level; its
warnings are cross-file false positives and one deliberate tilde in a display
string. Everything below is a real defect.

── The Git section aborted on any machine where git was not already configured ──

`git config --global --get <key>` exits NON-ZERO when the key is simply unset,
and `VAR="$(git_get …)"` propagates that under `set -e`. So on a fresh machine —
the case this script exists for — the section died at its first assignment,
before printing anything, and took the remaining nine sections with it.

It passed every earlier test because those harnesses sourced the section under a
`bash -c` with no `set -e`. Verified now against a genuinely fresh account with
the real script: the section completes and writes a correct .gitconfig.

── An optional step failing aborted the whole run ──

Twelve functions ended on a command that can fail — `systemctl enable --now
earlyoom`, `systemctl restart systemd-logind`, `chsh`, `sysctl -w`, `chown -R`,
the oh-my-zsh installer, and others. Called as plain commands under `set -e`, any
one of them failing ends the script, so a masked unit or a container without
systemd would abort a 28-section run over an optional improvement.

They now return 0 explicitly and the callers verify the outcome instead — which
also fixed a lie: the sleep section printed "sleep disabled, logind reloaded"
whether or not the restart had worked. It now checks the targets and the logind
values and reports honestly.

── chown user:user assumed the primary group is named after the user ──

True on Debian and Ubuntu, which create a group per user. Not true for an account
from LDAP, or made with `useradd -g users`, or on an image with a shared group —
there `install -g <user>` fails with "invalid group" and the step aborts. Proved
it against an account whose primary group is `oddgroup`: the old form fails, the
new one gets ownership right. Eight call sites now ask `id -gn`.

── Also hardened ──

agent_path and current_editor gained `|| true` for the same reason git_get needed
it: "nothing is set" is an answer, not a failure.

Verified afterwards: shellcheck clean at error level, every section runs
standalone without aborting, and the two apparent failures in that sweep are
correct behaviour — Timezone and Git refusing an empty answer from /dev/null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:34:20 +00:00
pastilhasandClaude Opus 5 30052e3295 port the firewall, last, and bind the Docker rules to the real interface
Last in the run for the reason the original gave: enabling a firewall is the one
step that can cut the connection it is running over.

The security bug is in the shipped rules. ufw-docker-rules.conf hardcodes eth0 in
all three of its rules. Docker publishes container ports by writing its own
iptables rules underneath ufw — DOCKER-USER is the hook that lets ufw have a say
at all — so on a machine with predictable interface names (ens18, enp1s0, most
VPS images) none of those rules match, the final DROP never fires, and every
published port is open to the internet while `ufw status` reports active. A
firewall that says it is working and is not is worse than no firewall. The rules
are now substituted with the interface the machine actually uses, verified by
applying them against a stubbed ens18.

Order inside the section is the other thing that matters: OpenSSH is allowed
BEFORE anything is enabled, unconditionally, because a firewall enabled without
an ssh rule on a machine reached over ssh needs a console to fix. The prompt says
so, and says to open a second session before closing the current one.

tailscale0 is checked and offered, because the default is deny inbound and the
tailnet is an inbound interface like any other — without that rule Officer is
unreachable over the tailnet while Tailscale reports itself connected.

A correction to something I said while writing this: I reported that this host was
missing its tailscale0 rule. It is not. I had run `ufw status verbose | head -8`,
which cut the output above the rule list. The full status shows it allowed, and
nothing was wrong.

That leaves the NOT PORTED list empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:25:13 +00:00
pastilhasandClaude Opus 5 d6a78d7b5a put the shell configuration in one place, after everything it configures
The Shell section moves to 26, after Neovim, the runtimes and the agent CLIs.
Everything that writes to .zshrc now happens there and only there: the starship
init, the PATH for the agent CLIs (moved out of that section), the aliases, and
the editor.

That ordering is what the editor choice needs — it offers whichever of nvim, vim
and nano are actually present, so it has to run after Neovim is installed rather
than naming an editor that is not there. Which was the original's mistake in the
other direction: it set core.editor to nvim four sections before installing it.

The default editor is the setting git's core.editor was deliberately left out in
favour of. EDITOR, VISUAL and SUDO_EDITOR go in the account's shell, and the
Debian `editor` alternative is set too — an account's shell config cannot reach
root or sudoedit, and those are exactly the cases where the wrong editor is most
annoying.

Recorded a limitation of append_once while cleaning up after it: renaming a
marker orphans the block that used the old name, and changing a block's content
does nothing because the marker is still found. Both need the old block removed
by hand. This run left exactly that — a `local-bin` block superseded by
`agent-clis` — in the dev box's .zshrc, now removed.

UFW is deliberately still unported and will be last, for the reason the original
gave: it is the one step that can cut the connection the run is happening over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:22:15 +00:00
pastilhasandClaude Opus 5 607a962115 port the agent CLIs, using Anthropic's installer rather than npm
Claude Code goes in through https://claude.ai/install.sh, matching what the
platform already does for members in os-user-claude.ts and chosen there for the
auto-update npm does not give. The comment at os-user-claude.ts:28 claiming
setup.sh already did this was simply wrong — setup-ubuntu.sh used
`npm install -g @anthropic-ai/claude-code`.

Two things that installer insists on, both of which a naive port gets wrong and
both of which os-user-claude.ts had already found:

  It REFUSES to run under sudo from a regular user's shell — it checks for uid 0
  with SUDO_USER set, because everything it writes goes under $HOME and under
  sudo that is root's. This script runs as root, so the install has to be done AS
  the account.

  It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash. On
  Ubuntu /bin/sh is dash and `| sh` fails.

Two bugs found by running it rather than reading it:

  opencode does not install to ~/.local/bin. It goes to ~/.opencode/bin, which is
  what sidecar/opencode/index.ts:22 hardcodes. The first version looked in the
  wrong place, reported a working install as missing, and installed it again —
  the run said "did not complete" while the installer had plainly succeeded.

  claude on this machine came from npm, so `command -v claude` found it and the
  section would have left a copy that never updates. It now detects an npm
  install by resolving the binary into node_modules, says so, and offers to
  reinstall through the official installer — naming the npm copy and how to
  remove it rather than deleting something it did not put there.

~/.local/bin and ~/.opencode/bin are both added to the account's PATH. The
sidecars do not need it — they check the exact paths — but a user who cannot run
`claude` in their own terminal reasonably concludes it was never installed.

PI stays optional and says outright that nothing in the platform spawns it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:18:10 +00:00
pastilhasandClaude Opus 5 cb3e7062b9 ensure the bun symlink on every run, not only after installing it
The link was made as part of install_bun, so a machine that already had bun never
got one. That is this machine: bun 1.3.14 in ~/.bun/bin, no /usr/local/bin/bun,
and `bun` resolving to nothing at all for root. Nothing has broken yet only
because `pm2 startup` has never been run here — the moment boot persistence is
enabled, all twenty ecosystem apps that say `script: 'bun'` fail at boot and work
perfectly when started by hand.

ensure_bun_symlink now runs whether or not this script did the install, and says
which of the three things happened: made it, found it already correct, or could
not find bun to link. The last records an error, since a missing link is a
reboot-shaped failure rather than a cosmetic one.

Safe across upgrades, which was the question: a symlink resolves by path, not by
inode, and `bun upgrade` replaces the file at $BUN_INSTALL/bin/bun rather than
moving it. Demonstrated by replacing a target with a new file — new inode, link
still resolves. It breaks only if the home directory goes, which breaks bun
anyway.

Also fixed the status line, which reported "not installed" on a machine with bun
in the user's home: it asked root's PATH, which is exactly what has no bun before
the link exists. bun_version now asks whichever copy is there.

This run created the link on this machine — /usr/local/bin/bun -> the account's
copy, and root can now run bun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:11:38 +00:00
pastilhasandClaude Opus 5 00e58931ff port the JS runtimes: node, bun and pm2 installed rather than offered
Not a choice. Officer does not run without them, so asking would be asking
whether to install Officer — which was settled by running the script. They are
installed and reported, with the reason each is load-bearing stated once:

  node  pm2 is a Node application, and officer-pty compiles node-pty against
        whatever Node is installed. There is no Linux prebuild, so this is not
        an ABI question — it is a build dependency on every machine.
  bun   the platform itself and nineteen of the twenty pm2 apps.
  pm2   supervises all of them, and the ecosystem files are written for it.

Node now tracks the current LTS, asked of nodejs.org, rather than the pinned
setup_22.x the original used — which ages into "the version we happened to pick"
the moment a new LTS lands. Resolves to v24.19.0 (Krypton) today, and NodeSource
publishes setup_24.x, checked with a HEAD request before anything is piped into a
shell.

Deno is the one genuine choice and stays optional, defaulting to no. Nothing in
Officer imports it — verified across the whole tree, the only references left are
in the old setup script — so the prompt says that outright and offers it for the
user's own work rather than pretending it is part of the platform.

bun is installed as the account and then symlinked into /usr/local/bin. pm2
started at boot by systemd has no login shell and therefore no ~/.bun/bin on
PATH; without the symlink every bun-based sidecar fails on reboot and works when
started by hand, which is a miserable thing to debug.

Every install is verified after it runs rather than trusting an exit status. A
NodeSource run can succeed while apt holds an older nodejs back, and reporting
the version asked for instead of the one present is how a machine ends up
disagreeing with its own setup log. Tested with an installer stubbed to succeed
and change nothing: both report failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:08:41 +00:00
pastilhasandClaude Opus 5 999362948f allow Node 22 or newer, and record why it was pinned to exactly 22
The preinstall check demanded exactly 22 — `v < 22 || v > 22` — which refuses
Node 24, the current LTS. Relaxed to `>= 22`.

Recording the reason it was exact, because it was deliberate and the details are
gone: some months before now there was a real node-pty build failure that pinning
to 22 solved. Nobody remembers what it was. That is exactly the kind of decision
that gets undone twice, so it is written down here, in CLAUDE.md, and in the
project memory rather than living in one person's recollection.

What the evidence says now: node-pty 1.1.0 ships prebuilt binaries for
darwin-arm64, darwin-x64, win32-arm64 and win32-x64 — and nothing for Linux. So
its install script always falls through to `node-gyp rebuild` and compiles
against whatever Node is installed. There is no prebuilt binary, so there is no
ABI to mismatch, and node-pty declares no engines field.

That reasoning is sound and completely untested: nothing here has built node-pty
against 24, and node_modules has never existed on this machine. If `bun install`
fails building it, or officer-pty cannot load its native module, restore the exact
pin — CLAUDE.md says so, with the line to put back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:04:13 +00:00
pastilhasandClaude Opus 5 068a310bd3 add python3 to core utils — node-gyp needs it, and node-gyp is not optional
node-pty ships prebuilt binaries for darwin-arm64, darwin-x64, win32-arm64 and
win32-x64. That is the complete list — there are no Linux prebuilds. So on Linux
its install script always falls through to `node-gyp rebuild` and compiles from
source, every time, on every machine.

node-gyp needs Python 3. python3 was in the old setup.sh and I dropped it when
rewriting the package list as "what the script itself would break without" —
which missed that the thing it breaks is not this script but `bun install`, later,
with an error about a Python that was never mentioned. The terminal sidecar then
does not come up, and the reason is three steps removed from the symptom.

build-essential was already there and is the other half of the same requirement;
they are now noted together where they are declared.

Found while answering whether node-pty constrains the Node version. It does not —
with no prebuilt binary there is no ABI to mismatch, so it builds against whatever
Node is installed, including 24. The exact-22 pin in package.json:11 is the
platform's own choice, not node-pty's requirement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:01:17 +00:00
pastilhasandClaude Opus 5 0286cc6db6 port the Neovim section
Kept as it worked — upstream tarball, symlink, a config repo cloned into the
account's ~/.config/nvim — with the defects fixed rather than the design changed.

The one that mattered: the asset name. Neovim publishes nvim-linux-x86_64.tar.gz
and nvim-linux-arm64.tar.gz. The original mapped aarch64 to "aarch64", which is
not a name Neovim has ever published, so on an arm machine it downloaded a 404
and handed the HTML error page to tar. Verified against the release API — same
class of bug as lazygit's hardcoded x86_64, and the second one this port has
found in an arch mapping. The tarball is now checked with `tar -tzf` before
anything is removed, so a bad download says what is wrong instead of failing
inside tar.

The rest:

  The tarball went to the working directory, via `curl -LO`, and stayed there if
  tar failed. It goes to /tmp and is cleaned up.

  The old /opt install was removed before the new one was known to be good. The
  download and its sanity check now come first, so a failed fetch leaves the
  working copy alone.

  The custom-repo option defaulted to git@gogs:andrepadez/nvim-config.git — a
  private repository nobody else can clone, and the same mistake as defaulting
  the login server to a personal headscale. No default now.

  git clone runs from /, for the reason git config does: the script's working
  directory is usually under the invoking user's home at 0750, which the target
  account cannot stat.

  The ~/.config/nvim/.git removal is now conditional on it being the starter.
  That is a template and dropping its history is right; a config of the user's
  own is something they will want to keep pulling.

  An existing config is left alone and said so, rather than moved to a .bak that
  silently overwrote the previous .bak.

The PATH line the original appended to .zshrc is gone. /usr/local/bin/nvim is
symlinked and already on PATH, so it was doing nothing except growing the file on
every run.

Verified on this host (already current, existing config left alone) and against a
fresh account (LazyVim starter cloned, owned correctly, .git dropped).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:54:59 +00:00
pastilhasandClaude Opus 5 5a33a517e7 move Tailscale ahead of the command-line tools
Now section 6, with the tools at 7. No dependency in either direction: Tailscale
needs curl, which core utils installs at 5, and nothing in it touches lazydocker,
lazygit, starship or fastfetch.

Same reasoning as putting it early in the first place — it is a second way into
the machine, so it should exist before anything that can go wrong does, and
fetching four upstream binaries is a longer gap than it needs to sit behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:51:39 +00:00
pastilhasandClaude Opus 5 2e70a6dd21 ask before replacing a config the user already has, and show the difference
Keeping theirs silently was safe but unhelpful: they never learn a newer version
exists, and the only hint was a cp command printed in a warning. Now it asks.

  [1] keep yours — nothing changes
  [2] use ours — yours is kept as <file>.before-machine-setup
  [3] show me the difference first

The diff is labelled "yours" and "ours" rather than by path, so - is what you
would lose and + is what you would gain, and it goes through the pager because a
config diff is routinely longer than a screen. Choosing to replace always keeps
the old file beside the new one; nothing is destroyed.

An unattended run — ASSUME_YES, or no terminal on stdin — keeps theirs and says
so. "Yes to everything" cannot sensibly mean "overwrite configuration nobody was
present to defend", so this is the one prompt ASSUME_YES answers conservatively
rather than affirmatively.

Applies to every file that goes through install_config, which is .tmux.conf and
starship.toml today and is where any other dotfile should go.

On the starship question: there is one file now, scripts/setup/starship.toml, and
the inline copy is gone. Owner and members get the same prompt, which is what
os-user-shell.ts always claimed.

Verified through a pty, since the -t 0 guard correctly makes the interactive path
untestable over a pipe: the diff renders, replacing writes the backup, and the
live file ends up byte-identical to ours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:48:08 +00:00
pastilhasandClaude Opus 5 2b9e16c11e port the shell section, and make one starship config serve both audiences
os-user-shell.ts:33 calls scripts/setup/starship.toml "the prompt config the
owner's own install uses — one file, both audiences". It was not: the original
machine script wrote a DIFFERENT config inline, so the owner got a prompt that
only disabled language modules while every member got the repo file with its
custom format. Two prompts, one comment claiming otherwise.

This deploys the same file the platform does, which makes the comment true.
Verified with cmp against a fresh account: byte-identical to what a member gets.

Nothing overwrites any more:

  .config/starship.toml and .tmux.conf go through install_config, so they are
  written when absent, skipped when identical, and KEPT when they differ — with
  the cp printed, so taking ours stays the reader's decision. On this host that
  is what happens: the existing config differs and is left alone.

  The starship line in .zshrc is marker-wrapped by append_once. Verified over
  three consecutive runs: one block, not three. The original appended it
  unguarded every time.

The login shell is now its own question. Having zsh on the machine and being
handed it at every login are different decisions, and `chsh` made the second one
silently. It also adds the shell to /etc/shells first, which chsh requires.

.tmux.conf lives here now, with the rest of the dotfiles, rather than in user
creation where the original put it only because that is where $USER_HOME first
exists.

One bug found by running it: install_config returns 2 for "kept yours", which is
an outcome rather than a failure — but still non-zero, so calling it as a plain
command under `set -e` ended the run before `case $?` could read it. Captured with
&& / || at both call sites, and the contract is documented where the function is
defined so the next caller does not repeat it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:45:25 +00:00
pastilhasandClaude Opus 5 03cc01a135 say that stopping and coming back costs nothing
Choosing option 1 means leaving to set up a server elsewhere, which could take an
afternoon. The run now says outright that Ctrl-C is fine and that returning picks
up here — completed steps skipped, answers kept — so nobody feels they have to
finish in one sitting or start over.

The same promise once at the top, where the resume notice already was. That
notice is now phrased as what it means rather than as file paths: "2 step(s)
already done, and they will be skipped", with the command to start over instead
of a bare mention of the file.

On the question of why pre-flight kept re-asking: it does not, and I caused what
you saw. Nearly every test command I have run today ended with
`sudo rm -f .setup-answers`, so the file was deleted between your runs. Proved the
round trip — a run with the variables set writes all three, and a second run with
no environment at all asks nothing and prints what it remembered. The file is
gitignored, so there was never a reason to be deleting it. Stopped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:39:39 +00:00
pastilhasandClaude Opus 5 4fcc34de18 frame the public server as common to all three, not a cost of offscale
"offscale runs on a publicly reachable server" read as a demand offscale makes
and the easy route does not. It is not. Tailscale's coordination server is
publicly reachable too — they run it for you, and that is the entire difference
between option 3 and hosting it yourself.

Said that way round, the requirement stops being a reason not to self-host and
becomes what self-hosting means. Same sentence in all three places: the menu
entry, the branch taken when 1 is chosen, and the long answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:37:26 +00:00
pastilhasandClaude Opus 5 a989f8fbfa say where offscale runs, which matters more than how it installs
"One command to install" was the wrong emphasis and read as though it happens
here. It does not: a coordination server has to be reachable by every device that
joins, including phones on mobile data and laptops in other buildings, so it
needs an address that resolves from anywhere. It goes on a small public VPS of its
own — not this machine, and not behind a home router.

That is the thing people get wrong, and getting it wrong produces a private
network unreachable from exactly the devices it exists to reach. It is a property
of being the thing everyone checks in with, so it is true of headscale too, and
the long answer now says so.

Choosing option 1 leads with it, links the install anchor rather than the page,
and says plainly that nothing below will work until that server is up and
answering — so somebody who has not done it stops here instead of typing an
address that does not exist yet.

"Installs in one command" survives in the goodies list, where it belongs: it is
one command ON THAT SERVER, and the sentence now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:34:40 +00:00
pastilhasandClaude Opus 5 c629a849d9 make all four network options work, including having no network at all
Option 1 no longer refuses. It assumes offscale is already running — installing
it is one command, documented on the site — and asks for its address and a key,
which is mechanically what option 2 does. The two share a branch because the
difference between them is what to say, not what to do: one is "you already have
a server", the other is "set one up first, here is where".

Option 4 is new: no private network. Presented as a real choice rather than a
failure to choose, with what it costs stated before it is taken and paged so it
is read rather than scrolled past:

  · anything reachable remotely has to be published deliberately and kept closed
    otherwise
  · TLS certificates are yours to obtain and renew
  · every exposed service needs its own authentication, since there is no longer
    a boundary in front of it
  · the machine will be found — anything on a public address is scanned within
    minutes

And the one that is specific to this platform rather than general advice:
ALLOW_ANY_ORIGIN defaults ON, which is deliberate and only defensible because
the tailnet is the perimeter. With no tailnet it must be set to false with an
HTTPS proxy in front, or Officer runs with a check disabled on an assumption that
is no longer true. The summary line says so, so it survives the run.

Declining option 4 redraws the menu rather than dropping to a bare prompt.
Tailscale is still installed when 4 is chosen, and the run says how to connect it
later.

Verified all four end to end with tailscale stubbed, plus the decline-and-choose-
again path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:32:27 +00:00
pastilhasandClaude Opus 5 06492c3297 name the control server explicitly, and log out before moving between them
Two gaps found by tracing the assembled command rather than assuming it, and the
first meant option 3 did not work at all on a machine like this one.

`tailscale up` with no --login-server keeps whatever ControlURL is already
stored. So on a node already pointed at a self-hosted server — which this host is
— choosing "the easy route" left it exactly where it was. No error, no message,
and a summary line claiming it had connected. The URL is now passed explicitly in
both cases, TS_DEFAULT_CONTROL_URL for Tailscale's own service.

And a node logged in to one coordination server cannot simply be pointed at
another; it has to be logged out first. That is now detected by comparing the
stored URL with the target, and offered rather than done quietly — the tailnet
drops while it happens, and the run says so, because on a machine reached over
the tailnet that is the session you are reading this in. Declining leaves the
node where it is and records that.

Verified all three paths with tailscale stubbed: switching logs out then connects
to controlplane.tailscale.com, declining leaves it on offscale, and reconnecting
to the SAME server offers no logout at all.

Also noted while tracing: lan_cidr correctly finds nothing on this host, since a
/32 with host routes has no subnet to advertise. That means the homelab
subnet-router prompt is the one path here that has not been exercised on real
hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:29:26 +00:00
pastilhasandClaude Opus 5 22a661131d page the ? output
The long answer is now well past a screen, so it scrolls the question off the top
and the reader lands at a prompt having lost what they were choosing between.
Piped through a pager, so it is read a screen at a time and the menu is redrawn
underneath it afterwards.

`more` rather than `less`: it exits at the end of the file instead of sitting
there waiting to be quit, which is right for something asked for once.

Only when stdout is a terminal. Redirected or piped — a transcript, a log, the
test harness — it comes through whole, since a pager there either blocks or
mangles the output.

Applied to confirm()'s help hook as well, so every ? in the script pages, not
just this one.

Verified both ways: driven through a pty it shows --More--, and with output
piped all four help sections come through in full.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:27:00 +00:00
pastilhasandClaude Opus 5 85d8fa62f1 mention that Officer administers the network it just told you to set up
The section explains three ways to get a coordination server and then says
nothing about running one afterwards, which is the part that decides whether
self-hosting is a good idea. Officer's Headscale app is the answer to it, and it
works against headscale and offscale alike.

Written from what the app actually does rather than from the pitch: several
servers registered and switched between, each PROBED rather than remembered — the
comment in ServersView.tsx is explicit that a "not checked" dot is the one thing
that list must never show — and, on the active one, nodes, users, pre-auth keys,
invites and the ACL policy with an assistant, plus a console and diagnostics.

Placed in FOR OFFICER rather than under offscale, because it is true of either
self-hosted option and is the reason picking one is not a commitment to
administering it over ssh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:13:54 +00:00
pastilhasandClaude Opus 5 766c8ee655 say what the mobile app actually is, rather than overclaiming it
"our implementation of the same protocol rather than a wrapper around theirs"
was too strong. It is the Tailscale client with our branding, and one real
difference: it takes an invite from the server directly.

The corrected version is not a weaker claim, it is a more specific one. "Our own
implementation" invites the question of whether it is trustworthy and whether it
keeps up; "the Tailscale client, our branding, and it takes an invite directly"
answers both — it is their client, so it is as good as their client, and the
thing it adds is the thing that was hard.

The reason it is hard stays in, since it is what makes the difference worth
naming: the official app has to be talked into using a server that is not
Tailscale's, and that is where people abandon self-hosted headscale. And it still
says there are no desktop apps of our own, now with what to do instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:04:39 +00:00
pastilhasandClaude Opus 5 2d544db07e write the offscale copy from officer.dev, and link it in both places
Read https://officer.dev/infrastructure/offscale.html and used its own words for
the protocol claim — "the protocol on the wire is Tailscale's, the encryption is
WireGuard's" — which is stronger than my paraphrase and is the sentence that
stops "our own distribution" reading as a fork.

The goodies are named rather than gestured at. No placeholders left:

  · installs in one command, with the certificates handled
  · health, logs, restarts and access policies from the app, instead of a config
    file and a CLI
  · enrolling a device is a link and a tap — the key is minted and handed over
    for you
  · several networks at once, and services reachable across them

The URL appears twice, as asked: in the menu entry, where somebody deciding
between three options can reach it without typing ?, and at the end of the long
answer for somebody who read the whole thing and wants more.

The mobile-app paragraph stays and is not from the page — the page does not name
its client platforms. It is your account of them, kept because it answers the
obvious objection to self-hosting, and it still says plainly that there are no
desktop apps of our own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:03:03 +00:00
pastilhasandClaude Opus 5 46ce58dd12 correct the client claim: the mobile apps are ours
"talking to stock Tailscale clients" was wrong, and wrong in a way that gave
away the strongest thing offscale has. On computers it is the stock client. On
iPhone, iPad and Android it is our own app — our implementation of the same
protocol, not a wrapper around theirs. No desktop app of our own yet, and the
copy says so.

Stated as a differentiator rather than a footnote, because it is the specific
that answers the obvious objection to self-hosting. Getting the official mobile
app to talk to a self-hosted server is the part of running headscale people give
up at; having an app that simply does is worth more than any sentence about
extras.

The protocol claim is unchanged and still leads, because it is what makes the
mobile app reassuring rather than alarming: our own client is our implementation
of Tailscale's protocol, not a private one. "Our own distribution" plus "our own
app" reads as a fork unless the first thing said is that there is nothing to fork.

Marker narrowed from "goodies" to "more goodies" — one is now named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:57:33 +00:00
pastilhasandClaude Opus 5 2803bc34b7 draft the offscale copy: same protocol, different amount of work
Two things said plainly, because they are the two a reader needs before choosing:

  Nothing about the protocol changes. offscale is headscale's open-source code
  speaking to stock Tailscale clients, so a machine on an offscale network
  behaves exactly as it would on either of the others. Worth stating outright —
  "our own distribution" reads as a fork, and a fork of a network protocol is
  something to be wary of. There is no offscale protocol to be locked into,
  because there is no offscale protocol.

  What changes is the work. Running headscale yourself is a project: install it,
  put TLS in front of it, keep it upgraded, administer it through a config file
  and a CLI. offscale makes that a step in a setup script.

"our own sugar on top" is gone. One marker left, in both the menu and the long
answer: the goodies are unnamed. Two or three specifics would be worth more than
the sentence they replace — every product claims extras, and the claim is only
interesting when it says which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:50:14 +00:00
pastilhasandClaude Opus 5 538a2d3b6d give every network option its own exposition, not just offscale
Each of the three now carries a couple of lines under it, separated by a blank
line, so the choice can be made from the menu itself rather than by typing ? and
reading a page. 2 and 3 are written; offscale's is a marked placeholder with your
one-liner standing in until the longer copy arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:47:51 +00:00
pastilhasandClaude Opus 5 ff7035a47a correct the mechanism recorded for the set -e failure
The previous commit blamed the loop body. That is wrong, and I only found out by
trying to reproduce it: the same `[[ … ]] && assign` inside a case inside a while
loop survives `set -e` perfectly well at top level.

What actually happened is one level further out. The failing assignment was the
last thing the case ran, the case was the last thing the loop body ran, and the
loop was the last thing THE FUNCTION ran — so load_answers returned non-zero, and
calling a function that returns non-zero is a plain command failure, which does
end the script.

Worth getting right because the general rule is different from the one I wrote: it
is not "avoid && in loops", it is "a function whose last statement can return
non-zero fails when it is called, however innocuous the statement looks".

Scanned the libraries for that shape. The only hit is lan_cidr, which ends in an
awk pipeline and returns 0. Predicate functions ending in a bare test —
ballast_exists, has_authorized_key and the rest — are meant to return non-zero
and are only ever called in conditions, which set -e exempts.

The fix itself was already correct and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:46:30 +00:00
pastilhasandClaude Opus 5 4d478cc0f1 add the offscale line, and fix a set -e bug the test exposed
The menu is one function now rather than being written out twice — it is shown
again after ? prints the long answer — and option 1 carries your line: offscale is
just tailscale and headscale, with our own sugar on top.

The bug it surfaced is the more useful half. load_answers used

    [[ -z "${MACHINE_ROLE:-}" ]] && MACHINE_ROLE="$value"

as the last statement in a while-read loop body. When the variable is already set
the test is false, the compound returns non-zero, and as the final statement in a
loop body under `set -e` that ends the script. The failure is silent about its
cause: the trap prints "Step: unknown" and a line number inside the library,
before pre-flight has run.

It needed both conditions to appear — an answers file on disk AND the variables
already set in the environment — which is why every earlier test missed it and
running with env overrides hit it immediately. Written as if/then now, and the
other lib files scanned for the same shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:45:19 +00:00
pastilhasandClaude Opus 5 6a29c39b74 restructure the Tailscale network choice, with offscale left to be written
Four options, in the order you gave:

  1  set up your own network        (offscale)
  2  use a network you already run  (headscale, offscale)
  3  the easy route                 (tailscale.com)
  ?  what are tailscale, headscale and offscale?

? prints the long answer and then shows the options again, rather than dropping
the reader back at a bare prompt having forgotten what they were choosing
between.

The explanation frames all three as one question — who keeps the list of your
machines and hands out the keys — and says plainly that the coordination server
never carries traffic, since that is the thing people assume it does. Tailscale
and headscale are written. OFFSCALE is a marked placeholder, and so is what
option 1 actually does; both are yours to fill in and the run says so rather than
pretending.

Option 3 is the plain flow: no --login-server at all, and the auth-key prompt
says what that means — leave it blank and Tailscale prints a link that either
creates the account or adds this machine to an existing one. Option 2 keeps the
"no suggested URL" rule, because a coordination server URL is somebody's private
infrastructure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:41:43 +00:00
pastilhasandClaude Opus 5 059f0f6df2 lead the Tailscale section with the question, not the explanation
Ten lines of prose before the first prompt assumed the reader had never heard of
Tailscale. Anyone already running it does not need to be told what it is, and
having to scroll past it every run is the cost of writing for the other reader.

The prompt comes first now, and `?` is an answer. Typing it prints the full
description and asks again; not typing it costs nothing.

confirm() takes an optional help function as its third argument. Where one is
given the prompt becomes [Y/n/?], so the explanation announces that it is
available without taking up room. The same hook is there for any other section
that wants it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:34:09 +00:00
pastilhasandClaude Opus 5 a63a327065 remember the pre-flight answers between runs
The --only flag worked, in that it reached the section — but reaching it meant
answering four pre-flight questions first, every time, which is not usable for
working on one section. The same problem was already there without --only: a
resumed run re-asked the role, the account and the Officer path that it had been
told on the previous pass.

Answers are saved beside the progress file and loaded before anything is asked.
The environment still wins over what was saved, so SETUP_USERNAME=x on the
command line overrides it, and --reask throws the file away and asks again.

Read as assignments rather than sourced. The file sits next to the script and is
read by a run that is already root; sourcing it would make it executable content
in a place nothing guards.

Second run now goes straight through pre-flight, printing what it remembered, to
the one step asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:29:15 +00:00
pastilhasandClaude Opus 5 09303299cc port Tailscale as its own section, early, and stop it hanging
Moved to position 7 — after core utils, which give it curl, and well before SSH
hardening, which is the step that can lock you out. The argument is that
Tailscale is a second way into the machine, so it wants to exist before anything
that can go wrong does.

── Why the original hung, and what stops it now ──

Its prompt accepted an empty auth key and passed it anyway. `tailscale up
--authkey ""` falls back to the interactive flow: it prints a URL and blocks,
with no timeout, forever. From the outside that is a script that has frozen.

Nothing here passes an empty key — the flag is omitted entirely, and the run says
in advance that a URL is coming and that it will wait. Every call carries
--timeout=60s, and a timeout is reported with the command to run by hand rather
than left as silence. State is read with `tailscale status --json` before
anything is run, so a node that is already up is offered a reconfigure instead of
having `up` fired at it blindly.

Diagnosed on this host rather than guessed at, and honestly the diagnosis is
partial: the exit-node branch left no trace at all — no /etc/sysctl.d file,
networkd-dispatcher present but with zero mentions in apt history, so it came
with the image. ip_forward=1 came from the unconditional part of the section, not
the branch. That points at `tailscale up` as where it stopped, and the empty-key
path is the candidate that fits, but I could not reproduce it to be certain.

── What the section now covers ──

  control plane   Tailscale's own service by default; a self-hosted headscale as
                  an explicit choice with NO suggested URL. The original defaulted
                  to headscale.pastilhas.eu, so a stranger running it pointed
                  their machine at somebody else's control plane.
  auth            key, or the browser flow, stated as an equal option
  Tailscale SSH   ssh over the tailnet with no keys, governed by tailnet ACLs —
                  and pointed out as a way back in if the sshd hardening later in
                  the run goes wrong
  subnet router   homelab only, defaulting to this machine's actual LAN CIDR
  exit node       with what it means for whose traffic goes where
  forwarding      sysctls and Tailscale's recommended NIC offload settings, and
                  only when an exit node or a route actually needs them

Approval is mentioned: an advertised route or exit node does nothing until it is
approved in the admin console, which is otherwise a silent non-event.

Also added --only <step> and --list, because this section in particular needs to
be run on its own while it is being worked on. A step run that way ignores the
progress file and does not record itself — asking for one step is not progress
through the script.

One bash quirk fixed on the way: "${VAR:-Tailscale's own service}" does not
parse. An apostrophe inside a ${:-} default opens a quoted section that swallows
the closing brace, and the error surfaces as "unexpected EOF" 400 lines away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:25:12 +00:00
pastilhasandClaude Opus 5 9d53ff506e port Docker, with the group-versus-rootless choice spelled out
Three options, each explained rather than named, because the difference between
them is a security posture and the default is the one that sounds harmless.

  1  docker group, the default. The text says what the group actually is: anyone
     in it can run `docker run -v /:/host -it alpine chroot /host` and have a root
     shell. It is not "access to Docker", it is root by a longer route — the same
     framing os-user-docker.ts already uses for why members never get it.

     Whether that matters is conditional, and the run works it out rather than
     asserting either way: on an account that already has sudo it is a shorter
     path to something they can reach anyway, and it says so; on an account that
     does not, it is a real escalation, and it says that instead. Caught in
     testing, where the reassuring sentence was being printed for a throwaway
     account with no sudo at all — the exact case where it is untrue.

  2  rootless, with the thing nobody would find out stated at the prompt:
     Officer's app store cannot provision containers with it. compose.ts,
     preflight.ts and system-monitor all spawn `docker` with no environment of
     their own, so they reach /var/run/docker.sock; DOCKER_HOST is set only for
     member commands, in os-user-docker.ts. pm2 started at boot by systemd has no
     session either, so exporting it in a shell rc does not reach the process
     that matters. The consequence is recorded in the summary, not just spoken.

  3  neither, and what that costs.

Also fixed in the port: the repository codename came from `lsb_release -cs`, which
is wrong on every derivative — Mint reports "vanessa", Pop reports its own, and
Docker publishes neither, so `apt update` fails against a repository that does not
exist. os-release carries UBUNTU_CODENAME on exactly those systems for exactly
this reason; it is preferred now, with VERSION_CODENAME as the fallback, and the
ubuntu/debian half of the URL comes from ID_LIKE rather than being hardcoded.

The shared `services` network is created only when missing, checked with
`docker network inspect` rather than by running create and discarding the error.

Verified on this host, and against a throwaway account both with and without
sudo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:11:49 +00:00
pastilhasandClaude Opus 5 f9c6b7925a record the default-editor section, to come last
Asks for nano, vim or nvim and sets EDITOR/VISUAL in the shell config, plus the
Debian `editor` alternative so root and anything reading the system default agree
with it.

Has to come after the Neovim section, or nvim cannot honestly be offered as one
of the choices — which is the same ordering mistake the original made by setting
core.editor to nvim four sections before installing it.

This is the setting core.editor was left out in favour of: one preference that
git, crontab -e, visudo and systemctl edit all follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:04:15 +00:00
pastilhasandClaude Opus 5 4339ab829d set pull.rebase, and say why core.editor is not set
pull.rebase true, which is the setting whose absence stopped this very repository
mid-session today: git refuses to pull when branches have diverged and asks which
of three things you meant, every time, until told once. Rebasing replays local
commits on top of what was fetched rather than adding a merge commit that records
nothing but the fact that you had not pulled yet.

core.editor stays out, deliberately, and the run says so rather than leaving its
absence to look like an oversight. Git's fallback chain is GIT_EDITOR →
core.editor → $VISUAL → $EDITOR → system default, so core.editor is a git-only
override sitting above $EDITOR. The original set both it and `export EDITOR` in
the shell section — two settings for one preference, which drift apart the moment
either is changed and leave git using an editor nothing else does. Setting only
$EDITOR means git, crontab -e, visudo and systemctl edit all follow one answer.

Verified on a throwaway account: .gitconfig comes out with user, init.defaultBranch
master and pull.rebase true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:02:49 +00:00
pastilhasandClaude Opus 5 28673869c3 port the git section, and stop it overwriting an identity that already exists
Detects first, then asks. If there is a name and email configured it shows them
and offers to change them, defaulting to no — the common reason to re-run this
script is everything except this. If there is nothing configured it just asks.

Default branch is master rather than main, unless the answer says otherwise.

Three problems in the original, beyond running unconditionally:

  prompt_value accepts an empty answer, so pressing Enter wrote `user.name = ""`.
  An empty name is worse than none: unset makes git refuse to commit and say why,
  empty makes it commit with a blank author and never mention it. ask_required
  re-asks instead.

  core.editor was set to nvim four sections before Neovim is installed, so
  anything invoking the editor in between failed. Dropped for now rather than
  moved — it is a preference, and worth deciding separately.

  Nothing checked whether the writes worked.

That last one was not theoretical. Testing against a throwaway account, all three
writes failed and the section still printed "OK: written". `git config --global`
needs no repository, but git stats the working directory on the way, looking for
one — and the script runs from under the invoking user's home, which is 0750, so
the target account cannot stat it:

  fatal: failed to stat '<cwd>': Permission denied

The wrappers now run in a subshell from /, which every account can stat, and the
caller checks the exit status and reads the value back before claiming success.

Also recorded where it is written: docs/agent-git-identity.md says every agent
Officer runs commits as the owner, because it runs as the owner. This is not only
the human's identity, it is what git log attributes agent commits to — which is
worth knowing while choosing it.

Verified both paths: this host's existing identity is shown and left alone by
default, and a fresh account gets a correct .gitconfig owned by that account with
defaultBranch master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:00:54 +00:00
pastilhasandClaude Opus 5 08e7de1b6d move unattended-upgrades into core utils, and make sure it is actually on
apt only. It is a Debian and Ubuntu package — dnf's equivalent is dnf-automatic
and pacman has no equivalent at all — so it is not a name to translate across the
other lists.

Installing the package is not by itself enough to switch it on. The apt-daily
timers read /etc/apt/apt.conf.d/20auto-upgrades, and on this host no package owns
that file: `dpkg -S` says it came from nothing, which means the original script
wrote it. So the section checks for it and offers to write it, rather than
assuming the install did.

Beyond that it only reports, because the interesting facts about unattended
upgrades are not whether it installed:

  It never reboots on its own, deliberately. A kernel or libc update is installed
  and then not used, and the machine keeps running the old one until it restarts.
  Nothing announces that except /var/run/reboot-required, which nobody reads. The
  section prints it, names the packages waiting, and puts it in the summary — it
  is the failure people do not notice for months.

  Ubuntu's Allowed-Origins includes plain ${distro_codename} as well as
  -security, so this takes ordinary updates too, not only security ones.

Verified both paths: this host reports enabled with no reboot pending, and
pointing AUTO_UPGRADES at a temp file exercises the enable path and writes the
three periodic settings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:56:27 +00:00
pastilhasandClaude Opus 5 0da184d082 verify the fail2ban defaults instead of recalling them
The previous commit hedged on the ban policy because I thought fail2ban was not
installed here. It is — the earlier ubuntu-setup run installed it — so the claim
could be checked rather than remembered.

Checked, and it was right: /etc/fail2ban/jail.d/defaults-debian.conf ships
`[sshd] enabled = true`, and the running jail reports maxretry 5, findtime 600,
bantime 600. This host has banned 6 addresses already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:52:41 +00:00
pastilhasandClaude Opus 5 f176b92378 move fail2ban into core utils, and reduce its section to a status report
Your call, and the reasoning holds: it configures nothing of its own, an existing
install with its own jails is untouched because pkg_install never names a package
that is already present, and it is worth having by default.

One thing recorded where it is declared, because it makes fail2ban unlike every
other entry in that list: it is a daemon, not a binary. Installing it starts it,
and Debian and Ubuntu ship an enabled sshd jail — so from that moment an address
that fails to log in five times in ten minutes is blocked for ten. That is the
point of it, and it includes you, from wherever you are connecting. (Recalled
rather than verified: fail2ban is not installed on this host and the sandbox
would not let me unpack the .deb to check the shipped jail.d file.)

The section no longer installs anything. It reports whether fail2ban is running,
which jails are active, and how to unban an address — because a daemon quietly
blocking connections is worth knowing about before it blocks yours, and a run
that installs it as one name in a list of twenty gives no hint that anything
started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:52:14 +00:00
pastilhasandClaude Opus 5 bdf13331ae port the static IP section, and offer the actual fix for the reboot-changes-IP problem
Homelab only, as it should always have been. On a vps the provider's DHCP is
authoritative and already stable, and pinning an address there is how an instance
is stranded; on dev the machine moves between networks and a fixed address is the
opposite of what is wanted. Both say so rather than skipping quietly.

The more useful change is that a static address is no longer the only answer
offered, because it is not the right one for the problem it was added to solve.

A fresh Ubuntu box taking a new IP on every reboot is not the router
misbehaving. systemd-networkd's ClientIdentifier defaults to `duid` — man
systemd.network is explicit — so the machine introduces itself to DHCP with an
RFC 4361 client ID built from an IAID and a DUID. This host shows it:

    DHCP4 Client ID: IAID:0x56504d98/DUID

Consumer routers key leases and reservations on the MAC. The two never match, so
the router does not recognise the machine as one it has seen and hands out the
next free address — and a reservation pinned to the MAC is never honoured, which
is the part that makes the router look broken.

`dhcp-identifier: mac` in netplan sets ClientIdentifier=mac and the router sees
what it expects. DHCP keeps working, reservations start being honoured, and
nothing is pinned on the machine. That is now the first option, with the static
address second and still carrying the original's warnings.

It is written as its own 99- netplan file and merged with whatever the installer
or cloud-init already wrote, rather than this script parsing and rewriting their
YAML. Deliberately NOT applied: it takes effect at the next reboot, which is the
moment the problem shows up anyway, so there is nothing to gain by dropping the
network now. `netplan generate` validates before either file is kept, and the
file is removed again if it does not.

Found and fixed while testing: dhcp_client_identifier parsed the value with
awk -F': *' and took field 2 — but the value is itself "IAID:0x…/DUID", so the
split yielded "IAID", the DUID test failed, and the helper reported "mac" on a
machine that was plainly sending a DUID. It would have told the user the opposite
of the truth about their own problem. Reads everything after the first colon now.

Verified on this host: correctly reports duid, explains why, and renders both the
homelab and vps paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:49:50 +00:00
pastilhasandClaude Opus 5 7a5cf89819 port the DNS section, as a choice rather than a decision
The original hardcoded Cloudflare plus Google with no way to say otherwise, and
rewrote /etc/systemd/resolved.conf wholesale — discarding DNSSEC, DNSOverTLS,
Domains and Cache if anything had set them, without mentioning it had. The
settings are a drop-in now, and the resolver is picked from a list with a
"keep what is there" that is the default.

The part worth having explicit is which layer is being changed. With
systemd-resolved there are two:

  per-link   what DHCP handed each interface, and what Tailscale installs on its
             own. These answer for that link's domains — the provider's internal
             names, the tailnet — and are printed by this step precisely to show
             they are NOT being touched. Overriding them is how private
             networking quietly stops resolving.

  global     the resolver used when no link claims the query. This is the one
             the step sets.

On this host that distinction is live: eth0 has Hetzner's resolvers and
tailscale0 has 100.100.100.100, which is what answers ts.pastilhas.dev. Both are
left alone.

The drop-in is named 99- because systemd reads drop-ins in lexical order and the
LAST value wins. That is the opposite of sshd, whose drop-in three files away in
this same directory has to sort FIRST. Both are stated where they are written,
because getting it backwards fails silently in either direction.

resolv.conf is checked for actually pointing at resolved's stub before the
drop-in is trusted to do anything — a machine where something replaced the
symlink with a static file bypasses resolved entirely.

Resolution is tested afterwards rather than assumed. A resolver that does not
answer makes every later step fail for a reason that has nothing to do with it,
so that failure is reported and recorded rather than swallowed.

The choice names what each provider actually is, including that a resolver sees
every name the machine looks up.

Verified both paths against this host: keep reports unchanged, Quad9 renders the
right addresses, and the per-link display shows Hetzner and Tailscale correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:45:24 +00:00
pastilhasandClaude Opus 5 192293cdca offer to add an ssh key even when one already exists
The zip option was already gone — it never came across in the port, since the
file is key material that cannot live in the repository and the script no longer
sits next to it. Pasting a public key was already the first option. What was
missing is the case where the account HAS a key: the section went straight to
hardening, so there was no way to authorise a second machine, a rebuilt laptop or
anyone else, and the original had no way to do it at all.

The same menu is now offered either way. What differs is whether it can be
declined without consequence: with no key, declining means the hardening below
refuses too, and the run says so rather than quietly moving on.

confirm() takes an optional default so this one can be [y/N]. Most questions in
this script are "do the thing you already asked for" and Enter should mean yes; a
genuine extra defaulting to yes is how people end up agreeing to things by
reflex.

A pasted key is trimmed before validation. Copying from a terminal or a password
manager routinely brings leading or trailing whitespace, and ssh-keygen will not
parse a key with it attached — which would have read as "that is not a valid
key" for a key that is perfectly fine.

Also corrected the reason unzip is in core utils, which still said it was there
to open ssh-keys.zip.

Verified: the add-another prompt appears and defaults to no, a whitespace-wrapped
key is trimmed and accepted, and the already-hardened path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:42:58 +00:00
pastilhasandClaude Opus 5 9591f917f5 port ssh keys and hardening as one section, and make the hardening actually work
They were two sections, and being two is what let the second lock you out of a
machine the first had failed to put a key on. Step 8 could warn-and-skip — no
ssh-keys.zip, or an unrecognised menu choice, since its case had no default arm —
and still mark itself done; step 9 then disabled password authentication and root
login regardless. No key, no password, no root, on a box that may be in a
datacentre.

Nothing here turns off password authentication without first confirming a usable
key is in place, and the refusal says why rather than skipping quietly.

The hardening also did not do anything on a modern Ubuntu, and could not be seen
not to:

  It sed'd /etc/ssh/sshd_config. Ubuntu includes /etc/ssh/sshd_config.d/*.conf
  from line 12 of that file, and sshd takes the FIRST value it obtains for a
  keyword rather than the last. Cloud images ship 50-cloud-init.conf containing
  `PasswordAuthentication yes`, read long before the line the sed edited. The run
  reported "SSH hardened" and password login stayed on. The settings now go in a
  drop-in named 01-machine-setup.conf, which is the only placement that wins
  under first-value-wins.

  It also sed'd ChallengeResponseAuthentication, renamed to
  KbdInteractiveAuthentication in OpenSSH 8.7. On 24.04 the old name is nowhere
  in the file, so that substitution matched nothing at all.

State is read with `sshd -T`, which reports what sshd resolves across the main
file and every drop-in — reading the config files tells you what is written, not
what wins.

Keys are counted by asking ssh-keygen to parse authorized_keys rather than by
counting lines: comments, blanks and a half-finished paste all look like lines,
and "there is a file" is not "there is a key that works". A pasted key is
validated before it is stored, and matched on the key body rather than the whole
line, so re-running does not authorise the same key four times over four runs.

sshd -t validates the new config before anything is reloaded, and the drop-in is
restored or removed if it does not parse — a config sshd refuses is a machine
with no ssh after the next restart. Reload rather than restart, so the session
this is running over is not the experiment, and the run says out loud to test a
new connection before closing the current one.

Generating a keypair now says the obvious thing the original did not: the private
key is on the server, and a private key living on the machine it opens is a spare
copy of the lock rather than a second factor.

Verified against this host (1 key, already hardened, correctly does nothing) and
with sshd_effective stubbed to a fresh-cloud-image state — the guard refuses and
harden_sshd is never reached. Also verified key validation, dedup and 0700/0600
permissions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:39:23 +00:00
pastilhasandClaude Opus 5 b1cc916258 detect root by uid, not by the name root
A provider whose image logs you in as "ubuntu" at uid 0 would have walked
straight past the previous check, which compared the string. What makes an
account root is uid 0; "root" is only the usual label for it.

Two places now ask id -u rather than comparing names:

  the answer — an account at uid 0 is refused whatever it is called, and says
  which case it is rather than a bare "not root"

  the invoker — the warning about working as root fires when SUDO_USER is unset
  OR when SUDO_USER is itself uid 0. The second is the one that hides: sudo from
  a uid-0 account sets SUDO_USER to something that reads like an ordinary user
  and is not.

The EUID check that requires the script to run as root was already uid-based and
is unchanged.

Verified by creating a real uid-0 account named ubuntu on this box: refused with
the uid named, where the name check accepted it. That account has been removed —
userdel refused it at first because it matches by uid and saw PID 1 running as
uid 0, so -f was needed, and deliberately not -r, since its home was /root.
Confirmed afterwards that root, /root, root's shadow entry and sudo are all
intact and that root is once again the only uid-0 account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:36:16 +00:00
pastilhasandClaude Opus 5 d9d4085033 warn against working as root at the username prompt
root was already refused as an answer, but only the refusal said so — and only
after somebody typed it. The advice now comes with the question, along with why:
no safety net, a typo in a path that deletes instead of refusing, and nothing to
distinguish you from a process that got out of hand.

An extra warning when SUDO_USER is unset. That means the script was started as
root rather than through sudo, which usually means root is how they log in — the
exact situation the general advice is about, and the one where general advice is
easiest to assume is aimed at somebody else. It says so plainly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:33:32 +00:00
pastilhasandClaude Opus 5 c0eb3e3a85 create the user account first, and fix the sudoers filename bug that found
The user account section is now the first thing that acts, ahead of disk space.

The reason is a real defect, not tidiness. If the account does not exist yet,
USER_HOME is a path that is not there — and the ballast offers to put its file in
it, where ballast_create's `mkdir -p` runs as root and creates /home/<name> owned
by root:root. adduser afterwards finds the directory already present and does not
populate or chown it, so the account ends up with a home it cannot write to.
Making the account before any step can write into its home removes the ordering
entirely.

USER_HOME is re-read from getent after adduser runs. Until that point it is the
/home/<name> guess, because there is nothing to look up; adduser is free to have
used something else and every later step writes there.

Found while testing that, and worse than the thing it was testing:

  local user="$1" dest="/etc/sudoers.d/99-${user}-nopasswd"

bash expands ${user} before the assignment to user has happened, so dest came out
as /etc/sudoers.d/99--nopasswd with the name missing. The rule inside was correct,
which is what made it invisible — visudo passes, sudo works, and the account
really does get passwordless sudo. What breaks is everything around it: every
account granted this way writes to that same file, so a second grant silently
overwrites the first and revokes it; and has_passwordless_sudo looks for
99-<user>-nopasswd, never finds it, and re-grants on every run forever.

Split into separate declarations, with the reason recorded where it happened, and
an empty username is now refused outright. Scanned the other lib files for the
same shape — the remaining multi-assignment locals only read positional
parameters, which is safe.

The stray /etc/sudoers.d/99--nopasswd this created on the dev box during testing
has been removed and visudo -c re-verified.

Verified with a real throwaway account: correctly reports not-granted before,
writes 99-msdemo-nopasswd as root:root 0440 with the right rule, reports granted
after, and leaves sudoers valid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:31:29 +00:00
pastilhasandClaude Opus 5 fbb90c917d default the username to whoever ran sudo, and look up their real home
Three changes to the question at the top of the run.

USER_HOME is looked up rather than assumed. The original built "/home/$USERNAME",
which is only the usual answer — an account created with a different home, or one
whose home was moved, had every later step writing to a directory that was not
theirs. getent passwd knows; the /home guess remains only as the fallback for an
account that does not exist yet, where there is nothing to look up.

The default is now whoever invoked sudo. On a re-run, or on a machine that is
already somebody's, that is the answer every time, and retyping it is a chance to
typo it into creating a second account. root invoking the script directly offers
no default, since root is never the account being set up — and is refused if
typed.

The name is validated against the portable shape of a Linux account name before
anything else happens. Letting adduser refuse it later means several questions
have already been answered against a name that was never going to work.

It also no longer goes through prompt_value, which obeys any environment variable
matching the name it is filling in. USERNAME is set by some login environments,
and a variable this script silently takes as an answer should not be one that
might already be set for unrelated reasons. SETUP_USERNAME is the explicit
override.

The tmux config write moved out of the user section entirely. It was there only
because the original copied it right after adduser, where $USER_HOME first
exists. It is a dotfile and belongs with .zshrc and the starship config in the
shell section.

Verified: defaults to the sudo invoker, resolves daemon's home to /usr/sbin
rather than /home/daemon, falls back to /home for an account that does not exist,
and rejects a name with a space, a leading digit, one over 32 characters, and
root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:27:46 +00:00
pastilhasandClaude Opus 5 3fb0e5c887 port the user account section, and stop clobbering files in the home
Two real defects fixed on the way across.

The sudoers write was in the wrong order. The original echoed the rule straight
into /etc/sudoers.d, validated it afterwards, and chmod'd it later still. A
malformed file there breaks sudo COMPLETELY — and you cannot sudo to repair it,
so on a remote machine that is a rescue console — and so does one with loose
permissions, because sudo refuses to read its own configuration. Both of those
windows were live in the original ordering. grant_passwordless_sudo now writes a
temp file, runs visudo -c against it, and only then places it with install(1),
which applies the content and the 0440 mode in one step. Nothing reaches
/etc/sudoers.d that has not already been validated.

The .tmux.conf copy overwrote whatever was in the home on every run. lib/files.sh
adds the two shapes that stop this whole class of thing:

  install_config  installs when absent, does nothing when identical, and keeps
                  what the user wrote when it differs — printing the cp to take
                  ours, so the choice stays theirs
  append_once     wraps a block in named markers so a second run recognises its
                  own work; also lets a human see which lines came from this
                  script and remove them as a unit

append_once is what the five unguarded `cat >>` into .zshrc need when those
sections are ported — a second pass currently duplicates the starship init, the
nvim PATH, bun, deno and the aliases.

Passwordless sudo is asked separately from creating the account, because it is a
security posture rather than part of making a user, and the cost is stated: a key
that can log into this account is root without a further step. Officer's actual
requirement is stated too — os-user-shell.ts runs `sudo -n`, and a prompt it
cannot answer surfaces as a permissions error rather than a question — and
refusing records that consequence in the summary instead of a bare "skipped".

Verified: all three install_config outcomes, append_once writing exactly once
across two runs, visudo rejecting junk before anything is installed, and the
section reporting correctly against this host's existing account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:22:40 +00:00
pastilhasandClaude Opus 5 458510a0a8 scope sleep to homelab, and port the boot hang fix
Sleep and suspend is homelab only now. The two exclusions are for different
reasons and both are stated in the run rather than left implicit:

  dev — a laptop should sleep; disabling it is a hot bag and a flat battery.

  vps — not merely unnecessary, harmful. A virtual machine has no lid and no
  power button, but the provider's Shut down control works by sending an ACPI
  power button event. HandlePowerKey=ignore makes the VM ignore it, so graceful
  shutdown requests silently do nothing and the instance is hard-killed instead.
  systemd defaults that key to poweroff for exactly this reason.

The boot hang fix is everything except vps, where systemd-networkd genuinely
manages the network and the unit is load-bearing.

Rather than asking whether boot "feels slow" — a question people answer from
memory of the worst time it happened — the step prints what the unit actually
cost on this boot, from systemd's own accounting. On this host that is 14ms,
which ends the discussion. On a NetworkManager desktop it is two minutes, which
also ends it. On dev the wording says outright that a small number here means
there is nothing to do.

The original's live guard is kept and is what actually decides: NetworkManager
active and networkd not. Anything else, including "cannot tell", is left alone,
and the reason is printed. The warning against disabling systemd-networkd
outright is carried across into the library, where the alternative would be
attempted.

Verified all three roles on this host, which is a networkd machine: homelab and
dev both correctly refuse and explain, vps skips as not applicable, and the
timing helper reads 14ms out of systemd-analyze.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:17:17 +00:00
pastilhasandClaude Opus 5 c87127ff93 port the sleep and suspend section
The original ran unconditionally, so a laptop that went through it stopped
suspending — a hot bag and a flat battery. It is a server concern: dev is skipped
with the reason printed, like the ballast.

Four things wrong with it beyond the role:

  HandleLidSwitchDocked was never set. A laptop used as a homelab server, docked
  and closed, still suspends — which is the exact machine this setting exists
  for. Added.

  RuntimeDirectorySize=10% was set alongside the sleep handlers. It is the size
  of /run, has nothing to do with sleeping, and 10% is systemd's own default, so
  the line never did anything. Dropped.

  systemd-logind was restarted on every pass whether or not anything changed,
  disturbing live sessions for nothing. The step now checks first and does not
  reach the restart when the machine is already configured. (The platform's own
  scripts/setup-old/setup.sh already had this guard; the machine script did not.)

  The settings were sed'd into logind.conf in place. They are a drop-in at
  /etc/systemd/logind.conf.d/99-machine-setup.conf now, so what this script set
  is one file that can be read or removed on its own.

Current state is printed before anything is asked — whether the targets are
masked, and what the lid, idle and power-key handlers actually do. logind_effective
reads the main file and every drop-in and takes the last match, since a drop-in
overrides logind.conf; reading only the main file reports a configured machine as
unconfigured.

The power-button consequence is stated rather than left to be discovered: after
this, pressing power physically does nothing and a clean shutdown is
`sudo poweroff`.

WSL has no logind and cannot suspend, and says so.

Verified both roles on this host, which the old script had already configured —
correctly reports the targets masked and the handlers set, and correctly reports
itself not fully configured because HandleLidSwitchDocked is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:11:16 +00:00
pastilhasandClaude Opus 5 6ffd3534bd do not offer the ballast on a dev machine, and route its alerts through one place
Servers only now. On a machine you sit at, a filling disk announces itself — the
editor refuses to save, the browser complains — and you are there to deal with
it. The reserve is for the box nobody is watching, where the first sign is a
service that stopped working hours ago.

Skipped rather than asked, but said out loud with the reason and recorded in the
summary. A section that silently produces no output is indistinguishable from
one that failed.

The cron this section installs was already there and is unchanged: /etc/cron.d
runs the checker as root every ten minutes, and it deletes the ballast when free
space falls under the threshold.

What changed is where its message goes. Both alerts now run through one notify()
inside the generated checker rather than calling logger directly, so there is a
single place to add a second channel. Today it is still syslog only — the
message lands in the journal and nowhere else, so nobody learns about it until
they go looking, which is precisely the wrong moment. Push, mail or Officer's own
notify sidecar hook in there. It also echoes to stderr now, so running the
checker by hand shows the message instead of appearing to do nothing.

Verified: dev reports not-applicable and asks nothing, vps still asks and records
a refusal, and the regenerated checker parses and reports status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:09:03 +00:00
pastilhasandClaude Opus 5 d19dc5a92a ask where the ballast goes and how big it is
Three questions instead of one, because the two the original never asked are the
two that decide whether the thing is useful.

  1. Do you want one, with the explanation first.

  2. Where. Home (easiest to find again months from now), beside Officer, or a
     path typed in. This is not tidiness: the checker measures its own directory,
     so a ballast only protects the filesystem it sits on. Choosing where it goes
     is choosing which mount is covered.

  3. How much, as 5/10/20% — with the actual numbers, and with what would be LEFT
     rather than only what is taken:

       [1]   5%  — reserves 2.9GB    leaving 54.3GB free
       [2]  10%  — reserves 5.8GB    leaving 51.4GB free
       [3]  20%  — reserves 11.5GB   leaving 45.7GB free

     A percentage on its own is unanswerable. The number that decides it is the
     one on the right: the reserve has to be big enough to matter and small
     enough not to be the thing that filled the disk.

The size is computed against the filesystem the chosen path lands on, after the
location is known, so the percentages are of the right disk. ballast_free_kb
walks up to a directory that exists, since nothing has created the target yet.

An existing ballast in either default location is found and left alone rather
than a second one being made beside it.

Verified end to end in a temp home: created at the chosen path, 2.9G for 5% of
57.1GB, checker installed and reporting it present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:05:42 +00:00
pastilhasandClaude Opus 5 d4b9b7b334 ask where Officer should be installed, in pre-flight
OFFICER_ROOT, defaulting to <user home>/officerdev. One directory holding the
four things Officer is made of, per docs/sidecar-app-store.md — the app, its
data, the item store, and any containers the app store provisions — so the whole
installation can be moved, backed up or deleted as a unit.

Asked at the start with the other questions rather than at the point it is first
needed. It decides the shape of several later steps: where the repository is
cloned, where DATA_PATH sits beside it, and which filesystem the app store's
bind mounts come out of. Asking once up front also means the run can be described
before it starts rather than discovered as it goes.

A leading ~ is expanded explicitly. It arrives as a literal from a read or an
environment variable — nothing expands it there — and would otherwise create a
directory actually named "~" in whatever the working directory happened to be.
Relative paths are refused with the value named, and a trailing slash is trimmed
so the path composes cleanly with what gets appended to it.

Nothing creates the directory yet; that belongs to officer-setup. This records
the answer and reports it, including whether it already exists.

Verified: Enter takes the default, ~ expands, trailing slash trims, OFFICER_ROOT
in the environment skips the prompt, and a relative path fails with the value
named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:02:28 +00:00
pastilhasandClaude Opus 5 6947284f1b check the root filesystem is actually using the whole drive
New first section, before anything else that changes the machine, because the
swapfile and the ballast both size themselves from free disk.

Ubuntu Server's installer on its defaults gives the root logical volume a fixed
size and leaves the rest of the drive as unallocated extents in the volume group.
On a 2TB disk that is a ~100G root with nothing to indicate a problem: lsblk
shows the whole drive, df shows 100G, and the two are never seen side by side
until the day it fills. Growing a virtual disk at a provider leaves the same
shape one layer down, and so does resizing a partition without telling the
filesystem inside it.

Three layers, any of which can be the short one, so all three are measured and
printed together:

     drive:        76.3GB   /dev/sda
     volume:       76.1GB   /dev/sda1
     filesystem:   76.1GB   ext4, mounted at /

Seeing them in one place is most of the value. The fix is then whichever layer is
short: lvextend for free extents, growpart for a partition that stops early
(followed by pvresize and lvextend when LVM is in the way), or resize2fs alone
when only the filesystem is behind.

Only ever grows. Nothing here shrinks, creates or deletes a partition, and ext4,
xfs and btrfs all grow while mounted — so no unmount, no reboot, and a failure
part-way leaves a smaller filesystem on a larger container, which is the state it
started in.

growpart is the authority on whether a partition can move — it exits 1 with
NOCHANGE when the partition already reaches the end — but it comes from
cloud-guest-utils, which is not on every image. Installing a package purely to
ask a question is too eager, so plain arithmetic on the device sizes decides
whether it is even worth looking, and only then is growpart fetched.

A gigabyte of slack before anything is reported: a filesystem is always slightly
smaller than its container, and reporting journal and reserved-block overhead as
reclaimable space would make this section cry wolf on every machine.

Verified on this host: plain ext4 partition filling its disk, correctly reports
nothing to reclaim, and every helper returns the right device and size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:59:38 +00:00
pastilhasandClaude Opus 5 9b0e05bfd8 add three resource-pressure sections, each asked rather than assumed
Swap covers memory pressure. These are its neighbours:

  8.  Emergency disk ballast  the same valve, for disk
  9.  earlyoom                what happens when swap runs out too
  10. inotify watch limit     the silent one

All three follow the rule this script now works to: the role sets which way the
recommendation points, never whether the question is asked. A dev machine is
still offered the ballast, with the recommendation pointing the other way; a
server is still offered the inotify raise, because anything running `bun --watch`
or serving a file browser is a watcher too.

The ballast is section 22 of the original, moved up beside swap where it belongs
and moved out of the user's home. The original wrote the checker into
$USER_HOME/.local/bin and ran it from a root cron — a root cron executing a
script in a directory its owner can write is a privilege escalation waiting to be
noticed. Moot on a box where that user already has passwordless sudo, but wrong.
Both the checker and the file are in root-owned system paths now.

Two bugs found by running the generated checker rather than reading it:

  It df'd the ballast's own directory, which does not exist before the ballast is
  created — and with `set -euo pipefail` that meant cron mailing an error every
  ten minutes. It now walks up to a directory that exists, and the installer
  creates the directory itself rather than depending on the create step.

  The inotify text claimed a default of 8192. This host is at 29461: Ubuntu
  raised it, and stating a number the reader can see is wrong on their own screen
  undermines the rest of the explanation. It now describes the failure instead
  and prints the machine's actual value.

earlyoom is a distro package and a systemd unit, so it is checked with
`systemctl is-active` and reports honestly when it installs but fails to start.

Verified: checker --status and its no-op path both exit 0 with no directory
present, and the helpers report correctly against this host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:54:48 +00:00
pastilhasandClaude Opus 5 716a6e2750 port the swap section, and size it against the disk
Four things the original got wrong, all of which only show up on a machine that
is not this one:

  It detected swap with `swapon --show | grep -q '/'` — a test for a swap FILE.
  A machine using zram or a swap partition reports no swap at all, and the step
  would add a swapfile beside working swap. Reads SwapTotal from /proc/meminfo
  now, which covers every kind.

  It never looked at free disk. On a VPS with 4G free and 16G of RAM it would
  fallocate 8G, fail, and take the run down under `set -e`. The recommendation is
  now capped by what is actually there, keeping 5G back, and refuses rather than
  shrinking to something useless.

  fallocate was assumed to work. It produces a file that btrfs and zfs will not
  swap on, so dd is the fallback — slow, but it always works.

  swappiness was written by sed'ing /etc/sysctl.conf in place, tangling it with
  whatever else lives there. It is a drop-in at /etc/sysctl.d now, so what this
  script set is visible as its own file.

Role-dependent, which is the first use of MACHINE_ROLE: swappiness 10 on a
server, where swapping is the emergency valve and a page fault on a request path
is latency somebody is waiting for; the kernel default of 60 on dev, where
swapping out an application nobody has touched in an hour is exactly what you
want.

WSL is left alone entirely — WSL2 runs its own managed swap inside the VM, and a
swapfile written here is wasted disk the kernel will not use.

Sizes are reported rounded rather than floored. A 4 GiB swapfile is 4194300 kB,
which floors to 3 and reads as though a gigabyte went missing. Free disk stays
floored, deliberately: it decides how much to allocate, and rounding up invents
space.

Verified on this host — 4G RAM, 4G existing swap correctly detected and left
alone — and with the disk check stubbed at 6G free (caps to 1G) and 3G free
(refuses).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:48:45 +00:00
pastilhasandClaude Opus 5 b5948f9673 refresh the package index in pre-flight, not inside a skippable step
The refresh lived inside "System update", which `step` skips when its name is
already in the progress file. So a resumed run — the common case, since that is
what the progress file is for — installed core utils, added the fastfetch PPA and
set up the Docker repo against whatever the index happened to say hours or days
earlier. On a box left overnight that is a stale index and a "package not found"
somewhere unrelated.

It now runs in pre-flight, unconditionally, before any step exists to skip it.
`apt-get upgrade` stays where it was and stays confirmable: refreshing the index
changes nothing on the machine, upgrading is the one thing that does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:45:19 +00:00
pastilhasandClaude Opus 5 6480788979 port the timezone section
The original took whatever was typed and handed it straight to timedatectl. An
unknown zone — a typo, a guess at the spelling — fails there, and under `set -e`
that takes the whole run down four steps in. Names are now checked against
/usr/share/zoneinfo before use, and a bad one just re-asks.

It also never showed what the machine was already set to, and defaulted to option
1 (UTC) on Enter, so pressing return on a correctly-configured box silently moved
it. Now the current zone is printed, Enter keeps it, and a zone equal to the
current one reports nothing to do rather than setting it again.

timezone_current reads three sources — timedatectl, /etc/timezone, then the
/etc/localtime symlink — because they differ in availability rather than in
answer: timedatectl needs systemd, /etc/timezone is Debian's, and the symlink is
the one that is always there. timezone_set writes through timedatectl where
there is a systemd to talk to and the files directly otherwise, which is what it
would have written anyway; that is also the WSL path, where timedatectl exists
but does nothing.

Europe/Berlin added to the shortlist; TIMEZONE in the environment answers the
prompt ahead of time and is validated the same way, failing early with the bad
value named.

Verified detection (UTC here), validation of four names, and the env-var
rejection path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:43:20 +00:00
pastilhasandClaude Opus 5 163f8d5899 port the locale section
Was three unconditional lines that ran on every pass and reported success either
way. Now it checks, says what it found, and asks.

The original tracked one fact where there are two:

  what a new login shell is told to use   LANG in /etc/default/locale
  whether that locale actually exists     whether it has been generated

Setting the first without the second is what produces "setlocale: LC_ALL: cannot
change locale" on every ssh login and every perl invocation. They fail
differently, so the step names whichever one is actually missing rather than
reporting a flat "locale not set".

Also fixes two things the original would have hit on a minimal image:

  locale-gen comes from the `locales` package, which cloud base images do not
  ship and which is not in core utils. It is installed on demand rather than
  assumed, instead of failing with "locale-gen: command not found".

  The locale is uncommented in /etc/locale.gen rather than only passed to
  locale-gen as an argument. A locale generated by argument alone disappears the
  next time anything regenerates from that file.

`locale -a` prints en_US.utf8 where the configuration spells it en_US.UTF-8, so
both sides are folded before comparing — a literal match reports a working locale
as missing.

LOCALE in the environment overrides the default. pacman, dnf and brew branches
are written but unreachable while the pre-flight gate is apt-only; macOS has no
system locale to set and says so.

Verified both paths on this host: en_US.UTF-8 reports already set and generated,
pt_PT.UTF-8 correctly reports both facts missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:41:49 +00:00
pastilhasandClaude Opus 5 1beb357f2e drop the ominous wording from the system update prompt
"the only step that changes software already on this machine" is true, and
reads like a warning about something dangerous rather than a description of
apt upgrade. The reasoning stays in the section comment, where it explains why
this is its own step; the prompt just says what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:39:16 +00:00
pastilhasandClaude Opus 5 454faf5406 list what the system update would actually upgrade
The section asked "Proceed?" without saying what it was proposing to change —
the one question in the script where the answer matters most, since it is the
only step that moves versions of software already on the machine.

pkg_upgradable now names them, from `apt-get upgrade -s`: the same calculation
the real run does, as opposed to `apt list --upgradable`, which also lists
packages held back that would not actually move.

Nothing to upgrade means no prompt at all, and the summary says so rather than
claiming an upgrade happened. The list is capped at 25 with a count of the rest,
because a box untouched for months lists hundreds and a wall of names is no more
informative than the number.

Verified against this host (0 upgradable, so it reports current and does not
ask) and with a stubbed 40-package list for the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:39:00 +00:00
pastilhasandClaude Opus 5 f896d4882f one section per concern, each announced and confirmed before it acts
Three sections where there were two, and none of them touches the machine until
you say so:

  2. System update      upgrades what is already installed
  3. Core utils         what the distribution provides
  4. Command-line tools lazydocker, lazygit, starship, fastfetch

The split matters because these are different kinds of change and deserve
separate answers. System update is the only step in the whole script that moves
versions of software already on the machine; core utils only ever adds what is
absent; and the four tools are upstream binaries the distribution does not ship
at all. Previously the update and the core packages were one step and the tools
were tacked onto the end of it, so agreeing to "essentials" meant agreeing to all
three at once.

Every section now prints what it will install and what it is leaving alone, then
asks. Enter means yes — unlike the machine-role question, which has no default,
because these are "do the thing you already asked for" and making twenty of them
require a deliberate keystroke would train people to hold the y key down.
ASSUME_YES=1 answers all of them for an unattended run, and EOF fails with that
named rather than spinning.

Refusing is recorded rather than glossed: LAST_SKIPPED feeds the summary, so a
declined section reads "Core utils: SKIPPED by request — cowsay neofetch" instead
of quietly reporting nothing installed.

Nothing to install means no prompt at all — there is nothing to agree to.

announce_plan takes the array NAMES rather than their contents, because once a
list has been through word splitting an empty one cannot be told from a missing
one.

Verified all three paths: accept, refuse, and nothing-to-do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:37:31 +00:00
pastilhasandClaude Opus 5 a5ef9f7662 report what a section actually installed, not what it was asked for
The summary claimed credit for everything in a section's list, including the
packages it had just decided to leave alone — so a run that installed nothing
still ended with "Command-line tools: lazydocker lazygit starship fastfetch".
The announce above it said "nothing, all present" in the same breath.

pkg_install and tools_install now record LAST_INSTALLED and LAST_KEPT, and
summarise_last turns those into one honest line:

  Core packages installed: btop tmux (17 already present)
  Command-line tools: already present, nothing installed

Verified all three shapes — everything present, nothing present, and mixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:34:34 +00:00
pastilhasandClaude Opus 5 dd655577e3 make the machine-role question require an answer
No default, and it is the only question in the script like that. A guessed
default is right often enough to be trusted and wrong in exactly the case that
costs the most — pinning a static IP on a rented box, or leaving the 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.

Empty and unrecognised answers re-ask rather than aborting; a failed read means
EOF rather than a wrong answer, and fails with the environment variable named,
because otherwise the loop spins forever the first time this runs unattended.

Drops guess_machine_role, which existed only to supply that default. default_iface
stays — the static IP section needs it when it is ported.

MACHINE_ROLE in the environment still answers it ahead of time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:32:41 +00:00
pastilhasandClaude Opus 5 5cb243eed9 port into a clean script instead of editing the original in place
Your call, and the right one. Editing in place let mis-grouped code sit unnoticed
until it scrolled past in a live run — which is exactly how the four upstream
binaries buried in "System Update & Essentials" were found. Porting forces the
question of where each thing belongs before it runs, not after.

machine-setup.sh now contains only what has actually been worked through:
pre-flight, system update and core packages, command-line tools, and the summary.
1149 lines down to 172. The sections still to come are listed in a NOT PORTED YET
block, in order, and each arrives as its own commit.

The original is beside the other superseded scripts as
scripts/setup-old/setup-ubuntu.sh — verified byte-identical to the live
/root/ubuntu-setup copy — so porting reads from a file in the repo rather than
from root's home.

Two claims trimmed from the ported summary, because they were true of the old
script and not of this one yet: it reported the shell as "zsh (Oh My Zsh +
Starship)" unconditionally, and told you to reconnect as a user it had not
created. Replaced with what pre-flight actually knows — system, role, user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:30:29 +00:00
pastilhasandClaude Opus 5 7622239949 split the upstream binaries out of the package section
lazydocker, lazygit, starship and fastfetch were buried inside "System Update &
Essentials", after the package install and with no announcement — so a run
appeared to be installing system packages and then started pulling tarballs and
printing a five-shell starship tutorial. They are a different thing: upstream
binaries on their own release cadence, not anything the distribution ships. Now
their own step, announced in the same shape as the package section.

Each is checked before it is fetched. The original re-ran every installer on
every run, which is why a machine that already had starship got it reinstalled
along with its "add this to your ~/.zshrc" instructions — advice this script
does not want followed, since it writes the shell config itself. Its output is
now dropped; errors still surface.

Two real bugs fixed on the way:

  lazygit's asset name was hardcoded to x86_64, so on arm64 the download 404s
  and tar fails partway through the run. It now maps ARCH, and spells the
  architectures the way lazygit does rather than the way we do.

  The version was extracted with `tr -d 'v'`, which deletes every v in the
  string rather than the leading one. `${version#v}` instead.

fastfetch stays a package but stops assuming the PPA is needed: Ubuntu picked
it up in 24.10, so the repository is now checked first and the PPA added only
where the archive has nothing. Verified on this host — noble genuinely has no
candidate, so the PPA is still the only source here.

Verified both branches of tools_install by stubbing the presence check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:28:46 +00:00
pastilhasandClaude Opus 5 bba6d854bc stop the run at the end of the rewritten sections
A WIP boundary after section 2 so the finished part can be run start to finish
on its own, without the untouched sections below acting on the machine. It moves
down as each section is worked through and goes away when the walk ends.

Also ignores .setup-progress, which the script writes beside itself and is
per-machine. The exit message names it, because with it in place a second run
skips section 2 and the rewritten part cannot be re-felt from scratch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:25:38 +00:00
pastilhasandClaude Opus 5 dbdef23d29 install what is missing and keep what is there, per package manager
lib/packages.sh, and section 2 wired to it.

The rule it exists to enforce: `apt-get install <present-package>` is not a
no-op, it upgrades the package if the repository has a newer one. On a machine
somebody already uses that silently moves a version they chose, and a setup
script is the last thing that should do that behind their back. pkg_install
queries the package database first and names only the genuinely absent packages
on the command line — a package already installed is never passed to apt at all.

It also says so out loud, every time, because a provisioning run should not be
opaque about what it is doing to the machine:

  :: Core packages — installs what is missing, keeps what you already have
       already here: curl ca-certificates gnupg git jq …
       to install:   btop tmux

Section 2's flat list of 19 is now pkgs_core(), split per package manager rather
than through a canonical-name table with overrides. The names genuinely disagree
(build-essential/base-devel, fd-find/fd) and three of them are not packages
elsewhere at all — apt-transport-https, lsb-release and software-properties-common
are apt concepts that exist to let later steps add the Docker repo and the
fastfetch PPA. A `case $PM` shows what each system actually gets, in one place.

Of those 19, six are load-bearing and the rest are the environment. Only
build-essential reaches beyond itself: it is a meta-package, so on a box with a
pinned gcc it pulls the distribution default alongside. Noted where it is
declared; it is the first thing to move out of core if that ever bites.

apt-get upgrade stays, but as its own announced step — it is the one place that
deliberately moves versions, rather than something that happens as a side effect
of asking for a tool.

DEBIAN_FRONTEND=noninteractive and NEEDRESTART_MODE=a now live inside the
helpers. needrestart has been on by default since Ubuntu 22.04 and stops to ask
which services to restart, which is how an unattended run ends up silently
waiting for a keypress.

dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
package removed but leaving its config behind — that state would read as present
and never be reinstalled.

Verified against this host's real dpkg database: all 19 report present, and a
mixed list correctly passes only the absent ones through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:19:46 +00:00
pastilhasandClaude Opus 5 41ff8030e9 ask what the machine is for, once, in pre-flight
MACHINE_ROLE is homelab, vps or dev, and several steps have a different right
answer per role with no way to work it out themselves: whether the address is
yours to pin (static IP), whether the box faces the open internet (fail2ban, SSH
hardening, UFW), and whether it is allowed to sleep (suspend, logind).

Asked in pre-flight rather than at each point of use. The steps that care run
from swap through to the firewall, and being asked "is this a VPS?" for the
fourth time halfway down a provisioning run is how people start answering
without reading.

The default offered is guessed from whether this machine's own address is in
RFC1918 space, which beats asking whether it is virtualised — a homelab is very
often a VM on Proxmox and would be misread as rented — and is the same fact most
of the branches turn on anyway. A graphical session means dev; so does macOS.
It is only ever a suggestion the user confirms.

MACHINE_ROLE in the environment answers it ahead of time for an unattended run,
which is why it is declared with :- rather than a plain assignment. The first
version wiped the caller's value before ask_machine_role ever saw it; caught by
running with MACHINE_ROLE=vps and watching the menu appear anyway.

Verified: guesses vps on this host (public IPv4, no DISPLAY, no display
manager), env override takes, and a bad value fails with the three valid ones
named. Nothing consumes the role yet — the steps get wired as each is worked
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:11:47 +00:00
pastilhasandClaude Opus 5 34bb8fc22a put the superseded setup scripts in setup-old, and repair what the move broke
scripts/setup/ is now what the new installer is being built in — machine-setup/
for the box, officer-setup.sh for the platform on top — and everything being
replaced moved to scripts/setup-old/. It still works and is still what to run.

Three things the move broke, and what each needed:

  starship.toml is not an old-setup artifact. os-user-shell.ts reads it at
  RUNTIME to seed a member's ~/.config/starship.toml when their Linux account is
  provisioned, and line 125 reads it inside a try whose catch returns
  "could not read the shell templates" — so account provisioning would have
  failed outright, not degraded. Moved back to scripts/setup/, which is where it
  belongs anyway (one file, both audiences) and which leaves the code correct
  with no edit.

  package.json's `setup` script pointed at a path that no longer exists. It now
  points at officer-setup.sh, where the installer is going, rather than at
  setup-old/ which is temporary.

  officer-setup.sh was created empty. An empty script exits 0, so `bun setup`
  would have reported success while doing nothing — worse than the broken path
  it replaced. It now explains that it is not written yet and exits 1, naming
  the setup-old script to run meanwhile.

Also brought .tmux.conf and ufw-docker-rules.conf in beside machine-setup.sh,
which reads both from SCRIPT_DIR and had been silently skipping them since the
script was vendored. ssh-keys.zip deliberately stays out: it is key material,
and *.zip is ignored.

Comments in os-user-claude.ts, app-store/preflight.ts and two docs still name the
old scripts/setup/setup.sh path. Left alone on purpose — repointing them at
setup-old/ only to repoint them again when officer-setup.sh lands is churn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:07:28 +00:00
pastilhasandClaude Opus 5 44141faf0a split machine-setup into an entry point and a base library
Structure before the work rather than during it: scripts/machine-setup.sh becomes
scripts/setup/machine-setup/, with the script itself as the entry point and
lib/base.sh holding what every part of it needs.

  machine-setup.sh   pre-flight and the numbered sections, for now
  lib/base.sh        shared state, output, the step/resume machine, prompts,
                     and OS detection

The rule for lib/ is definitions only — nothing there installs, writes or
restarts anything, so sourcing it is safe from anywhere. That is why the ERR
trap stayed in the entry point: a trap is a side effect on whoever sources it.

Behaviour is unchanged. Verified by diffing the moved region against the previous
commit: identical set of functions, and the only differences are added comments,
section banners, fail() reformatted onto three lines, and one new line — a guard
against double-sourcing, which matters because steps will source this directly
once they move out, and a second pass would reset SUMMARY.

The sections are still one 1111-line block below pre-flight; they move into
steps/ as each is worked through. The script also still reads ssh-keys.zip,
.tmux.conf and ufw-docker-rules.conf from SCRIPT_DIR, which is now this
directory, so those three steps warn and skip until the files follow it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 17:02:15 +00:00
pastilhasandClaude Opus 5 dda214ffb0 detect the operating system before any step runs
The script assumed Ubuntu on x86_64 in every line of it. detect_os() now runs
first and fills in OS, OS_NAME, OS_VERSION, PM, ARCH and IS_WSL, so the steps
have something to branch on as support for other systems is added.

Read from /etc/os-release rather than probing for a binary: a machine can have
more than one package manager on PATH, and only os-release can say which
distribution this actually is or give a version worth printing. Sourced in a
subshell so its NAME, VERSION and ID do not leak in here. ID_LIKE is the
fallback, so Pop!_OS, Mint and EndeavourOS resolve without being named.

ARCH is normalised to amd64/arm64 in one place because upstream disagrees —
Neovim ships aarch64, Go and Docker ship arm64, lazygit ships x86_64 — and
several steps hardcode one spelling today.

Windows exits with a message pointing at WSL2. WSL itself is detected and
warned about rather than refused: it reports as Linux but has no real systemd
session, so the suspend, logind and boot-hang steps do nothing there.

Everything below pre-flight is still apt and systemd only, so a gate refuses
pacman/dnf/brew by name rather than half-building a machine and stopping
somewhere unhelpful. Relax that case one entry at a time as each grows a path.

Verified on this host (Ubuntu 24.04.4, amd64, apt) and by stubbing uname and
os_release for arch, manjaro/arm64, fedora, pop, macos, mingw and riscv64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:56:21 +00:00
pastilhasandClaude Opus 5 483bb15d8a vendor the ubuntu machine provisioning script, verbatim
A byte-for-byte copy of /root/ubuntu-setup/setup-ubuntu.sh, the script that has
provisioned every Ubuntu server here. Committed unchanged, before any edit, so
that everything the setup-script rework does to it reads as a diff against what
actually ran on real machines rather than against a tidied-up version of it.

Nothing in the repo calls this yet. It also cannot find three files it reads from
its own directory — ssh-keys.zip, .tmux.conf and ufw-docker-rules.conf all live
beside the original in /root/ubuntu-setup, and SCRIPT_DIR is scripts/ here, so
those steps warn and skip.

The original stays where it is and stays authoritative until this one replaces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:56:21 +00:00
pastilhasandClaude Opus 5 315073cf3b move the pm2 ecosystem files into ecosystem-files/ for reference
Temporary, and it breaks things — nothing has been repointed yet:

  scripts/setup/setup.sh:59          joins a bare filename to $PROJECT_DIR
  scripts/setup/setup_mac_light.sh:60  the same
  src/servers/app-store/pm2.ts:23    starts sidecars from 'ecosystem.config.cjs'
  src/servers/app-store/catalogue.test.ts:12-13  require('../../../ecosystem…')
  ServersView.tsx:207                tells the owner to run pm2 start ecosystem.config.cjs

And one thing that changed silently rather than breaking: ecosystem.profile.cjs:53
pins cwd to __dirname, which was the repo root and is now ecosystem-files/, so the
.env that line exists to find is no longer beside it.

These are here to be read while the setup scripts are reworked, and get deleted
once that lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:56:21 +00:00
pastilhasandClaude Opus 5 79da78008a turn the field report into something an agent can follow
Expands the communications section from a list of what worked into the actual convention: the
directory's lifetime and the rule that anything durable must move to docs/ before the merge;
numbering, parity as attribution, non-consecutive numbers; slugs; reply-in-a-new-file and the
one case where editing your own is right; referring to commits by sha because three remotes
carried the same branch names.

Records what a handoff must contain, with the verified/assumed split named as the rule that
carried the most weight — a handoff confident about something untested is worse than none,
because the reader builds on it. Adds a skeleton to copy.

Documents termination as the four attempts it actually took, ending at the only checkable
version: the exchange pauses when no open item is actionable by a participant. Adds the third
state, deferred-with-a-reason, since a two-state protocol forces an agent to lie in one
direction. Notes that a stall must be detectable because the human spotted both before either
agent did.

Adds a review-discipline section — check the enforcement rather than the description, run it
against a real machine, a check never seen failing is not evidence, distrust vacuous passes,
expect stacked bugs, distrust "inert today", and look at which way unknown resolves. Adds a
failure-mode table to pattern-match against, and the git hygiene that bit us, including
merge-verify-then-delete, which I got wrong.

Closes with session economics, an ordered list of what to build, and the one thing not to
automate: agents may coordinate on what is true and must not decide what is permitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:52:27 +00:00
pastilhasandClaude Opus 5 015e280e5c document how to launch the watcher, since the mechanism is what did not transfer
The owner could not convey this to the second agent, who launched it differently and got
something that looked identical and did not work. The script was never the hard part; the
mechanism is.

States the requirement so it survives a different harness — a detached shell process owned by
the agent's harness, which exits when it has something to say, and whose exit re-invokes the
agent — and notes that dropping any one of those three breaks it invisibly.

Then the four wrong ways, each of which looks correct while running. Backgrounding with nohup
or & produces a process that polls correctly, detects the push, exits, and never tells the
agent, because the harness is not tracking it; I made that exact mistake and caught it only by
re-reading my own command. A model-driven interval is functionally correct and pays a full
context re-read per tick to learn nothing — the intuitive design, and the expensive one, which
is why it is the first thing to warn a new agent about. A loop that does not exit on detection
has no path to the agent at all. And per-tick logging is deferred cost that lands all at once
on wake.

Also records why 30s polling is free in a shell and ruinous in the model, including the
five-minute prompt-cache TTL that makes any model-side wake beyond it pay for a full uncached
read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:46:42 +00:00
pastilhasandClaude Opus 5 e9d0261e87 a field report on two agents working one branch
docs/agent-coordination.md states the objective — several agents on one body of work,
coordinating with each other rather than through the human — and was written in theory on
2026-08-07. On 2026-08-11/12 it ran for ten hours with two agents and the owner arbitrating.
This is what happened, written as evidence rather than proposal.

The load-bearing observation is narrower than "two reviewers are better than one": the person
who writes the sentence explaining why something is safe is the worst-placed person to notice
the code disagrees with it. One agent wrote "a wrong answer here must not happen by accident"
and shipped that accident in the same commit; the other wrote a verification script that could
not fail on the first one's machine. Neither was careless. Each was reading their own reasoning
back and finding that it agreed with itself.

Also records what only running found — an installer piped into the wrong shell, a parent
directory created root:root, an ACL mask clamped so the file browser could not read a member's
home, a chat cwd the member could not enter, ACL entries surviving a chown — all on first
executions, all invisible to review. And what the communications channel got right and the five
ways its termination rules broke, and why the repo watcher belongs in a shell loop rather than
in the model.

Names the identity gap as the first thing to build: both agents commit as the owner, so neither
the log nor an agent can say who wrote a line. docs/agent-git-identity.md has called that an
idea since 2026-08-10; it stopped being one tonight.

Also corrects the deprovision spec's status, which still said "not yet run against a real
account" after it had been run and verified clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:46:42 +00:00
pastilhasandClaude Opus 5 2eedbcda54 bind nginx proxy manager to the tailscale address
It was the only service here publishing on 0.0.0.0, and a published docker port
is not behind the firewall: docker writes its DNAT rules straight into the nat
table, which ufw's INPUT chain never sees. `ufw default deny incoming` never
covered 80/443/81 — ufw-docker-rules.conf on the host exists to patch exactly
that, and patching a rule is weaker than never opening the socket.

The address is read from `tailscale ip -4` at run time rather than passed in,
because the host provisioning has already done `tailscale up` by the time this
executes. It is validated against 100.64.0.0/10, the range tailscale and
headscale both allocate from. SETUP_NPM_BIND overrides it.

With neither, selecting NPM exits instead of falling back to 0.0.0.0 — a
fallback would silently undo the point of the change.

Two consequences worth knowing. tailscaled becomes a boot-order dependency, so
the script warns when it is not enabled at boot; docker's restart policy covers
the window but only if the tailnet comes up on its own. And HTTP-01 ACME
challenges can no longer reach port 80, so any certificate NPM issues now needs
DNS-01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:24:30 +02:00
pastilhasandClaude Opus 5 4e404f17c8 split the optional host dependencies out of setup.sh
8 Rust, 9 PulseAudio, 10 cliamp, 13 yt-dlp and 17 the remote desktop move to
scripts/setup/setup-sidecars.sh, which nothing invokes — running it is a
deliberate act. They are what the optional, sidecar-backed features need on the
host, not what the app needs to serve itself.

11 Neovim, 12 the shell extras and 14 the npm globals are gone entirely. The
host provisioning already installs node, npm, pm2, Claude Code, Neovim and the
shell, and two installers racing for the same binaries is worse than one. That
makes node, npm, pm2 and the agent CLIs prerequisites of this script rather
than products of it, so the verification block still checks claude and pm2 —
section 19 warns and skips rather than failing when pm2 is absent, which would
otherwise finish "successfully" with nothing listening.

eza is the one casualty: the provisioning installs lazygit, starship, oh-my-zsh
and nvim, but not that.

Section numbers keep their gaps so the two files read against each other. One
line survives from the removed section 14 — the ~/.local/bin PATH export, which
section 19's `has pm2` and the agent's claude lookup both still depend on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 07:24:19 +02:00
pastilhasandClaude Opus 5 cec8fbe57e the acl check could not fail, because sudo drops DATA_PATH
Review of 76cd7c2. The ACL finding is right and the fix is correct — verified here that `setfacl -R -P -b`
removes the default entries as well as the access ones, which the man page splits between -b and -k and
does not settle. `acl` is already a core package in setup.sh, so the new hard dependency is real.

But the checker it added cannot fail in the way it is documented to be run.

`assert-uid-free.sh` is invoked as `sudo ./assert-uid-free.sh --check ...`, and sudo's env_reset DROPS
DATA_PATH, so the script falls back to the hardcoded `/home/pastilhas/officerdev/data` — which is not this
machine's data directory and does not exist. Every check in the file is "look for X, report ok when nothing
is found", so a missing root reports clean without looking. Demonstrated: a tree carrying both
`user:65534:rwx` and `default:user:65534:rwx` was reported as `ok  no ACL entries naming uid 65534`.

The ACL check is the one that fails silently and completely, because it is the only one scoped to DATA_PATH
alone — the uid and subuid scans still walk /home and would catch something. So the check just added to
catch the hazard ownership cannot see is the check a wrong DATA_PATH disables.

Fixed by refusing rather than passing:

  require_roots       every search root must exist, or exit 2 naming it and showing the sudo invocation
                      that preserves DATA_PATH
  numeric guard       uid/start/count must be numbers. deprovisionOsAccount logs '<no-subuid-range>' in
                      that position for an account with no /etc/subuid entry, and pasting that log line in
                      — which is exactly how it is meant to be used — made sub_end empty and turned the
                      range scan into a no-op.

The handler's audit line now prints DATA_PATH inside the command it tells the operator to copy, and says
so explicitly when there is no subuid range rather than emitting a command that cannot work.

Verified: bogus root exits 2, non-numeric range exits 2, and the ACL check FAILS on a specimen tree
carrying the entries — the "make it fail before trusting it to pass" step from the spec's own subuid
section, now done for the ACL half too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:34:30 +00:00
pastilhasandClaude Opus 5 76cd7c20bf sever the ACL as well as the ownership
severMemberTree reassigned the tree and left the access-control entries behind. confineUserTree
grants each member a named ACL on their whole tree — u:<uid>:rwx plus a default: copy — and
chown does not remove them: they are xattrs rather than ownership, and they record the uid
numerically.

Measured before this change: after chown -h -R to the service user, user:<uid>:rwx was still
present on the directory, on its children and in their defaults. The tree read as the
platform's while still granting the freed uid read and write on every byte, so the next account
allocated that number would inherit the previous member's home, keys, credential and container
storage — the hazard this file exists to prevent, reached through a door that find -uid cannot
see.

Now chown then setfacl -R -P -b. Proven on a scratch tree: owner 1001 with five entries naming
1001 becomes owner 1000 with none.

-b rather than removing the member's entries alone, because the service user owns all of it
afterwards and "no ACLs" is cheaper to verify than "no ACL naming one id". -P is already the
default for a recursive setfacl — verified, a symlink out of the tree was not followed — and is
stated for the same reason the chown above carries -h: a member chooses what their symlinks
point at, and this argv should not rest on a traversal default holding.

Found by running assert-uid-free.sh against a real tree; the spec and the checker had the same
blind spot and were corrected in a2f63dc5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:28:59 +00:00
pastilhasandClaude Opus 5 a2f63dc534 severing ownership does not sever the ACL, and neither the spec nor the checker said so
Reviewing 46799dad against a real tree: severMemberTree reassigns ownership and leaves the
access-control entries behind. confineUserTree grants each member a named ACL on their whole
tree — u:<uid>:rwx plus a default: copy — and chown does not remove them. They are xattrs
rather than ownership, and they store the uid NUMERICALLY.

Measured: chown -h -R to the service user leaves user:<uid>:rwx intact on the directory, its
children and their defaults. So a preserved tree owned by the platform still grants the freed
uid read and write on every byte, and the next account allocated that number inherits the
previous member's home, SSH keys, credentials, transcripts and container storage. That is the
hazard the function exists to prevent, reached through ACLs instead of ownership.

This was my omission as much as the implementation's: the spec said "sever the data from the
uid" and specified only chown, and assert-uid-free.sh checked find -uid, which reads ownership
and cannot see an ACL. Both are fixed here — the spec now requires setfacl -R -b alongside the
chown, and the checker scans DATA_PATH with getfacl -R -n for entries naming the freed uid.

The checker was verified to catch it: run against green's live tree it now reports
"ACL entries still grant uid 1001 (user:1001:rwx)", which it did not before.

The code fix is one line in severMemberTree and is not mine to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:24:31 +00:00
pastilhasandClaude Opus 5 46799dada8 deprovision a member's linux account when the platform account goes
Implements docs/deprovision-os-account.md. Until now deleteUserHandler removed the row, cascaded the
database, and left the entire Linux side running — measured on production on 2026-08-12: working login
shell, healthy postgres container, 454M of data, uid queued for the next useradd to reissue along with
everything still owned by it.

The load-bearing rule from the spec: sever the data from the uid BEFORE releasing the uid, and if
severing fails, do not release. A failed deprovision is not a broken account, it is a trap for whoever
is created next.

Sequence: disable-linger, terminate-user, reap-and-prove, chown -R, userdel (never -r).

  reap    terminate-user is not a barrier. Production measured a three-hour-old `/bin/zsh -i` surviving
          it AND the removal of /run/user/<uid>. So: pkill, bounded wait, pkill -9, bounded wait, and a
          final count that must be zero or the account is not released.
  chown   fixes the uid and subuid halves in one pass — it rewrites every file it walks whatever owned
          it. The range is still captured first, because userdel removes the /etc/subuid entry and after
          that nothing on the machine remembers what it was. It is returned on every path including the
          failures, and logged as the exact assert-uid-free.sh command line.

Two guards the spec did not ask for, both pure and unit-tested:

  guardDeletable    ensureOsUser's adoption rule backwards. Deletable only if the passwd home is the one
                    the platform would have confined, and uid >= 1000. Without it `userdel root` is one
                    bad users.osUser away and nothing else in the sequence would object.
  guardMemberTree   the tree must resolve to a direct child of DATA_PATH. The email reaches join() from a
                    database row and the result is the argument to a recursive chown.

chown runs with -h. Measured here that `chown -R` already declines to follow a symlink out of the tree and
re-owns the link itself, but the argv should say so rather than rest on traversal semantics — and
re-owning links is what makes `find -uid` (lstat) a meaningful check afterwards.

destroy exists, has no call site, and is chown-then-delete-as-the-service-user rather than sudo rm -rf, so
a recursive root delete built from a database column does not exist in this codebase.

deleteUserHandler now runs this FIRST and refuses to delete the row if it fails: the row is what remembers
there is anything to clean up, so deleting it first makes a failure unrecoverable through the UI.

NOT YET RUN AGAINST A REAL ACCOUNT. Only the pure guards have tests. The five-step validation is in the
doc; it needs the production host, a shell left open, and a container writing as a non-root user — the two
cases the quiet path passes vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:19:50 +00:00
pastilhas f34d7fef70 merge: a checker for the deprovision spec, and the trap that makes it pass for free 2026-08-12 04:11:38 +00:00
pastilhasandClaude Opus 5 35715546e2 a checker for the deprovision spec, and the trap that makes it pass for free
scripts/assert-uid-free.sh is the verification half of docs/deprovision-os-account.md, written
outside the implementation on purpose: a checker the function calls is a restatement of its own
beliefs rather than an audit. Nine checks — passwd entry, uid reuse, both subid files, linger,
runtime dir, live processes, files owned by the uid, and files owned anywhere in the freed
subuid range.

Two modes, because the range has to be captured BEFORE deletion. userdel removes the
/etc/subuid entry along with the account, and after that there is no way to ask what range it
held — so a checker that only runs afterwards silently drops the half most likely to be wrong.

Exercised against green while fully provisioned: eight of nine checks fail, exit 1. A checker
that has never been seen to fail is not evidence.

And the trap worth knowing before anyone trusts a green result: the subuid check passes
vacuously on most accounts. Files get a mapped owner only when a process inside a container runs
as a NON-root user; an image whose files are root-owned maps to the member's own uid and leaves
the range empty. Measured on green after a night of real use — claude installed, an image
pulled, transcripts written — the range check found zero files and passed without testing
anything. The spec now says how to build a specimen that actually exercises it, and to watch the
check fail on that tree before trusting it to pass on a cleaned one.

Docs and a script only; no behaviour change. On a branch, for whoever merges it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:05:48 +00:00
pastilhasandClaude Opus 5 f5f509a99d scripts/setup is the initial install, nothing else
Two of the eight did not belong. cleanup-desktop.sh is the teardown — the inverse of an install, not part
of one. provision-user-dirs.ts runs per account at invite time, on a machine that is already set up.
Both are back at the top level, with their `../` derivations and usage strings put back.

What is left is what a fresh machine runs once: the two installers (setup.sh, setup_mac_light.sh), the
two things setup.sh calls (setup-dockers.sh, setup-desktop.sh), and the two files they deploy —
starship.toml, copied to ~/.config, and officer-set-display.sh, which setup-desktop.sh installs to
~/.local/bin as a login-time mode setter. The last one is not a setup script and does not read like one;
it is here because it is install payload, same as the toml, and setup-desktop.sh loads it by
`$(dirname $0)`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:05:06 +00:00
pastilhasandClaude Opus 5 9c353f5f0d move host setup into scripts/setup/
scripts/ was holding two unrelated kinds of thing: install-this-machine, and run-this-occasionally. The
eight installers now live in scripts/setup/; what stays at the top level is the build steps (gen-index,
prebuild, build/) and the two maintenance scripts (reindex-music, rebuild-soulseek-tree).

The move is not just a rename. Three of these derive the repo root from their own location:

  setup.sh:51            PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
  setup_mac_light.sh:51  same
  cleanup-desktop.sh:134 ENV_FILE="$(dirname "$0")/../.env"

Left alone, all three would now resolve to scripts/ — and nothing downstream complains. PROJECT_DIR is
where .env is written, where `bun install`, `gen:index` and `db:push` run, and what pm2 is pointed at, so
a fresh install would have quietly provisioned scripts/ and reported success. cleanup-desktop.sh fails
the other way: it would find no .env, print "No .env — skipping", and leave the real VNC_PASSWORD in the
real file. All three are now `../..` with a comment saying why the level matters.

provision-user-dirs.ts imports data-path.ts relatively; that one tsgo caught.

Also disambiguated `setup.sh` where it had become two files. app-store/templates/<name>/setup.sh is a
per-sidecar installer with its own contract, and preflight.ts + docs/sidecar-app-store.md discussed both
in the same paragraph. The host one is now spelled with its full path at those sites.

Verified: bash -n on all six shell scripts, tsgo clean, os-user tests pass, both derivations resolve to
the repo root, starship.toml still resolves from os-user-shell.ts, and provision-user-dirs.ts runs under
DRY_RUN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:02:19 +00:00
pastilhasandClaude Opus 5 37adc65a12 delete the spent one-shot scripts
Eight scripts in scripts/ that nothing references and that mostly can no longer run. Kept in history;
none of them is recoverable knowledge that isn't already in the code they migrated to.

Three could not run at all against the current database:

  migrate-items-to-files.ts   SELECT * FROM tasks — that table was dropped when items became files
  reset-user-data.ts          deletes chat_sessions, chat_groups, projects; none exists. It has no
                              transaction, so it would wipe user_settings, user_state,
                              user_integrations and dock_configs and THEN throw. A half-wiped account
                              is worse than no script. It also misses chat_session_events, which is
                              where chat state actually lives now.
  add-email-dock-user2.ts     one-time, hardcoded to user 2, seeds a dock containing /projects

The rest are spent migrations whose destination is now the only implementation:

  migrate-auth-to-pg.ts             JSON -> Postgres, 2026-02
  migrate-pg-to-files.ts            Postgres -> JSON, the other leg of the same abandoned round trip
  migrate-server-settings-to-pg.ts  2026-02
  migrate-emails-to-sqlite.ts       backfill into the email sidecar's store, 2026-07-31
  seed-imap-uids.ts                 the sidecar writes imap_lastuid/imap_uidvalidity itself now
                                    (sidecar/email/gmail-api.ts:533-535)

Kept, and why, since "unreferenced" was not the test: rebuild-soulseek-tree.ts is reusable by
construction — it runs the same buildTree the sidecar's ingest runs, so it answers any future change
in tree shape. reindex-music.ts is named in sidecar/music/index.ts:447. provision-user-dirs.ts shares
USER_DIRS with data-path.ts. cleanup-desktop.sh and officer-set-display.sh are called by
setup-desktop.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 03:58:13 +00:00
pastilhas a6acfea9a6 carry the two threads todo.md was missing 2026-08-12 03:52:29 +00:00
pastilhas a730fc0fe0 keep the three open threads the comms channel was holding 2026-08-12 03:52:11 +00:00
pastilhasandClaude Opus 5 dcaee2fc95 keep the three open threads the COMMS channel was holding
The channel was deleted when per-user Claude merged, which was right — it was conversation,
not documentation. Three things in it were neither: found while proving the feature worked,
understood, and unfinished.

The web terminal renders a long URL unreadably. OSC 52 is fixed so "press c to copy" works,
which is the path a user is meant to take; the rendering itself is not diagnosed. It matters
because first-run login is every member's first five minutes, and the workaround was running
claude under tmux on the server and reassembling the URL from a captured pane.

Agent sessions do not survive a restart with their identity intact. That one property is behind
three symptoms — the crash blast radius, the restart sweep having to skip sessions with no
recorded userId, and the stuck "generating" spinner — and documenting them separately invites
three separate fixes for one cause.

And the ProcessTransport rejection is survivable but still unexplained. Recorded with the log
markers that distinguish "the backstop is working" from "it stopped working", since the next
occurrence is now evidence in a live process rather than a corpse.

On a branch rather than straight onto master, docs-only, for whoever merges it next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 02:06:57 +00:00
pastilhasandClaude Opus 5 7040536f1f merge sidecar-app-store: per-user Claude
A member's agent turn now runs as their own Linux account, with their own claude
install, their own ~/.claude credential, their own transcripts and sessions that
record whose they are. Verified end to end on the production host: uid 1001,
nine environment variables, zero ANTHROPIC_*, zero POSTGRES_URL, zero
JWT_SECRET.

The two owner-only refusals that held chat closed to members — the wholesale
isSuperAdmin middleware in api/chat/chat.ts and the socket's 403 in server.tsx —
are gone, removed together once the turn ran under runAs.

Also carries a live credential fix that predates this work: mcp-host.json held
the owner's 30-day JWT at 0644 inside a 755 directory on a host where every role
has a shell. Now 0600 plus an explicit chmod, since writeFileSync's mode is
ignored on an existing file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 01:58:14 +00:00
295 changed files with 17785 additions and 3405 deletions
+36 -17
View File
@@ -1,18 +1,37 @@
# What officer-setup writes. Everything below this block is optional, or is on its way out.
PORT=9000 PORT=9000
JWT_SECRET="<generate with: openssl rand -base64 32>"
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer" POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
MAIL_TRANSPORT="smtp://localhost:1025"
# 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.
#
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
PUBLIC_URL=http://localhost:9000 PUBLIC_URL=http://localhost:9000
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to # ── No secrets live here ───────────────────────────────────────────────────────────────────────
# "dev" or "development". Leave it unset or set it to "production" for a real deployment; only set # JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
# it to "dev" on a local machine you trust, since that disables all three. # lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
PUBLIC_BUILD_ENV=production # 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.
DATA_PATH=/path/to/data # ── Optional ───────────────────────────────────────────────────────────────────────────────────
OFFICER_ITEMS_DIR=/path/to/officer-items # Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
HOME_DIR=/home/user # "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
BROWSER_RELAY_PORT=18792 # by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
# only loads this file, so `bun dev` against a production .env runs fully hardened.
# PUBLIC_BUILD_ENV=dev
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR were here until 2026-08-12 and are no longer read.
# The install root is derived as the parent of the working directory (src/servers/data-path.ts), so
# data/, capabilities/ and dockers/ follow from it; the owner's home comes from the OS. Three values
# that had to agree with each other and with the disk became one that cannot disagree.
# ── Sidecars ──────────────────────────────────────────────────────────────────────────────────── # ── Sidecars ────────────────────────────────────────────────────────────────────────────────────
# Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy # Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy
@@ -31,14 +50,14 @@ BROWSER_RELAY_PORT=18792
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as # daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
# X-API-Key on every forwarded request. # X-API-Key on every forwarded request.
# Vaultwarden (officer-vault). VAULT_STORE_KEY encrypts stored secrets at rest — any strong secret # Vaultwarden (officer-vault). VAULT_STORE_KEY is at the top of this file — it is the platform's
# of 16+ chars works, and CHANGING IT MAKES EXISTING STORED SECRETS UNREADABLE. # key, not Vaultwarden's, however much the name and its old position here suggested otherwise.
VAULTWARDEN_URL=http://127.0.0.1:8222 # VAULTWARDEN_URL=http://127.0.0.1:8222
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
# Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which # The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see
# lives in the host env rather than here. # src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either:
# ANTHROPIC_PROXY_PORT=5051 # the upstream token is the OAuth one `claude` writes to ~/.claude/.credentials.json, and the
# ANTHROPIC_API_KEY the agent presents to it is the proxy's own generated secret.
# ReClip — the self-hosted yt-dlp service the download-media capability talks to. Defaults to # ReClip — the self-hosted yt-dlp service the download-media capability talks to. Defaults to
# http://localhost:8899. # http://localhost:8899.
+12
View File
@@ -51,3 +51,15 @@ src/apps/officer-web/index.gen.html
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree. # Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
public/plugins/ public/plugins/
# Written by machine-setup.sh to record completed steps; per-machine, never shared.
.setup-progress
.setup-answers
# 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
+78 -34
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`; - **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 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. 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 - **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's `kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
home, so there is no level of "read" that makes them safe. They have no level at all and the grants "read" that makes them safe. They have no level at all and the grants API refuses to store one.
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, The distinction earns its keep in one place: **a confined grant means nothing without that Linux
calendar…), and is still always "the owner" for anything that executes code or touches the disk. 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. `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" **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, - 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 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. 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 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 `/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-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`, `officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea` `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 — twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth. 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 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 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 "restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
@@ -72,7 +84,7 @@ src/
│ └── landing/ # marketing landing page │ └── landing/ # marketing landing page
├── servers/ ├── servers/
│ ├── hono.ts # router composition; everything under /api │ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, origin validation, rate limiting │ ├── _middlewares/ # auth, body parsing, the capability gate, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router │ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn │ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine │ ├── queue/ # background job engine
@@ -92,7 +104,15 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
## Tech Stack ## Tech Stack
- **Runtime**: Bun (Node 22 is enforced by a `preinstall` check) - **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way. - **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components - **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono - **Backend**: Hono
@@ -104,16 +124,27 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
Two stores, and the split matters: Two stores, and the split matters:
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards, **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 email accounts, queue and pipeline jobs. One directory per feature holding `schema.ts` and
`src/queries/`, types inferred from the schema in `src/types.ts`. `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 **The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in per-account email SQLite stores — those are the **email sidecar's**, and nothing in the platform opens
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under them.
`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). **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 ### Schema changes use `push`, not migrations
@@ -134,14 +165,14 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
## Security Model ## Security Model
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly - `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate `dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
limiting and password rules all key off it — they fail closed. rules key off it — they fail closed.
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the - **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly. `ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
- **`ALLOW_ANY_ORIGIN` defaults to ON** — origin checking is off unless the var is explicitly `false`. what came out was documented defence in depth that was already switched off. Origin was never
A deliberate inversion of the usual rule, safe only because the perimeter is the tailnet and a valid authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
token is still required on every protected route. It is defence in depth that is currently switched outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
off, not the lock. is a valid token on every protected route plus the capability gate below.
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`). - JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from **The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in. Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
@@ -151,15 +182,17 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
### Capabilities — read this before mounting a router ### Capabilities — read this before mounting a router
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
valid"). It is `originScopeMiddleware``capabilities/authorize.ts`, mounted globally in `hono.ts` valid"). It is `_middlewares/capability-gate.ts``capabilities/authorize.ts`, mounted globally in `hono.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`. `userMiddleware`.
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds: - `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin` `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
(owner only, and `execution` is never grantable at any level). 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 - `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 **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`. is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
- `capabilities/totality.ts``assertCapabilityTotality` runs in `server.tsx` **before `serve()` and - `capabilities/totality.ts``assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
@@ -186,9 +219,17 @@ bunx tsgo # typecheck (not tsc)
bun test # tests bun test # tests
bun format # prettier over every dirty file — see the note below before running it bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres 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>`. 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. See `docs/working-on-officer.md` for which process a given change needs restarted.
@@ -379,6 +420,9 @@ explaining why it was safe.
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
persistence key families. Read before building a panel app. Its defect list is persistence key families. Read before building a panel app. Its defect list is
`docs/workspace-panel-todo.md`. `docs/workspace-panel-todo.md`.
- `docs/secret-store.md`**design, not built**: moving the encryption and signing keys out of `.env`
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped - `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine - `docs/working-on-officer.md` — how to run, restart and check your work on this machine
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet - `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
+32 -8
View File
@@ -14,24 +14,38 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Multi-user ## Multi-user
- [ ] **`deprovisionOsAccount` does not exist, and deleting a member leaves their whole Linux side.** - [ ] **`deprovisionOsAccount` is written but has never run against a real account.** Landed 2026-08-12 in
`deleteUserHandler` removes the row and cascades the database; `userdel` never runs. Observed on the `os-user-deprovision.ts` and wired into `deleteUserHandler`, which now refuses to delete the row when
production host on 2026-08-12: a member deleted through the UI kept a working login shell, a running the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards
Postgres container and 454M of data, and their uid was free for the next `useradd` to reissue. Spec in (`guardDeletable`, `guardMemberTree`, `parseSubUidEntry`) have tests; the reap loop, the `chown -R`
`docs/deprovision-os-account.md`. The ordering that matters: reap processes explicitly (`terminate-user` sever and `userdel` have been exercised by nobody. `docs/deprovision-os-account.md` → "What is still
does **not** reap a stale shell, and `userdel` fails while one lives), then `chown -R` to the service unproven" has the five-step validation, and it has to happen on the production host with a throwaway
user, then `userdel` — sever before release, and abort if the `chown` fails. account that has **a shell left open** and **a container writing as a non-root user** — those are the
two cases the quiet path passes vacuously.
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs` - [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers, replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
`appendBuffer`, so a replay reproduces output and never re-issues requests. `appendBuffer`, so a replay reproduces output and never re-issues requests.
- [ ] **The web terminal renders a long URL as unreadable fragments.** Claude Code's first-run login prints
a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing
selectable. Half worked around by `2a8f004` (OSC 52, so "press c to copy" reaches the clipboard) — the
rendering itself is undiagnosed. This is every new member's first five minutes.
`docs/open-threads-after-per-user-claude.md` §1 has what is known and where to start.
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart - [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process `userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
missing property. missing property. `docs/open-threads-after-per-user-claude.md` §2.
- [ ] **`ProcessTransport is not ready for writing` — survivable since `8c4f150`, still unexplained.** A
floating rejection inside the SDK's own input pump, with no frames from our code, so no `await` of ours
can catch it. It crashed `officer-agent` four times on 2026-08-11, once truncating a turn mid-sentence;
the `unhandledRejection` backstop has caught it once since. Best hypothesis is the `claude` CLI exiting
while `streamInput` is still pumping. It needs looking at after the next occurrence, not catching in the
act — markers to grep in `docs/open-threads-after-per-user-claude.md` §3.
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users` - [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in (`api/users/create-user.ts`, owner-gated) plus an Add-account form in
@@ -66,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell. standing between a Member and a shell.
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
which is why nobody noticed. Found 2026-08-14.
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a - [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
broken panel or an endless spinner rather than a clean refusal. broken panel or an endless spinner rather than a clean refusal.
+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.
+105 -7
View File
@@ -1,11 +1,15 @@
# Deprovisioning a member's Linux account # Deprovisioning a member's Linux account
**Status:** specification. Not implemented. Written from a manual teardown performed on the production host on **Status:** implemented 2026-08-12 in `src/servers/os-user-deprovision.ts`, called by `deleteUserHandler`.
2026-08-11, so the ordering constraints below are measured rather than reasoned. **Run against a real account on 2026-08-12 and verified clean**`green`, uid 1001, with a live systemd
session, a running rootless Docker stack and a shell parented outside the session cgroup. Nine processes
reaped in ~60s, then all ten checks of `scripts/assert-uid-free.sh` passed, and the `preserve` policy left
316 MB reassigned to the service user with zero ACL entries naming the freed uid.
Written from a manual teardown performed on the production host on 2026-08-11, so the ordering constraints
below are measured rather than reasoned.
**Trigger for implementing:** before the first account that does not belong to the server owner. Not "after The spec is kept as written rather than rewritten in the past tense: it is the reasoning the implementation
per-user Claude" — the risk opens when a real person has an account that might later be deleted, which may or has to keep satisfying, and the failure modes it names are still the ones a change would reintroduce.
may not be the same moment.
--- ---
@@ -111,6 +115,26 @@ keeping every byte.
rm -rf <member-tree> rm -rf <member-tree>
``` ```
**Ownership is not the only link.** `confineUserTree` grants the member a NAMED ACL entry on their whole
tree — `u:<uid>:rwx` and a `default:` copy, inherited by everything either party creates. `chown` does not
remove them: they are xattrs rather than ownership, and they store the uid **numerically**. Measured — a
`chown -h -R` to the service user leaves `user:<uid>:rwx` intact on the directory, its children and their
defaults.
So a tree reassigned to the service user still grants the freed uid read and write on every byte, and the next
account allocated that number inherits it: home, SSH keys, `.credentials.json`, transcripts, container
storage. That is the hazard this function exists to prevent, arriving through ACLs instead of ownership.
Severing therefore has two parts:
```
chown -h -R <service-user>:<service-group> <member-tree>
setfacl -R -b <member-tree> # or -x u:<uid> -x d:u:<uid> to keep the platform's own entry
```
`-b` is the simpler answer for a preserved tree: the service user owns every byte afterwards, so a named
entry granting themselves access is redundant.
**This step must complete before step 4.** That is the one ordering choice the manual teardown got wrong: it **This step must complete before step 4.** That is the one ordering choice the manual teardown got wrong: it
released the uid first and removed the data afterwards, which leaves a window where the uid is free while released the uid first and removed the data afterwards, which leaves a window where the uid is free while
files still carry it. If the process dies in that window, the next `useradd` inherits them. Sever first, then files still carry it. If the process dies in that window, the next `useradd` inherits them. Sever first, then
@@ -145,9 +169,37 @@ All of these must hold for the freed uid *and* its freed subuid range:
- `/var/lib/systemd/linger/<user>` absent - `/var/lib/systemd/linger/<user>` absent
- `/run/user/<uid>` absent - `/run/user/<uid>` absent
- no processes owned by the uid - no processes owned by the uid
- **no ACL entry naming the uid** anywhere under `DATA_PATH``getfacl -R -n` and look for
`user:<uid>:` / `default:user:<uid>:`. Ownership checks cannot see these, and `chown` does not clear them.
Worth extracting as `assertUidFree(uid, subuidRange)` and reusing it as the post-condition of the function and `scripts/assert-uid-free.sh` implements exactly this, deliberately **outside** the function: a checker the
as a test. implementation calls is a restatement of its own beliefs, not an audit. Two modes, and the split matters —
```
./scripts/assert-uid-free.sh --capture green # BEFORE: prints "green 1001 165536 65536"
sudo DATA_PATH="$DATA_PATH" ./scripts/assert-uid-free.sh --check green 1001 165536 65536 # AFTER: exit 1 unless clean
```
**Pass `DATA_PATH` through explicitly.** sudo's `env_reset` drops it, so the plain `sudo ./assert-uid-free.sh`
this used to say fell back to a hardcoded default — and every check here reports `ok` on finding nothing, so
a wrong root reports `CLEAN — uid safe to reissue` without having looked at a single member tree. The ACL
check is the one that failed silently and completely, because it is the only one scoped to `DATA_PATH` alone.
The script now refuses to run when a search root is missing rather than passing vacuously.
The range has to be captured **before** deletion, because `userdel` removes the `/etc/subuid` entry with the
account. After that there is no way to ask what range it held — and a check that silently skips that half is
the exact failure this section exists to prevent.
**The subuid check passes vacuously on most accounts, and that is a trap.** Container files are owned by a
mapped id only when a process inside the container runs as a NON-root user; an image whose files are root-owned
maps to the member's own uid and leaves nothing in the range. Measured on green after a night of real use —
`claude` installed, images pulled, transcripts written — the range check found **zero** files and passed
without testing anything.
To build a specimen that actually exercises it, run a container whose process writes as a non-root user. The
`postgres:18-alpine` case from the same night is the natural one: its entrypoint drops to uid 70, and the data
directory came out owned by `subuid_start + 70` on the host. Verify the range check *fails* on that tree before
trusting it to pass on a cleaned one.
**Trap for the verifier:** do not use `sudo -u <user> …` to check anything after step 2. Creating a session **Trap for the verifier:** do not use `sudo -u <user> …` to check anything after step 2. Creating a session
starts a user manager and recreates `/run/user/<uid>`, so the check would undo the step it is verifying. starts a user manager and recreates `/run/user/<uid>`, so the check would undo the step it is verifying.
@@ -208,3 +260,49 @@ subuid ranges, and the final verified-clean state (no accounts ≥ 1000 but the
The one thing not measured is the preserve path. The teardown used `rm -rf`, because the data was a disposable The one thing not measured is the preserve path. The teardown used `rm -rf`, because the data was a disposable
test database. `chown -R` as a severing mechanism is reasoned, not observed. test database. `chown -R` as a severing mechanism is reasoned, not observed.
---
## What the implementation decided, where the spec left it open
- **Q1, where severed data goes:** in place. A `deleted/` location is a second thing that can fail between
severing and releasing, and the ordering rule already says nothing may come between them.
- **Q2, destroy in the UI:** no. `policy: 'destroy'` exists and has no call site; `deleteUserHandler` always
preserves. It is also implemented as *chown, then delete as the service user* rather than `sudo rm -rf`, so
a recursive delete as root built from a database column does not exist in the codebase at all.
- **Q3, monotonic uid allocation:** not done. Severing addresses the same hazard and also fixes files orphaned
by any other route; the two are not exclusive and this one is still available later.
- **Q4, a preserved member's Docker images:** unchanged — they stay on disk, owned by the service user,
readable by nobody who wants them. Correct and wasteful, as the spec predicted.
Two guards were added that the spec did not ask for, both exported and unit-tested:
- `guardDeletable` — the adoption rule from `ensureOsUser` read backwards. An account is only deletable if its
passwd home is the home the platform would have confined, and its uid is ≥ 1000. Without it, `userdel root`
is one bad `users.osUser` value away, and nothing else in the sequence would object.
- `guardMemberTree` — the member tree must resolve to a direct child of `DATA_PATH`. The email reaches
`join()` from a database row and the result is the argument to a recursive `chown`.
`chown` runs with `-h`. Measured on this host that `chown -R` already declines to follow a symlink out of the
tree and re-owns the link itself, but the flag states it in the argv rather than resting on traversal
semantics — and re-owning links is what makes `find -uid` (which uses `lstat`) a meaningful check.
## What is still unproven
The whole thing has been exercised only by unit tests over the pure guards. Nothing has run against a live
account, and this dev machine is deliberately not the place to try it.
To validate on the production host, against a throwaway account:
1. Create a member, open a terminal as them, and **leave a shell running** — that is the case
`terminate-user` does not handle, and the reap loop is the part most likely to be wrong.
2. Give them a container that writes as a non-root user, so the subuid range is genuinely populated:
`postgres:18-alpine` drops to uid 70 and its data directory lands on `subuid_start + 70`.
3. `./scripts/assert-uid-free.sh --capture <user>`**before** deleting, or the range is gone.
4. Confirm the range check *fails* on that tree while the account still exists. A checker that has never
failed has not been tested.
5. Delete through the UI, then
`sudo DATA_PATH="$DATA_PATH" ./scripts/assert-uid-free.sh --check <user> <uid> <start> <count>`.
The delete handler logs that exact command line with the captured values after a successful deprovision,
because after `userdel` nothing else on the machine remembers the range.
+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.
+3 -2
View File
@@ -213,7 +213,8 @@ both, so the endpoint cannot be used to discover whether an id exists.
## Things that will surprise you ## Things that will surprise you
- **No `Origin` header is needed today.** `ALLOW_ANY_ORIGIN` defaults to on, so origin checking is off - **No `Origin` header is needed.** Origin checking was removed entirely on 2026-08-13; before that it
was off by default
and the apps work sending none — which is what they do. Nothing here changes that. If it is ever and the apps work sending none — which is what they do. Nothing here changes that. If it is ever
switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that
is a separate conversation, not part of this work. is a separate conversation, not part of this work.
@@ -326,4 +327,4 @@ service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
WebSocket upgrade in `server.tsx`, and the vault socket. WebSocket upgrade in `server.tsx`, and the vault socket.
- `src/servers/api/api-keys/router.ts` — the three endpoints. - `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.
+689
View File
@@ -0,0 +1,689 @@
# Offscale — the first real plugin
**Status: LIVE DOCUMENT, opened 2026-08-14.** Decisions and findings from the session that started the
plugin system. Correct it in place; it is meant to be edited, not archived.
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
stock. The distinct name marks a distinct product, not a badge on someone else's.
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
---
## The reframe
**Core is `officer` and nothing else. Everything else is a plugin**`officer-pty`, `officer-opencode`,
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
intuition is that it is one plugin requiring two sidecars.
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
written on runs five.
### Two words, because "core" was doing two jobs
- **baseline** — what a fresh install actually runs
- **first-party** — what Officer Dev publishes
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
"is X core?" a question with two answers.
---
## What a plugin is made of
Combined per plugin as needed. **Only `meta` and the ID are always required.**
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
- an **ID** (see below)
- a **sidecar**
- a **backend router** and its routes
- a **db schema**
- **default permissions per user group**
- what it stores in the **secret store**, and whether that is per-user or plugin-global
- a **frontend router**, its routes, and the frontend code
- how it **mounts into the file browser context menu**
- a set of **capabilities added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
mount, whether its sidecar is in the ecosystem file, and so on.
### What offscale needs
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
---
## Identity and routing
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
across two namespaces means whichever is weaker becomes the real attack surface.
**Uniqueness comes from two mechanisms**, because one is not enough:
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
generic as `notes` gets refused: it is a name Officer Dev may want later.
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
Needed because a private plugin never asks the marketplace anything.
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
restrictions; publishing is what invites review.
### Mount prefixes
```
first-party /api/<app-name> e.g. /api/offscale
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
```
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
special case in the router, a bypassed check, a different install branch — first-party and third-party
become two systems, and only one of them gets tested.
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
adding core routes forever without breaking installs.
---
## The database
**Tables live in `public`, prefixed with the app-name**`offscale_servers`, exactly as the codebase
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
### A Postgres schema per plugin was tested and rejected
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
earns. Recorded so nobody re-runs the experiment:
| Property | Result |
| ----------------------------------------------------------------- | ------------------------- |
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
| Cross-schema FK to `public.users` | works |
| Partial unique index preserved | works |
| Push is idempotent, no spurious re-creation | works |
| Cascade delete across the schema boundary | works |
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
naming the schema in `schemaFilter` made the identical push work.
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
costume of a normal result.
---
## Mounting — rebuild and swap, at runtime
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
### What was actually tested
| Router | `app.route()` after serving has begun |
| -------------------------------- | --------------------------------------------------------------------- |
| `SmartRouter` _(Hono's default)_ | **throws**`Can not add a route since the matcher is already built` |
| `RegExpRouter` | **throws**, same reason |
| `TrieRouter` | works |
| `PatternRouter` | works |
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
remove a route**, which uninstall needs.
### The approach that solves both
Rebuild the whole app from the current plugin set and **reassign the variable**:
```ts
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
// install: app = buildApp([...installed, 'offscale'])
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
```
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
```
no plugins /offscale/x -> 404 | /core -> 200
installed /offscale/x -> 200 | /core -> 200
uninstalled /offscale/x -> 404 | /core -> 200
```
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
### The one line that has to change
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
This is the whole mechanical cost.
### Websockets are a separate table, and they reload
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
```
before reload /api/offscale/ws -> refused | /core -> 200
after reload /api/offscale/ws -> CONNECTED | /core -> 200
after remove /api/offscale/ws -> refused | /core -> 200
```
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
before a plugin install can interrupt somebody's terminal.
The two tables remain two lists, which is the same seam as the totality bug below.
### What this means for `assertCapabilityTotality`
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
where mounting actually happens instead of once at start-up.
Two things it must survive, both live today:
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
uninstalled plugin's routes are not mounted, so nothing can reach them.
- The check is currently **fed the wrong list**`Object.keys(handlers)` from `server.tsx`, while Bun
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
still needs pointing at reality.
---
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
platform's to assign.
### The platform grants read or write. Everything richer is the plugin's own job
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
the surface; `write` permits everything.
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
means; the platform only knows whether this account got through the door.
### Offscale v1 uses that model exactly, with nothing added
One shared resource, role-gated:
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
- **write** — can change them, including deleting a server the owner registered
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
use is read for most roles. Say Developers get `read` and nobody gets `write`.
Two implementation consequences, both inside the plugin:
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
- `POST /ssh-test` — a reachability probe that mutates nothing
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
delete, policy `PUT` — is a genuine write.
### Music is where the richer model gets designed
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
where offscale's is just "the owner's".
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
### Three different things are called "capability" here
A manifest needs three names, not one:
1. `capabilities/registry.ts`**permissions** (`headscale`, `vpn`)
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
3. `sidecar-registry` `capabilities: ['music']`**routing keys** for `sendCommand`
Offscale needs (1) and (3), and not (2).
---
## Secrets
Two stores, and a plugin author will reach for the wrong one unless told:
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
parties touch it.
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
the host:
```ts
import { db } from '../db'; // the connection
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
```
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
the pilot rather than being invented.
---
## `/api/vpn` is being deleted
Officer had two headscale surfaces:
| | `/api/vpn` | `/api/headscale` |
| ---------- | ---------------------------------------- | -------------------------------------- |
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
| surface | one route, `POST /enroll` | the whole admin API |
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
anywhere.** Verified against the mobile monorepo:
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
2. `enroll()` is reached only via `if (embedded) await enroll()`
3. `embedded` is optional and defaults to `false`
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
does the Officer web app. The live database holds no `vpn` grants.
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
### Everything collapses to one namespace
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
contract has no counterparty.
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
- **phone → Companion** — untouched by anything here
- **web admin → Officer → sidecar** — ours to rename freely
### There are THREE components, not two
Easy to miss, and worth stating because two of them contain the word "enroll":
| Component | Repo | Enrolment surface |
| ---------------- | ---------------------------- | ------------------------------------------------ |
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
in the invite (`https://<domain>/officer-api`).
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
`POST /_officer/enroll` handler below it is dead.
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
The exemption must be declared with a reason or the boot check refuses. Not needed today.
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
is correct. The invite flow supersedes it completely:
1. the Officer headscale app holds an admin API key for the Headscale server
2. from it the owner mints an **invite** — a URL pointing at the Companion
3. the Companion turns that into the redirect the phone app claims
4. the device joins
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
---
## What headscale actually is — the inventory
Read end to end on 2026-08-14. This is what has to move.
### Backend — 2,406 lines
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
Three things worth knowing before touching it:
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
object back. One file containing all of it is the model for a plugin's client layer, not something to
undo.
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
branches on whether the body parses.
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
### Frontend — 29 files, 27 endpoints
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
Diagnostics · Console.
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
The whole frontend↔host coupling, which becomes the plugin API:
| Import | Why it matters |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `hooks/useClient``useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
| `helpers/clipboard``copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
| `AppRegistryMeta` | the panel-contribution contract |
| `officerdev``WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
| `../Terminal/Terminal``TerminalView` | **the awkward one** — a code dependency on another panel app |
### `assist.ts` travels, but stays unwired
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
later. Do not tidy it away as unused code.
---
## The manifest — proposal
Written against offscale rather than invented in the abstract, on the principle that a field list designed
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
is the floor, not the ceiling.
```ts
// plugins/offscale/manifest.ts
export const manifest = {
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
publisher: 'officerdev',
/** The plugin's own semver. Updates compare against this. */
version: '1.0.0',
/** Which platforms this build is good for. Refused at install when it does not match. */
platform: '>=1.0.0 <2.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
icon: 'Network',
color: '#818cf8',
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes and ACLs',
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
ownerOnly: true,
},
],
} as const;
```
### Everything the tree can say, the tree says
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
a human chose. Everything structural is convention, and **presence is the declaration**:
| Path | Means |
| -------------------- | --------------------------------------------------------------------------------------------- |
| _the directory name_ | `appName``plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
duplication that could only ever drift.
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
field that can contradict the file it describes.
### Install asks nothing, and that is the default
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
and no external service to point at.
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
how it works today.
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
### Dropped from the first draft
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
when something consumes it.
- **`kind`** — see below.
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
means a tile. Fine for offscale; add a flag the first time something needs it.
### `admin` has to be allowed, and the pilot proved it immediately
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
nothing is gained by forbidding it.
Corrected rule:
| Kind | May a plugin declare it? | Why |
| ----------- | ------------------------ | ---------------------------------------------------------- |
| `app` | yes | the ordinary grantable surface |
| `admin` | yes | self-restriction, never an escalation |
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
| `confined` | **no** | implies a Linux identity the platform provisions |
### One function decides the prefix
`publisher` is the only input, so first-party and third-party cannot become two code paths:
```ts
const mountPrefix = (m: Manifest) =>
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
```
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
### Notes on the fields
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
everything else is bun. One plugin already needs it, so it is not speculative generality.
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
1.0 is expected before anyone outside Officer Dev writes a plugin.
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
degrade. This is for the human reading the store.
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
- **No `migrations`.** Deferred; a field can be added without redesign.
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
---
## What is built, as of 2026-08-14
The plugin system works end to end for a plugin that has an `api/router.ts`. Verified against a running
server, with **no restart at any point**:
```
/api/example/ping BEFORE install 404
AFTER install 200 {"plugin":"example","ok":true}
AFTER disable 404
AFTER enable 200
AFTER uninstall 404
core routes throughout 200
```
| Piece | Where |
| ------------------------------------------- | --------------------------------------------- |
| Manifest type, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, and the rebuild | `servers/plugins/mount.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| The closure that makes the swap take effect | `server.tsx`, the `/api/*` route |
| Install state | `plugin_installs` (`officer_db/src/plugins/`) |
| The four verbs | `servers/api/plugins/router.ts`, owner-only |
| The screen | `/plugins` — two panels, `?selected=` |
| The reference plugin | `plugins/example/` — meant to be read |
### Not wired yet
- **The schema push.** A plugin with `db/schema.ts` installs, but its tables are not created. Marked
`[open]` in the router.
- **The sidecar's PM2 entry.** A plugin with `sidecar/` installs, but no process starts. This is the same
hole `app-store/pm2.ts:23-29` documents for the app store, and it is the next thing to build.
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
still the six hardcoded providers.
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` remains the core list, so plugin mounts are
not covered by the boot check — and the check is reading the wrong list anyway (see below). The
assertion wants moving into `buildHonoApp`, which is now the single place routes are mounted.
### Deliberately not done
**Offscale is not extracted.** The infrastructure is ready for it, but moving it means deleting working
code across ~50 files, and that should happen with someone watching rather than unattended.
---
## The state of the app store, as found
It **is** the plugin system, roughly 90% built, with one structural hole.
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
> and it is not built."_
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
written, assets publish, the dock tile appears — and the sidecar never starts.
Also found:
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
`bun db:push`.
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
that the directory exists.
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
means editing two files and rebuilding.
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
renders against it. Not to be confused with any of the above.
---
## Where the code lives
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
used in addition rather than instead — they span orgs, which matters because browser extensions under
`extensions/` may become plugins later.
---
## Open questions
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
platform checkout, so dev-time and build-time are the same mechanism.
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
something has to apply schema changes over time. Cheap now, miserable to retrofit.
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
future, not before.
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
`TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
Console section does not travel with the plugin.
5. **`service_connections.service` namespacing** before third parties touch it.
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
@@ -0,0 +1,80 @@
# Open threads after per-user Claude
Three things found on 2026-08-11/12 that are understood but not finished. They were written up in the
`COMMS/sidecar-app-store` channel, which was deleted when the feature merged — this file is what survives.
None of them blocks per-user Claude; all three were found while proving it worked.
---
## 1. The web terminal renders a long URL unreadably
**Half fixed.** `2a8f0049` added an OSC 52 handler, so a program's "press `c` to copy" now reaches the
browser clipboard. That is the path a user is meant to take, and it works.
**Not fixed:** the URL itself renders as fragments. Claude Code's first-run login prints an OAuth URL of
~400 characters; in the web terminal it appeared as scattered characters with large gaps (`h : l`), with
nothing selectable or readable. On a normal terminal the same output wraps and reads fine.
Why it matters: first-run login is every new member's first five minutes, and the workaround was running
`claude` under `tmux` on the server, capturing the pane, and reassembling the URL by hand across three wrapped
lines. That is not something a member can be asked to do, and without OSC 52 there was no other way out.
Not diagnosed. What is known:
- the frontend loads `FitAddon`, `Unicode11Addon` and `WebLinksAddon`, and `allowProposedApi` is on
- `cols`/`rows` are sent on connect (`Terminal.tsx`) and on resize, so it is not obviously a sizing problem
- the pty gives `cols: Number(...) || 0` (`sidecar/pty/server.mjs:62`), so a client that omits them yields 0
Where I would start: capture the raw bytes the pty emits for that line and compare against what xterm renders.
Either the TUI is positioning with escapes xterm handles differently, or the width the program believes it has
disagrees with the width the terminal has.
## 2. Agent sessions do not survive a restart — one property behind three symptoms
Worth fixing as one thing, because it currently presents as three and invites three separate fixes:
- **Blast radius.** An unhandled rejection used to kill the agent sidecar and every live session with it.
`8c4f150c` made that survivable, but any *real* restart still loses every session.
- **The restart sweep must skip.** `endTurnIfAgentIsGone` asks the agent whether a session is really still
generating. Scoped by `userId` since `d59adbf1`, so a session with no recorded `userId` has no safe identity
to ask as and is skipped — correct, and it leaves that session marked generating.
- **Stuck "generating".** The user-visible face of the above. A spinner that never resolves after an agent
restart is this, not the UI.
The missing property is that a session does not survive a restart with its identity intact. Given that, the
sweep would not need to skip, a restart would be an inconvenience rather than a loss, and the spinner would
resolve itself.
## 3. `ProcessTransport is not ready for writing` — survivable, still unexplained
```
error: ProcessTransport is not ready for writing
at write (…/claude-agent-sdk/sdk.mjs)
at streamInput (…/claude-agent-sdk/sdk.mjs)
```
Four fatal crashes on 2026-08-11, one of which truncated a turn mid-sentence. There are **no frames from our
code** — it is a floating rejection inside the SDK's own input pump, so no `await` of ours can catch it. With
no handler registered it reached the top level and Bun exited, taking every session on the machine.
`8c4f150c` registered an `unhandledRejection` handler in `sidecar/claude/user-instance.ts`, which is the
process `ecosystem.config.cjs` starts as `officer-agent`. Verified on Bun 1.3.9: the handler fires and the
process survives. It has fired once in production since.
**The cause is still unknown.** Best hypothesis: the `claude` CLI exits while `streamInput` is still pumping,
so the transport's `ready` flips false mid-write. Unconfirmed.
It no longer needs to be caught in the act — it needs someone to look after it happens. The next occurrence
logs a full rejection in a *live* process with every other session still attached, which is a much better
vantage point than a corpse.
Markers in `~/.pm2/logs/officer-agent-error.log`:
```
grep -c 'Bun v1.3' → fatal exits. Was 4. A fifth means the backstop stopped working.
grep -c 'UNHANDLED REJECTION' → caught and survived. Was 1.
```
`uncaughtException` is deliberately not handled the same way: a rejection leaves the process's state intact,
whereas a synchronous throw that unwound to the top supports no such claim, and continuing on a possibly
corrupted heap is worse than restarting. That asymmetry is an argument for §2 rather than against itself.
+7 -2
View File
@@ -146,7 +146,7 @@ test (`os-user.test.ts` → "does not pass the platform environment through").
sudoers *policy*`env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's sudoers *policy*`env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
shell" must not depend on a config file someone may have edited. shell" must not depend on a config file someone may have edited.
Root is available: `scripts/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service Root is available: `scripts/setup/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
rule anyway. rule anyway.
@@ -279,7 +279,12 @@ Three bugs surfaced only by running it:
also *unreachable*. also *unreachable*.
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is 3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
start with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable. start while any `.env` in the project root is group- or world-readable.
That check was itself conditional on `OFFICER_OS_USERS` until 2026-08-12, which meant the guarantee was
opt-in. The flag is gone and the check is unconditional: a security prerequisite that only holds when
somebody remembers to set a variable is not a prerequisite. Per-user Linux accounts are now simply what
the platform does, so there is nothing to enable and nothing to forget.
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd` **`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
+251
View File
@@ -0,0 +1,251 @@
# The secret store
**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 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.
---
## What is wrong with today
Nothing is insecure. The separation is already right — the thing worth keeping is stated first so it is
not lost in a refactor:
> **Secrets live in Postgres. The key that opens them does not.**
That is why `officer_db/src/crypto.ts` reads `VAULT_STORE_KEY` from the environment, and it is what
makes `keys.ts:26` true: *"a stolen database dump is useless without .env, a stolen .env is useless
without the passphrase"*.
What is wrong is narrower, and it is about **blast radius across processes**.
`.env` sits in the repository root, and Bun auto-loads it. `ecosystem.config.cjs` says so in as many
words — it is the reason the Anthropic credential was moved out of the main process. So today
`VAULT_STORE_KEY` is present in the environment of **all twenty pm2 processes**. `officer-music` holds
the key that decrypts wallet seed envelopes. Anything that can read `/proc/<pid>/environ` for those
processes has it, and nineteen of them have no reason to.
The second problem is that changing the key is currently unrecoverable rather than an operation. See
[Rotation](#rotation).
---
## What the key actually protects
Worth listing, because it is wider than the name suggests. Everything below is AES-256-GCM ciphertext in
Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`:
| column | what it is |
| --- | --- |
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" |
| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
| `jellyfin_servers.access_token` | Jellyfin session token |
| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority |
| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key |
`decryptSecret` throws when the key does not verify, so a wrong key is not a degraded mode — it is every
one of those becoming unreadable at once.
The seed envelope is the only one protected by a second, independent secret (the owner passphrase, never
persisted). Everything else in that table has exactly one lock.
---
## Decisions
### 1. The store is SQLite, in the install, outside Postgres
Keys cannot live in the database they unlock. A dump would then contain both the ciphertext and the
thing that opens it, and the property quoted at the top stops being true. Encrypting the key with a
second key only moves the question — eventually exactly one secret has to be readable without any other
secret, and the only real decision is *where it lives*.
SQLite rather than a flat file, for one reason that is not secrecy: **rotation needs key versions.** A
rotation has to decrypt with the old key and re-encrypt with the new, and an interrupted rotation needs
both to still exist. That is a table with `id, purpose, key, created_at, retired_at`, and it is awkward
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 — 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:
```
$ bun --eval 'db.exec("PRAGMA key = \"supersecret\""); … insert …'
read without key: THE-SECRET-VALUE
strings enc.db | grep THE-SECRET-VALUE -> found
```
Stock SQLite **silently ignores unknown pragmas**, so `PRAGMA key` succeeds, encrypts nothing, and the
value sits in the file in plaintext. `bun:sqlite` ships stock SQLite 3.53.0, not SQLCipher.
Whole-file encryption therefore needs SQLCipher, which means a native module — and this project already
knows what one of those costs, since node-pty has no Linux prebuild and compiles from source on every
machine.
So the store holds **encrypted values in an unencrypted file**, the same shape as the Postgres columns.
What leaks is metadata: which purposes have keys, and when they were rotated. That is an acceptable
trade and it is written down here so nobody later assumes the file is opaque.
`[open]` SQLCipher, if the native-dependency cost ever becomes worth paying.
### 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.
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~~ — none does
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
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`**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.
- **The anthropic proxy secret**, purpose `anthropic-proxy`. Agreed 2026-08-12. Neither an encryption
key nor a signing key — a bearer credential, generated once by `ensureProxySecret` and presented by
`officer-agent` to `officer-anthropic-proxy` on `127.0.0.1`. It qualifies on the same three
properties: generated once, shared between two processes, fatal to regenerate silently.
It is in the store for a sharper reason than the other two, though. It is not in `.env` today — it
is in `$DATA_PATH/sidecar/claude-state.json`, mixed in with session records. That is the one
location [decision 3](#3-where-the-file-goes) rules out by name: `DATA_PATH` is what people back up,
so the secret already travels in the same tarball as the data it protects.
**Naming.** It is called `ANTHROPIC_API_KEY` in `ensureAnthropicEnv`, and that name is wrong in both
halves — it is not Anthropic's and it is not an API key. Anthropic's real credential is the OAuth
token in `~/.claude/.credentials.json`, which the proxy swaps this one for on the way out. Our name
for it is **anthropic-proxy-secret** everywhere we control.
The exception is the last line before the spawn. `claude` reads the variable `ANTHROPIC_API_KEY` and
format-checks the `sk-ant-api03-` prefix, so both are the CLI's contract rather than ours and both
stay. That one assignment keeps the CLI's name, with a comment saying why.
---
## Core, and plugins
The store is core infrastructure, created at first boot. It is **not** a side effect of installing any
one sidecar — it exists on a machine that installs nothing, so that a plugin installed in six months
finds it already there.
The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`,
`officer-agent`, `officer-opencode`, `officer-pty`**plus `officer-headscale`**.
Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet *is* the
perimeter — origin checking was removed on 2026-08-13 precisely because the tailnet is what stands in
its place, so the tailnet is now load-bearing rather than one layer of two. A security model
that rests on the tailnet cannot treat administering the tailnet as an optional extra. Vaultwarden and
the wallet are not load-bearing that way — nothing else stops working without them — so they become
plugins.
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. That derivation is doing its job and should not be worked around.
Headscale is then the store's **first user**, not its creator — `headscale_servers.api_key` is the first
core credential needing a key.
---
## The contract
What a plugin gets, and is bound by. To be written properly when the first one uses it; the shape is:
- **Ask for a key by purpose**, not by name. `getKey('vault')` returns the active key for that purpose,
creating one on first use.
- **Never hold it.** Read it at the point of use. A key cached in a long-lived process is the
process-environment problem in a different container.
- **Never write to another plugin's purpose.** Same rule `service_connections` already has for rows.
- **Tolerate rotation.** A key may change between two calls. Anything that decrypts must be prepared to
be handed the retired key for data written before a rotation.
---
## Rotation
The feature that makes the store worth building, and the reason versions exist.
Today, changing `VAULT_STORE_KEY` is not an operation — it is data loss. Every column above becomes
unreadable, and for `wallets.seed_envelope` that is unrecoverable: the owner passphrase does not help,
because it opens the inner envelope and the outer one is gone. Unless the mnemonic was written down
offline, the coins are gone with it.
Rotation turns that into a supported action:
1. Mint a new key for the purpose, leaving the old one in the store as retired.
2. For every ciphertext column belonging to that purpose: decrypt with the retired key, re-encrypt with
the new one.
3. Retire the old key only when every row has moved.
Two properties it must have, both learned from the failure it replaces:
- **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can
tell which rows are which.
- **Verify before writing.** Every row must decrypt with the retired key *before* anything is written.
A key that is already wrong should fail loudly on row one rather than produce a second layer of
unreadable data.
`[open]` Whether rotation is a UI action, a CLI command, or both. It is a long operation on a large
wallet table and it cannot be interrupted safely, which argues for something that reports progress.
---
## What this does not change
- Secrets stay in Postgres. This moves the **keys**, not the data.
- ~~`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.
---
## Open questions
1. Where the file lives, given it must not be swept up by a backup of `data/`.
2. Whether the store's own key stays in `.env` or moves to a file read on demand.
3. Whether rotation is UI, CLI, or both — and how it reports progress on a table that takes minutes.
4. SQLCipher, and whether whole-file encryption is ever worth a second native dependency.
5. What happens to a plugin's keys when it is uninstalled. The app store already decided that
uninstalling never deletes data; the same answer probably applies, but "probably" is not a decision.
+7 -3
View File
@@ -107,9 +107,13 @@ consequences, both wanted:
### Docker is assumed, and nothing guarantees it ### Docker is assumed, and nothing guarantees it
Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** `setup.sh` calls Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** The host installer
`setup-dockers.sh`, which invokes `docker compose` with no preflight, so a fresh host without Docker `scripts/setup/setup.sh` calls `scripts/setup/setup-dockers.sh`, which invokes `docker compose` with no
fails partway through setup with a bare "command not found". preflight, so a fresh host without Docker fails partway through setup with a bare "command not found".
(Not to be confused with the per-template `setup.sh` below — `app-store/templates/<name>/setup.sh` — which
is a different file with a different contract. The host one provisions the machine; a template one
provisions a single sidecar.)
That is the seam where this project's origin shows — it began as one person's own machine, provisioned That is the seam where this project's origin shows — it began as one person's own machine, provisioned
by his own scripts, where Docker was simply always there. by his own scripts, where Docker was simply always there.
+430
View File
@@ -0,0 +1,430 @@
# Two agents on one branch: a field report
**What this is:** an account of 2026-08-11/12, when two agents worked the same branch for roughly ten hours
with the owner arbitrating, and shipped per-user Claude end to end. It is evidence rather than proposal.
`docs/agent-coordination.md` states the objective — several agents on one body of work, *"coordinating with
each other rather than through the human"*. That was written in theory on 2026-08-07. This is what happened
when it ran, and the ways the theory was wrong.
Read it as a record of what to build, not as a design. Where something worked it says so; where it broke it
says how, because the failures are more useful than the successes and there were more of them.
---
## The shape that emerged
Nobody designed this. It settled into place in the first hour and held.
| | |
|---|---|
| **Agent A** (dev machine) | wrote the platform code |
| **Agent B** (production host) | verified against a real machine, never wrote the feature |
| **The owner** | arbitrated, held every irreversible decision, and pushed for real tests |
The split was not "two reviewers are better than one". It was **the author and the verifier being different
people**, and the mechanism is narrower than it sounds:
> The person who writes the sentence explaining why something is safe is the worst-placed person to notice
> that the code disagrees with it.
That is not a claim about carelessness. Agent A wrote *"a wrong answer here is the one thing that must not
happen by accident"* and shipped exactly that accident in the same commit. Agent B wrote a verification script
that could not fail on Agent A's machine. Neither was sloppy. Each was reading their own reasoning back and
finding it agreed with itself.
Re-reading your own diff does not reach this. You read the comment, agree, and move on.
## What each half was actually good for
**A machine is not a code review.** The defects split cleanly into two kinds, and the split is the most
useful thing in this report.
*Found by reading, almost always by the non-author:* two environment guards that could never fire; a binary
check comparing paths in a way that would have thrown on every turn; a credential resolver that answered "I
don't know whose turn this is" with the owner's identity; a function whose parameter changed meaning from an
email to a filesystem path while three callers kept passing emails, invisible to the compiler because both
are `string`.
*Found only by running, and invisible to any amount of reading:* an installer piped into `sh` when it needs
`bash`; a parent directory created `root:root` as a side effect of `install -d`; an ACL mask silently clamped
so the file browser could not read a member's home; a chat working directory the member could not enter; ACL
entries surviving a `chown` and granting a freed uid access to everything.
Every "found by running" defect appeared on a **first execution**. Provisioning a real account found three in
twenty minutes. The first real chat turn found the cwd. The first teardown was the only thing that could have
proved the process reaper.
The owner drove this repeatedly — *"I'm anxious to see this work"* — against both agents' instinct to keep
building. That instinct was wrong every time.
---
## The communications paradigm
Agents coordinated through `COMMS/<branch>/` — markdown files committed to the repo, alongside the code they
discuss, deleted when the feature merged.
**Why a directory in the repo and not chat.** It survives a context window. Both agents' reasoning outlived
the sessions that produced it, a third party could read the argument rather than a summary of it, and it
travels with the branch. Chat has none of those properties, and the owner relaying findings by hand between
two agents at the end of long sessions is the failure this replaces.
### The rules, as they ended up
**Location and lifetime.** `COMMS/<branch-name>/`. It is a *channel*, not documentation: when the feature
merges, the directory is deleted. Anything that will still be true in a month must be moved to `docs/` or next
to the code **before** the merge, or it is lost. We nearly lost three findings this way and only caught it
because someone checked.
**Numbered, alternating, parity is the author.** One agent takes odd numbers, the other even. `01`, `02`,
`03`… Never "your doc" / "his doc", which inverts depending on who is reading. The parity *is* the
attribution, and given that git could not attribute anything (see below), it was the only attribution that
worked.
**Numbers are ordered, not necessarily consecutive.** An agent needing two in a row takes `03` and `05` and
leaves `04` unused, rather than forcing a reply out of the other side to keep the count. A gap is legal and
means "no turn was taken".
**The slug is the content.** `02-verify-results.md`, `24-resolvememberrun-fails-open.md`. Not `02-reply.md`.
The filename is the index; a reader should know whether to open it without opening it.
**Reply in a new file. Never edit someone else's.** An edited handoff loses what was believed at the moment a
decision was made, which is usually the thing that explains the decision.
**Editing your own is allowed if it has not been read** — and say so in the commit. Better than a prediction
standing next to its own correction in two documents.
**Refer to commits by SHA, never by branch name.** Three remotes were in play with the same branch names on
each; one agent's `origin` was the other's `pertento`. A SHA is the only unambiguous reference, and this cost
real time before it was noticed.
### What a handoff must contain
This is the part that carried the most weight, and it is one rule:
> **State what you verified and what you assumed, separately and explicitly.**
A handoff that reads as confident about something untested is *worse than no handoff*, because the reader
builds on it. Every serious mistake of the night traces back to something asserted with more confidence than
it had been earned.
In practice, each document ended up with:
- **What changed** — with `file:line` throughout. Costs nothing to write, saves the reader a search, and
makes a claim checkable rather than believable.
- **VERIFIED** — what was actually run, on what, with the output.
- **NOT VERIFIED** — stated as prominently as the verified part. `provisionClaudeCli` carried "never executed
anywhere" through four documents, and that label is what eventually made someone run it.
- **What I am least sure of** — the author's own suspicions. One agent listed three; the second was a real
defect, found because it had been pointed at.
- **What I did not do** — so nobody assumes it. "I did not restart anything", "I did not touch the gates".
- **Open items with an owner** — see termination, below.
### Termination: the rule that took four attempts
This broke more times than anything else, so the failures are worth listing in order:
1. **Terminate by guess.** `NO REPLY NEEDED unless the test fails` — a prediction about content the sender had
not seen. It ended an exchange with items open.
2. **Terminate by politeness.** The fix — always reply, even with nothing to say — has no exit. "Nothing to
report" obligates another "nothing to report", indefinitely, at real cost.
3. **Terminate when the list is empty.** Too strong: the list is never empty and will not be for days.
4. **What actually works:** *the exchange pauses when no open item is actionable by a participant.*
That last one is checkable rather than felt. Everything remaining is either the human's, or deferred with a
stated reason, and either side reopens it by adding an item that is theirs.
**Three states, not two.** An item is `open` / `done` / **`deferred with a reason`**. Three times the honest
answer was "mine, and not now" — and only the *reason* distinguishes that from neglect. A protocol with two
states forces an agent to lie in one direction or the other.
**A stall must be detectable.** Silence and completion look identical from outside. Open items plus no
document for N minutes is a condition a machine can watch for; silence is not. The human noticed both stalls
before either agent did, which is the wrong way round.
### Ownership, which we did not have and needed
Late on, both agents independently wrote the *same document* — same filename, same three sections — because
one had read the other's notes before deleting them. Pure waste, caught only by diffing the two files.
Nothing in the protocol said who owned a piece of work. Adding it is cheap: an open item names its owner, and
an agent picking up an unowned item claims it in a document before starting.
### A skeleton to copy
```markdown
# NN — <what this is about in one line>
Commits read: <sha>..<sha>. Answering `<NN-1>`.
**Verdict / what changed** — one paragraph, file:line.
## VERIFIED
<what was actually run, on what, with output>
## NOT VERIFIED
<stated as prominently as the above>
## What I am least sure of
<your own suspicions, numbered>
## What I did not do
<so nobody assumes it>
## Open items
| item | owner | state |
|---|---|---|
| … | me / you / the human | open / deferred (reason) |
```
---
## The review discipline
"Verify" turned out to mean something more specific than reading a diff. What actually caught defects:
**Check the enforcement, not the description.** A document says a check is scoped by user; go read the line
that compares. Twice the description was right and the code did something else — and the author had read
their own description and agreed with it.
**Run it against a real machine.** Every defect that mattered was found this way, on a first execution. The
categories at the top of this report are not a coincidence.
**A check that has never been seen failing is not evidence.** A verification script was run against a live,
fully-provisioned account specifically to watch it fail; it reported 8 of 9 failures, which is what made the
later clean result meaningful. Related: a *skipped* test must announce itself, or an unconfigured run reads as
a pass.
**Distrust vacuous passes.** Three separate times something passed because it had not actually looked:
a subuid scan on a tree with no subuid-owned files; a search root that did not exist, where every check
reports "ok" on finding nothing; and a range scan handed a non-numeric argument. **Any checker whose checks
are "look for X, report ok if absent" must refuse to run when its inputs are wrong**, rather than pass.
**Expect stacked bugs.** Fixing the visible failure reveals the next one underneath. A container failed on a
mount-point guard; fixing that revealed an ACL traversal denial. An installer failed on the wrong shell;
fixing that would have revealed a root-owned parent directory. Never report "fixed" from a diff — only from a
run.
**Distrust "it is inert today".** Several things were safe only because a gate was up. That is a statement
about the present, and the entire purpose of the work was to remove the gate. Review inert code as if it were
live, because the commit that makes it live will be reviewed as if it were already correct.
**Fail closed, and check which way "unknown" resolves.** The most dangerous defect of the night was a resolver
that answered "I could not determine whose turn this is" with *the owner's identity*. Any place where an
unknown collapses into a privileged default is worth a specific look.
---
## Failure modes to expect
Collected from the night, phrased so an agent can pattern-match against them:
| pattern | what it looked like here |
|---|---|
| **Author reviews own sentence** | "a wrong answer here must not happen by accident" shipped with that accident |
| **Vacuous pass** | checker with a missing search root printing CLEAN |
| **Stacked bugs** | PG18 mount guard hiding an ACL traversal denial |
| **Inert-today reasoning** | unreachable code reviewed less carefully than reachable code |
| **Unknown resolves to privileged** | failed lookup → run as the owner |
| **Compiler cannot help** | a parameter changing meaning from email to path, both `string` |
| **Guard that cannot fire** | a denylist tested against an object built from an allowlist |
| **Side-effect creation** | `install -d` making a parent `root:root` |
| **Mode bits vs ACLs** | `chown` severing ownership and leaving access |
| **Tail-of-session work** | three of the night's bugs written after hour eight |
---
## Git hygiene for two agents on one branch
Small, and it bit us repeatedly:
- **Pull before you push, and expect a race.** Both agents pushed within the same minute more than once; one
rebase was needed mid-review.
- **Merge, verify, *then* delete.** A branch was deleted after an aborted fast-forward — master had moved —
and the commits survived only because git had not yet garbage-collected them. Verify the merge landed before
removing the only ref to it.
- **A doc-only commit still deserves a real message.** These commit messages are the durable record once
`COMMS/` is deleted; several findings in this repo now exist *only* in a commit body.
- **Say which remote.** See the SHA rule above.
## The background watcher — launch it exactly this way
This is the part that was hardest to convey to the second agent, who ended up launching it differently and
got something that looked identical and did not work. The mechanism matters more than the script.
### The requirement, stated so it survives a different harness
> A **shell process, detached, owned by the agent's harness, that exits when it has something to say** — and
> whose exit **re-invokes the agent**.
Three properties, and dropping any one breaks it in a way that is not obvious from watching it run:
1. **The waiting happens in the shell, not in the model.** No inference per tick.
2. **The harness owns the process**, so its exit is an event the harness delivers to the agent.
3. **It exits on detection.** A watcher that notices a change and keeps running has told nobody.
### The launch
In Claude Code this is the Bash tool with `run_in_background: true`. Whatever the harness, it must be *that
harness's* background mechanism — the one that notifies on completion — and not a shell backgrounding
operator.
```bash
cd /path/to/repo || exit 1
BASE=$(git rev-parse HEAD)
echo "watching origin/<branch> from base=$BASE"
for i in $(seq 1 2880); do
NEW=$(timeout 30 git ls-remote origin <branch> 2>/dev/null | awk '{print $1}')
if [ -n "$NEW" ] && [ "$NEW" != "$BASE" ]; then
echo "PUSH_DETECTED"; echo "base=$BASE"; echo "new=$NEW"; exit 0
fi
sleep 30
done
echo "WATCHER_TIMEOUT no push in ~24h base=$BASE"
exit 1
```
Every line of that is load-bearing:
| choice | why | what you get instead |
|---|---|---|
| `git ls-remote` | reads the remote, mutates nothing | `git fetch` moves refs under a working tree that may be mid-edit |
| `timeout 30` on the call | a hung network call would freeze the loop silently | a watcher that is alive and blind |
| one `echo` at start, then silence | the output enters the agent's context on wake | one line per tick = 2,880 lines to swallow |
| `exit 0` on detection | the exit **is** the notification | it notices and nobody hears |
| `seq 1 2880` | runaway backstop | a process nobody remembers, polling forever |
| `sleep 30` | free, because no model runs | see below |
### Why 30 seconds is free here and ruinous in the model
An idle watcher costs **nothing**. Measured: 85 bytes of output over seven minutes, no model inference at
all. The agent is suspended between turns; the loop is just a process.
Cost appears in exactly two places — when the accumulated output enters the context, and the single
re-invocation when the process exits. Both happen **once**, on the event.
A model-driven poll is a different thing wearing the same clothes. There the model wakes each tick and
re-reads the entire conversation to decide "nothing yet". At 30-second granularity that is enormous, and
there is a second trap: the prompt cache has roughly a five-minute TTL, so any model-side wake spaced beyond
that reads the whole context uncached and pays full price. Pushing the waiting *below* the model turns an
unaffordable poll into a free one.
### The four ways to launch it that look right and are not
**1. `nohup … &` or any shell backgrounding.** The process runs, polls correctly, detects the push, and exits —
and **the agent is never told**, because the harness is not tracking it. I did this myself and only noticed
because I re-read my own command. It fails silently and looks perfect: a running process, a correct script,
and an agent that sits there forever.
**2. A model-driven interval**`/loop 30s`, a scheduler, a wake-up timer. Functionally correct, and it pays
a full context read per tick to learn nothing. This is the one to warn a new agent about first, because it is
the intuitive design and the expense is invisible.
**3. A loop that does not exit on detection** — printing "found it" and continuing. There is no mechanism by
which that reaches the agent. The output file grows and no one reads it.
**4. Chatty output.** Any per-tick logging is deferred cost: silent while it accumulates, then all of it
lands in the context at once on wake.
### Two operational failures worth pre-empting
**Self-tripping.** An agent that pushes while its own watcher is live wakes itself. The real cause is
starting a new watcher without stopping the old one, so two run concurrently and the stale one fires on your
own commit. **Stop the previous watcher before starting the next**, and re-base the new one on the head you
just pushed.
**Silent death.** If the session restarts, the watcher dies, and a dead watcher is indistinguishable from a
quiet branch. Twice, pushes landed unnoticed and were found by a manual `git log`. Anything long-running
needs a liveness signal of its own, or the eventual replacement of polling with a webhook — the repo is a
Gitea instance the platform already runs, and an event delivered is one that cannot be missed by a process
that stopped existing.
## Identity: the gap that made the record unreliable
Both agents committed from machines configured with the owner's git identity. **Every commit on the branch,
by either agent, reads `Author: <the owner>` with a `Co-Authored-By: Claude Opus 5` trailer.**
The consequence surfaced at the end and was genuinely disorienting: the owner asked which commit an agent had
written, and *neither the log nor the agent could answer from the repository*. The only reason one agent knew
its own commits was that it had read the SHAs back from its own `git push` output during the session — which
does not survive the session.
`docs/agent-git-identity.md` describes this and is marked *"idea, not implemented"*. It stopped being an idea
tonight. Of everything here it is the cheapest to fix and the most corrosive to leave: an audit trail that
cannot attribute a line is not an audit trail.
---
## Session economics, which shape all of the above
**Idle is free; waking is not.** The watcher costs nothing while it waits. Every wake re-reads the entire
conversation, so a late wake in a long session costs far more than an early one, and the cost grows
monotonically with the session.
**This argues against one immortal session.** The durable shape is a *short-lived session per event* — the
platform detects a push, spawns an agent with the base SHA and the instruction, it reviews, reports, exits.
State lives in the repo, not in an ever-growing transcript. A ten-hour session is possible and was useful, but
its last hour cost several times its first.
**Compaction is the real horizon, not session death.** Where sessions persist, the limit is that the earliest
context — usually the most expensive reasoning — degrades to summary first. Anything that must survive belongs
in the repo the moment it is understood, not at the end.
---
## Turning this into a convention
In order, cheapest and most load-bearing first.
**1. Per-agent git identity.** Both agents commit from machines configured as the owner, so every commit reads
`Author: <owner>` with a `Co-Authored-By` trailer, for both of them. The owner asked which commit an agent had
written and *neither the log nor the agent could answer from the repository*. An audit trail that cannot
attribute a line is not an audit trail, and everything else here assumes attribution works.
`docs/agent-git-identity.md` describes the fix and has been marked "idea, not implemented" since 2026-08-10.
**2. `COMMS/` as a checked convention, not a habit.** The numbering, the parity, the verified/assumed split
and the open-item table are all mechanically checkable. A pre-commit hook or a small script that refuses a
malformed handoff would have caught the duplicate document and both stalls.
**3. State-based termination and stall detection.** Open items with owners, in a machine-readable block; the
exchange pauses when none is actionable by a participant; a watcher notices open items with no document for N
minutes. This is the single biggest quality-of-life gain and it is not hard.
**4. Event delivery instead of polling.** The repo is a Gitea instance the platform already runs. A webhook
removes the watcher entirely — with its self-trips, its bounded lifetime and its silent death — and replaces
"did I miss a push" with an event that cannot be missed by a process that stopped existing.
**5. Ownership on work items**, so two agents cannot independently write the same file.
**6. A durable-notes rule.** `COMMS/` is deleted at merge. Anything still true afterwards moves to `docs/`
*before* the merge, and the merge should refuse if the channel contains unresolved open items.
## What not to automate
**The human's arbitration.** Every irreversible decision was the owner's — lifting the chat gates, deleting an
account, choosing between two designs, deciding a directory should stop existing. Each was a judgement neither
agent should have made alone, and in at least two cases an agent talked the other out of a bad idea using an
argument *the human had originally made*.
`agent-coordination.md` sets the objective as agents coordinating rather than routing through the human. This
night supports that for **execution** and contradicts it for **authority**. The human was not a bottleneck in
the work — they were the only participant who could say "that is not yours to decide", and the only one who
consistently pushed for a real test over more building.
The distinction worth encoding: agents may coordinate freely on *what is true* and must not decide *what is
permitted*.
## Postscript: the one that worked first time
Everything above was found by something failing. One thing did not.
`deprovisionOsAccount` — the function whose failure hands one member another member's home, keys and
credentials — ran correctly the first time it ever ran, against a live account with a systemd session, a
running Docker stack and a shell parented outside the session cgroup. Ten checks, clean, on the first
execution.
It is also the only piece of work all night that was **specified before it was written, implemented by
someone who had not written the spec, and verified by a tool built before the implementation existed**.
That is the strongest single argument in this document, and it is one data point. Treat it accordingly.
+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, 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 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 ## 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: 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 ├── platform/ the application — a git repo
├── capabilities/ what the agent can do — a separate 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 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** 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 — 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. 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, So "which user" has three answers depending on the surface. For the **app** capabilities (gitea,
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner: terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
granted, because they run as the owner's OS user in the owner's home. 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 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 and five non-owner accounts are live; treat the capability registry as the source of truth over any
prose, here or elsewhere. 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 `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 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. system but written down nowhere else.
@@ -67,13 +93,13 @@ Commit messages: simple lowercase, no prefixes, explaining *why*.
## Running and checking your work ## 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-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`). `officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
`pm2 list` shows them; `pm2 logs officer` follows. `pm2 list` shows them; `pm2 logs officer` follows.
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential 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; **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` 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 - [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 `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 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 **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. 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. > which uuid ids would not.
- [ ] **`dashboards.id` is a global primary key but ids are `slugify(name)`.** - [ ] **`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 `"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 `"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 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. (see `databases/CLAUDE.md` → "Composite keys") — harmless churn, but read the plan.
- [x] **`upsertDashboard`'s UPDATE has no `userId` predicate.** - [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_ `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. 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.** **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,
},
],
};
-55
View File
@@ -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.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',
},
});
-68
View File
@@ -1,68 +0,0 @@
// macOS light profile — the same process set as the Linux light profile, on a laptop.
//
// Paired with scripts/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',
},
});
-56
View File
@@ -1,56 +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
*/
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 PORT=5000 with no POSTGRES_URL. __dirname is the repo root — this file sits beside
// ecosystem.config.cjs.
return { apps: include.map((name) => ({ ...byName.get(name), cwd: __dirname })) };
}
module.exports = { defineProfile };
+2 -2
View File
@@ -8,7 +8,7 @@
"src/workspaces/*" "src/workspaces/*"
], ],
"scripts": { "scripts": {
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22 || v > 22) { console.error('Node 22 required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"", "preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
"gen:index": "bun run ./scripts/gen-index.ts", "gen:index": "bun run ./scripts/gen-index.ts",
"predev": "bun run ./scripts/gen-index.ts", "predev": "bun run ./scripts/gen-index.ts",
"dev": "bun --env-file=.env --watch src/server.tsx", "dev": "bun --env-file=.env --watch src/server.tsx",
@@ -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": "{ 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:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/setup.sh" "setup": "bash scripts/install.sh"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41", "@anthropic-ai/claude-agent-sdk": "^0.2.41",
+10
View File
@@ -0,0 +1,10 @@
import { createRouter } from '@@/create-router';
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
//
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
export const router = createRouter();
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
+29
View File
@@ -0,0 +1,29 @@
import type { PluginManifest } from '@@/plugins/manifest';
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
// the smallest thing that is still a real one: a manifest and one route.
//
// Everything structural is convention, so this directory IS the documentation:
//
// manifest.ts you are here — only what a directory listing cannot say
// api/router.ts exports `router`; mounted at /api/example
// db/schema.ts tables, if it had any (every name prefixed `example_`)
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
// web/Router.tsx a frontend, if it had one
//
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
// code sits.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Example',
summary: 'The reference plugin — one route, nothing else',
icon: 'Puzzle',
color: '#94a3b8',
// Empty is meaningful: this plugin gates nothing of its own and is reachable by anyone who can reach
// the platform. A plugin with a surface worth protecting declares a permission here instead.
permissions: [],
};
-30
View File
@@ -1,30 +0,0 @@
/**
* One-time script: add /email to user 2's dock
*
* Usage: bun run scripts/add-email-dock-user2.ts
*/
import { getDockPaths, setDockPaths } from 'officerdb';
const USER_ID = 2;
const DEFAULT_PATHS = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
async function main() {
const existing = await getDockPaths(USER_ID);
const paths = existing ?? DEFAULT_PATHS;
if (paths.includes('/email')) {
console.log(`[dock] User ${USER_ID} already has /email in dock`);
} else {
paths.push('/email');
await setDockPaths(USER_ID, paths);
console.log(`[dock] Added /email to user ${USER_ID}'s dock: ${JSON.stringify(paths)}`);
}
process.exit(0);
}
main().catch((err) => {
console.error('[dock] Failed:', err);
process.exit(1);
});
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Verify that a deprovisioned account has genuinely released its uid.
#
# Written as the verification half of `docs/deprovision-os-account.md`, deliberately OUTSIDE the
# implementation: if the function under test calls its own checker, the check is a restatement rather than
# an audit. This runs against the machine and knows nothing about the code that was supposed to clean it.
#
# The subuid range must be captured BEFORE the account is deleted, because `userdel` removes the
# /etc/subuid entry along with the account — after which there is no way to ask what range it held, and a
# check that silently skips that half is the failure mode this whole file exists to prevent.
#
# ./assert-uid-free.sh --capture green # before: prints "green 1001 165536 65536"
# ./assert-uid-free.sh --check green 1001 165536 65536 # after: exits non-zero unless clean
#
set -uo pipefail
DATA_PATH="${DATA_PATH:-/home/pastilhas/officerdev/data}"
SEARCH_ROOTS=("$DATA_PATH" /home)
usage() { echo "usage: $0 --capture <user> | --check <user> <uid> <subuid_start> <subuid_count>" >&2; exit 2; }
# ── A search root that does not exist makes this whole script lie ──
#
# Every check below is "look for X; report ok when nothing is found", so a root that is missing reports
# clean without having looked. That is not hypothetical here: this script is documented to run under
# `sudo`, and sudo's env_reset DROPS DATA_PATH, so the fallback above is what actually gets used. On a
# host where the fallback is wrong, `--check` scans a directory that does not exist, finds nothing, and
# prints "CLEAN — uid safe to reissue".
#
# The ACL check is the one that fails silently and completely, because it is scoped to DATA_PATH alone.
# The exact check added to catch the hazard ownership cannot see is the one a missing DATA_PATH disables.
#
# So: refuse to run rather than pass vacuously. Same posture the subuid section of the spec argues for.
require_roots() {
local missing=()
for root in "${SEARCH_ROOTS[@]}"; do
[[ -d "$root" ]] || missing+=("$root")
done
if (( ${#missing[@]} )); then
echo "refusing to check: these search roots do not exist: ${missing[*]}" >&2
echo "" >&2
echo "DATA_PATH is currently '$DATA_PATH'. sudo strips it from the environment, so pass it through:" >&2
echo " sudo DATA_PATH=/path/to/data $0 --check ..." >&2
echo " (or: sudo -E $0 --check ...)" >&2
echo "" >&2
echo "Every check here reports 'ok' on finding nothing, so a wrong root reports CLEAN without looking." >&2
exit 2
fi
}
if [[ "${1:-}" == "--capture" ]]; then
user="${2:?user required}"
uid="$(id -u "$user" 2>/dev/null)" || { echo "no such account: $user" >&2; exit 1; }
range="$(awk -F: -v u="$user" '$1==u {print $2" "$3; exit}' /etc/subuid)"
[[ -n "$range" ]] || { echo "no /etc/subuid entry for $user — capture it another way or it is unverifiable" >&2; exit 1; }
echo "$user $uid $range"
exit 0
fi
[[ "${1:-}" == "--check" ]] || usage
user="${2:?}"; uid="${3:?}"; sub_start="${4:?}"; sub_count="${5:?}"
require_roots
# The range arithmetic has to be numbers. `deprovisionOsAccount` logs '<no-subuid-range>' in this position
# when the account had no /etc/subuid entry, and pasting that log line straight in — which is exactly how
# it is meant to be used — would otherwise make sub_end empty and turn the range scan into a no-op.
[[ "$uid" =~ ^[0-9]+$ && "$sub_start" =~ ^[0-9]+$ && "$sub_count" =~ ^[0-9]+$ ]] || {
echo "uid, subuid_start and subuid_count must all be numbers (got: '$uid' '$sub_start' '$sub_count')" >&2
echo "an account with no /etc/subuid range has nothing to scan for — verify the uid half by hand" >&2
exit 2
}
sub_end=$(( sub_start + sub_count - 1 ))
fails=0
ok() { printf ' ok %s\n' "$1"; }
bad() { printf ' FAIL %s\n' "$1"; fails=$((fails+1)); }
echo "checking $user (uid $uid, subuids $sub_start-$sub_end)"
getent passwd "$user" >/dev/null 2>&1 && bad "passwd entry still exists" || ok "no passwd entry"
getent passwd "$uid" >/dev/null 2>&1 && bad "uid $uid reassigned or still present" || ok "uid $uid unused"
grep -q "^$user:" /etc/subuid 2>/dev/null && bad "/etc/subuid entry remains" || ok "no /etc/subuid entry"
grep -q "^$user:" /etc/subgid 2>/dev/null && bad "/etc/subgid entry remains" || ok "no /etc/subgid entry"
[[ -e "/var/lib/systemd/linger/$user" ]] && bad "linger marker remains" || ok "no linger marker"
[[ -d "/run/user/$uid" ]] && bad "/run/user/$uid remains" || ok "no runtime directory"
procs="$(pgrep -u "$uid" 2>/dev/null | wc -l)"
[[ "$procs" -eq 0 ]] && ok "no processes" || bad "$procs process(es) still owned by uid $uid"
# The uid half.
owned="$(find "${SEARCH_ROOTS[@]}" -uid "$uid" -print -quit 2>/dev/null)"
[[ -z "$owned" ]] && ok "no files owned by uid $uid" || bad "files owned by uid $uid (e.g. $owned)"
# The subuid half — the one a uid-only check passes straight through. Container processes running as a
# non-root user inside their namespace write files owned by a MAPPED id, not by the member's uid, and
# `userdel` frees the whole range for reallocation.
mapped="$(find "${SEARCH_ROOTS[@]}" -uid +"$((sub_start-1))" ! -uid +"$sub_end" -print -quit 2>/dev/null)"
[[ -z "$mapped" ]] && ok "no files in the freed subuid range" || bad "files owned by the freed subuid range (e.g. $mapped)"
# ACL entries, which ownership checks cannot see. `confineUserTree` grants the member a NAMED entry on their
# whole tree — `u:<uid>:rwx` plus a `default:` copy — and `chown` does not remove them: they are xattrs, not
# ownership, and they store the uid NUMERICALLY. So a tree reassigned to the service user can still carry
# `user:1001:rwx` on every file, and the next account allocated 1001 inherits read/write on all of it.
#
# `-n` forces numeric output; after `userdel` the uid has no name to resolve to, and relying on the name
# would make this check depend on the very passwd entry that is supposed to be gone.
#
# Scoped to DATA_PATH: member trees live there, and a recursive getfacl over /home would walk the owner's
# entire account for no gain.
acl_hit="$(getfacl -R -n -p "$DATA_PATH" 2>/dev/null | grep -m1 -E "^(default:)?user:$uid:")"
[[ -z "$acl_hit" ]] && ok "no ACL entries naming uid $uid" || bad "ACL entries still grant uid $uid ($acl_hit)"
echo
if [[ "$fails" -eq 0 ]]; then
echo "CLEAN — uid $uid and its subuid range are safe to reissue"
exit 0
fi
echo "NOT CLEAN — $fails check(s) failed; do not reissue this uid"
exit 1
+2
View File
@@ -131,6 +131,8 @@ fi
# --- Step 7: .env --- # --- Step 7: .env ---
echo "[7/7] Cleaning .env..." echo "[7/7] Cleaning .env..."
# A wrong level here is quiet: the sed below simply finds no file, reports "No .env" and leaves the real
# VNC_PASSWORD in place. Keep this in step with wherever this script lives.
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env" ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env"
if [ -f "$ENV_FILE" ]; then if [ -f "$ENV_FILE" ]; then
sed -i '/^VNC_PASSWORD=/d; /^VNC_PORT=/d' "$ENV_FILE" sed -i '/^VNC_PASSWORD=/d; /^VNC_PORT=/d' "$ENV_FILE"
+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 template = join(root, 'src/apps/officer-web/index.html');
const output = join(root, 'src/apps/officer-web/index.gen.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'); 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')) { for (const line of (await Bun.file(envPath).text()).split('\n')) {
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/); const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
if (match) process.env.PUBLIC_URL = match[1]!.trim().replace(/^["']|["']$/g, ''); 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) { 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); 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}"
-137
View File
@@ -1,137 +0,0 @@
/**
* Migration script: auth data from JSON files → PostgreSQL
*
* Migrates:
* - users.json → users table
* - passkeys.json → passkeys table (email → userId FK)
* - token-blacklist.json → token_blacklist table
*
* Usage: bun run scripts/migrate-auth-to-pg.ts
*/
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { users, passkeys, tokenBlacklist } from 'officerdb/schema';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const AUTH_DIR = join(DATA_PATH, 'auth');
type OldUser = {
id: number;
email: string;
password: string | null;
role: string;
status: string;
name: string | null;
username: string | null;
avatar: string | null;
passwordChangedAt: number | null;
};
type OldPasskey = {
id: number;
email: string;
origin: string | null;
credentialId: string | null;
publicKey: string | null;
counter: number;
};
type OldBlacklistEntry = {
jti: string;
expiresAt: number;
};
async function readJson<T>(path: string, fallback: T): Promise<T> {
try {
const file = Bun.file(path);
if (!(await file.exists())) return fallback;
return (await file.json()) as T;
} catch {
return fallback;
}
}
async function migrate() {
console.log(`[migrate] Reading JSON files from ${AUTH_DIR}`);
const oldUsers = await readJson<OldUser[]>(join(AUTH_DIR, 'users.json'), []);
const oldPasskeys = await readJson<OldPasskey[]>(join(AUTH_DIR, 'passkeys.json'), []);
const oldBlacklist = await readJson<OldBlacklistEntry[]>(join(AUTH_DIR, 'token-blacklist.json'), []);
console.log(`[migrate] Found: ${oldUsers.length} users, ${oldPasskeys.length} passkeys, ${oldBlacklist.length} blacklisted tokens`);
if (oldUsers.length === 0) {
console.log('[migrate] No users to migrate. Done.');
process.exit(0);
}
// Build email → userId map for passkey migration
const emailToUserId = new Map<string, number>();
// Migrate users
console.log('[migrate] Migrating users...');
for (const u of oldUsers) {
const [inserted] = await db
.insert(users)
.values({
email: u.email,
password: u.password,
status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted',
name: u.name,
username: u.username,
avatar: u.avatar,
passwordChangedAt: u.passwordChangedAt ? new Date(u.passwordChangedAt) : null,
})
.returning();
emailToUserId.set(u.email, inserted!.id);
console.log(` [user] ${u.email} (old id=${u.id} → new id=${inserted!.id})`);
}
// Migrate passkeys
if (oldPasskeys.length > 0) {
console.log('[migrate] Migrating passkeys...');
for (const p of oldPasskeys) {
const userId = emailToUserId.get(p.email);
if (!userId) {
console.warn(` [passkey] Skipping passkey for unknown email: ${p.email}`);
continue;
}
await db.insert(passkeys).values({
userId,
origin: p.origin,
credentialId: p.credentialId,
publicKey: p.publicKey,
counter: p.counter,
});
console.log(` [passkey] ${p.email} / ${p.origin}`);
}
}
// Migrate token blacklist
if (oldBlacklist.length > 0) {
const now = Math.floor(Date.now() / 1000);
const active = oldBlacklist.filter((b) => b.expiresAt >= now);
console.log(`[migrate] Migrating ${active.length} active blacklisted tokens (${oldBlacklist.length - active.length} expired, skipped)...`);
for (const b of active) {
await db
.insert(tokenBlacklist)
.values({
jti: b.jti,
expiresAt: new Date(b.expiresAt * 1000),
})
.onConflictDoNothing();
}
}
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
-81
View File
@@ -1,81 +0,0 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { openEmailDb, upsertFromRawEml, setSyncMeta } from '../src/servers/sidecar/email/store';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// Find all user directories that have Gmail emails
const targetEmail = process.argv[2];
if (targetEmail) {
migrate(targetEmail);
} else {
const entries = readdirSync(DATA_PATH, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.includes('@')) continue;
const emailDir = join(DATA_PATH, entry.name, 'Gmail', 'emails');
try {
const files = readdirSync(emailDir).filter((f) => f.endsWith('.eml'));
if (files.length > 0) migrate(entry.name);
} catch {
// no Gmail dir for this user
}
}
}
function migrate(userEmail: string): void {
console.log(`Migrating ${userEmail}...`);
const emailDir = join(DATA_PATH, userEmail, 'Gmail', 'emails');
// Obsolete one-off migration (old .eml-file store → SQLite); kept only to compile.
const db = openEmailDb(userEmail, userEmail);
let filenames: string[];
try {
filenames = readdirSync(emailDir).filter((f) => f.endsWith('.eml'));
} catch {
console.log(' No .eml files found');
db.close();
return;
}
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
for (const row of rows) existingIds.add(row.id);
let added = 0;
let skipped = 0;
let errors = 0;
db.exec('BEGIN');
try {
for (const filename of filenames) {
const id = filename.replace(/\.eml$/, '');
if (existingIds.has(id)) {
skipped++;
continue;
}
try {
const raw = readFileSync(join(emailDir, filename), 'utf-8');
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount: userEmail, labels: ['INBOX'] });
added++;
} catch {
errors++;
}
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
console.log(` ${filenames.length} .eml files — ${added} added, ${skipped} skipped, ${errors} errors`);
// Store the latest email date so the next sync only fetches emails after it
const row = db.query('SELECT date FROM emails ORDER BY date DESC LIMIT 1').get() as { date: string } | null;
if (row?.date) {
setSyncMeta(db, 'last_sync_date', row.date);
console.log(` Stored last_sync_date: ${row.date}`);
}
db.close();
}
-112
View File
@@ -1,112 +0,0 @@
/**
* One-time migration: consolidate every agent item into the flat, file-based store
* ($OFFICER_ITEMS_DIR) and export the DB-backed `tasks` table to TASK.md files.
*
* Idempotent — safe to re-run. Run this BEFORE applying the drop-tables DB migration
* (it reads the `tasks` table, which still exists until that migration runs).
*
* Sources, in precedence order (later overwrites earlier on a dirName collision):
* - tasks: officer_db.tasks rows (native → global → user)
* - skills / tools / processes / extensions: $DATA_PATH/<type> then $DATA_PATH/<email>/<type>
* - tools: marketplace registry tools not already present (archive safety)
*
* Usage: bun run scripts/migrate-items-to-files.ts
*/
import { join, resolve } from 'node:path';
import { readdir, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { db } from 'officerdb/db';
import { sql } from 'drizzle-orm';
import { itemsDir, ensureItemDirs, DATA_PATH, OFFICER_ITEMS_DIR, type ItemType } from '../src/servers/data-path';
import { importTask } from '../src/servers/api/tasks/task-files';
ensureItemDirs();
console.log(`Target store: ${OFFICER_ITEMS_DIR}`);
async function listSubdirs(dir: string): Promise<string[]> {
try {
return (await readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return [];
}
}
// ── 1. Tasks: Postgres → TASK.md files ──
// Order native → global → user so user/global overwrite native on a dirName collision.
const scopeRank = (s: string) => (s === 'user' ? 2 : s === 'global' ? 1 : 0);
console.log('\n── Tasks (DB → files) ──');
let taskRows: Record<string, unknown>[] = [];
try {
taskRows = (await db.execute(sql.raw('SELECT * FROM tasks'))) as unknown as Record<string, unknown>[];
} catch (err) {
console.log(` could not read tasks table (already dropped?): ${err instanceof Error ? err.message : err}`);
}
taskRows.sort((a, b) => scopeRank(String(a.scope)) - scopeRank(String(b.scope)));
for (const row of taskRows) {
const dirName = String(row.dir_name);
await importTask(dirName, {
name: String(row.name ?? dirName),
description: row.description == null ? null : String(row.description),
version: Number(row.version) || 1,
mode: String(row.mode ?? 'agentic'),
language: row.language == null ? null : String(row.language),
args: (row.args as string[] | null) ?? null,
tags: (row.tags as string[] | null) ?? null,
tools: (row.tools as string[] | null) ?? null,
skills: (row.skills as string[] | null) ?? null,
inputs: row.inputs ?? null,
outputs: row.outputs ?? null,
dependencies: row.dependencies ?? null,
config: row.config ?? null,
trigger: row.trigger ?? null,
body: row.body == null ? '' : String(row.body),
implementation: row.implementation == null ? null : String(row.implementation),
});
console.log(` ${dirName} (${row.scope})`);
}
console.log(` ${taskRows.length} task file(s) written`);
// ── 2. On-disk items → flat store ──
const DISK_TYPES: ItemType[] = ['skills', 'tools', 'processes', 'extensions'];
async function copyItemsFrom(srcTypeDir: string, type: ItemType): Promise<number> {
let n = 0;
for (const name of await listSubdirs(srcTypeDir)) {
await cp(join(srcTypeDir, name), join(itemsDir(type), name), { recursive: true, force: true });
n++;
}
return n;
}
console.log('\n── Disk items (DATA_PATH → flat store) ──');
const emailDirs = (await listSubdirs(DATA_PATH)).filter((n) => n.includes('@'));
for (const type of DISK_TYPES) {
let n = await copyItemsFrom(join(DATA_PATH, type), type); // global
for (const email of emailDirs) n += await copyItemsFrom(join(DATA_PATH, email, type), type); // user (overwrites)
console.log(` ${type}: ${n} item(s) copied`);
}
// ── 3. Marketplace registry tools not already present (archive safety) ──
const MARKETPLACE_REGISTRY = process.env.MARKETPLACE_REGISTRY ?? resolve(import.meta.dir, '../../marketplace/registry');
console.log(`\n── Marketplace registry (${MARKETPLACE_REGISTRY}) ──`);
if (existsSync(MARKETPLACE_REGISTRY)) {
let n = 0;
for (const name of await listSubdirs(join(MARKETPLACE_REGISTRY, 'tools'))) {
const target = join(itemsDir('tools'), name);
if (existsSync(target)) continue; // don't clobber a synced/user version
await cp(join(MARKETPLACE_REGISTRY, 'tools', name), target, { recursive: true });
n++;
console.log(` tool ${name} (from registry)`);
}
console.log(` ${n} registry tool(s) added`);
console.log(' registry tasks come from the DB export above (native scope) — skipped here');
} else {
console.log(' registry not found, skipping');
}
console.log('\nDone. Verify counts in the UI, then apply the drop-tables DB migration.');
process.exit(0);
-79
View File
@@ -1,79 +0,0 @@
/**
* One-time migration: PostgreSQL auth tables → JSON files
*
* Usage:
* POSTGRES_URL="postgres://..." bun run scripts/migrate-pg-to-files.ts
*
* Reads users and passkeys from Postgres, writes JSON files to {DATA_PATH}/auth/.
* Safe to run multiple times (overwrites files).
*/
import { join } from 'node:path';
import { mkdir } from 'node:fs/promises';
import postgres from 'postgres';
const POSTGRES_URL = process.env.POSTGRES_URL;
if (!POSTGRES_URL) {
console.error('POSTGRES_URL env var is required');
process.exit(1);
}
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const AUTH_DIR = join(DATA_PATH, 'auth');
const sql = postgres(POSTGRES_URL);
try {
await mkdir(AUTH_DIR, { recursive: true });
const users = await sql`SELECT id, email, password, role, status, name, username, avatar, password_changed_at FROM users ORDER BY id`;
const passkeys = await sql`SELECT id, email, origin, credential_id, public_key, counter FROM passkeys ORDER BY id`;
const mappedUsers = users.map((u) => ({
id: Number(u.id),
email: u.email,
password: u.password ?? null,
role: u.role ?? 'Member',
status: u.status ?? 'Unverified',
name: u.name ?? null,
username: u.username ?? null,
avatar: u.avatar ?? null,
passwordChangedAt: u.password_changed_at ? Number(u.password_changed_at) : null,
}));
const mappedPasskeys = passkeys.map((p) => ({
id: Number(p.id),
email: p.email,
origin: p.origin ?? null,
credentialId: p.credential_id ?? null,
publicKey: p.public_key ?? null,
counter: Number(p.counter ?? 0),
}));
const maxUserId = mappedUsers.reduce((max, u) => Math.max(max, u.id), 0);
const maxPasskeyId = mappedPasskeys.reduce((max, p) => Math.max(max, p.id), 0);
const meta = {
nextUserId: maxUserId + 1,
nextPasskeyId: maxPasskeyId + 1,
};
const write = (file: string, data: unknown) => Bun.write(join(AUTH_DIR, file), JSON.stringify(data, null, 2));
await Promise.all([
write('users.json', mappedUsers),
write('passkeys.json', mappedPasskeys),
write('passkey-challenges.json', []),
write('token-blacklist.json', []),
write('meta.json', meta),
]);
console.log(`Migrated ${mappedUsers.length} users, ${mappedPasskeys.length} passkeys`);
console.log(`Files written to ${AUTH_DIR}`);
console.log(`meta: nextUserId=${meta.nextUserId}, nextPasskeyId=${meta.nextPasskeyId}`);
} catch (err) {
console.error('Migration failed:', err);
process.exit(1);
} finally {
await sql.end();
}
-42
View File
@@ -1,42 +0,0 @@
/**
* Migration script: server-settings.json → PostgreSQL server_config table
*
* Usage: bun run scripts/migrate-server-settings-to-pg.ts
*/
import { join } from 'node:path';
import { writeServerSettings } from 'officerdb';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const settingsPath = join(DATA_PATH, 'server-settings', 'server-settings.json');
async function migrate() {
console.log(`[migrate] Reading ${settingsPath}`);
const file = Bun.file(settingsPath);
if (!(await file.exists())) {
console.log('[migrate] No server-settings.json found. Done.');
process.exit(0);
}
let settings: Record<string, unknown>;
try {
settings = await file.json();
} catch {
console.log('[migrate] Could not parse server-settings.json. Done.');
process.exit(0);
}
const keys = Object.keys(settings);
console.log(`[migrate] Found ${keys.length} keys: ${keys.join(', ')}`);
await writeServerSettings(settings);
console.log('[migrate] Written to server_config table.');
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
+3 -1
View File
@@ -10,7 +10,9 @@
import type { BrowsedFile } from 'officerdb'; import type { BrowsedFile } from 'officerdb';
import { eq, asc } from 'drizzle-orm'; import { eq, asc } from 'drizzle-orm';
import { db, finishSoulseekBrowse } from 'officerdb'; 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'; import { buildTree } from '../src/servers/sidecar/slskd/browse';
const snapshots = await db const snapshots = await db
-161
View File
@@ -1,161 +0,0 @@
/**
* Reset all user data while keeping auth credentials.
*
* Deletes:
* - DB: user_settings, user_state, user_integrations, dock_configs,
* chat_sessions (cascades chat_messages), chat_groups,
* dashboards, screens, projects,
* task_logs, queue_jobs, terminal_containers
* - Filesystem: entire $DATA_PATH/<email>/ directory
* (home, settings, state, dashboards, chat_sessions, emails.db,
* Gmail, skills, tools, tasks, processes, extensions, logs, cache, etc.)
* - Queue job files: $DATA_PATH/queue/jobs/*.json owned by user
* - Terminal containers map: removes user entry from terminal-containers.json
*
* Preserves:
* - users table row (account, password, role, status)
* - passkeys table rows
* - passkey_challenges, token_blacklist
*
* Usage: bun run scripts/reset-user-data.ts <email>
* bun run scripts/reset-user-data.ts <email> --yes (skip confirmation)
*/
import { join } from 'node:path';
import { rm, readdir, unlink } from 'node:fs/promises';
import { db } from 'officerdb/db';
import { users } from 'officerdb/schema';
import { eq, sql } from 'drizzle-orm';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const email = process.argv[2];
const skipConfirm = process.argv.includes('--yes');
if (!email) {
console.error('Usage: bun run scripts/reset-user-data.ts <email> [--yes]');
process.exit(1);
}
// ── Resolve user ──
const [user] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.email, email));
if (!user) {
console.error(`User not found: ${email}`);
process.exit(1);
}
console.log(`\nUser: ${user.email} (id: ${user.id})`);
console.log(`Data dir: ${join(DATA_PATH, email)}`);
console.log('\nThis will delete ALL user data (settings, chats, dashboards, emails, home dir, etc.)');
console.log('Auth credentials (account, passkeys) will be preserved.\n');
if (!skipConfirm) {
process.stdout.write('Continue? [y/N] ');
const response = await new Promise<string>((resolve) => {
process.stdin.once('data', (data) => resolve(data.toString().trim()));
});
if (response.toLowerCase() !== 'y') {
console.log('Aborted.');
process.exit(0);
}
}
const userId = user.id;
// ── Database cleanup ──
// All these tables have ON DELETE CASCADE from users, but we don't want to delete the user.
// Delete explicitly by user_id.
console.log('\n── Database ──');
const tables = [
'user_settings',
'user_state',
'user_integrations',
'dock_configs',
'chat_sessions', // cascades chat_messages
'chat_groups',
'dashboards',
'screens',
'projects',
'task_logs',
'queue_jobs',
'terminal_containers',
];
for (const table of tables) {
const result = await db.execute(sql.raw(`DELETE FROM ${table} WHERE user_id = ${userId}`));
const count = result.length ?? 0;
console.log(` ${table}: ${count} rows deleted`);
}
// Agent items (skills, tools, tasks, processes, extensions) are now flat files in
// $OFFICER_ITEMS_DIR, shared and not user-owned — intentionally left untouched by a user reset.
// ── Queue job files ──
console.log('\n── Queue job files ──');
const queueDir = join(DATA_PATH, 'queue', 'jobs');
try {
const entries = await readdir(queueDir);
let deleted = 0;
for (const entry of entries) {
if (!entry.endsWith('.json')) continue;
try {
const file = Bun.file(join(queueDir, entry));
const job = await file.json();
if (job.userId === email) {
await unlink(join(queueDir, entry));
deleted++;
}
} catch {
// skip unreadable files
}
}
console.log(` ${deleted} job files deleted`);
} catch {
console.log(' queue dir not found, skipping');
}
// ── Terminal containers map ──
console.log('\n── Terminal containers ──');
const containerMapPath = join(DATA_PATH, 'terminal-containers.json');
try {
const file = Bun.file(containerMapPath);
if (await file.exists()) {
const map = await file.json();
let changed = false;
for (const key of Object.keys(map)) {
if (key === email || map[key]?.email === email) {
delete map[key];
changed = true;
}
}
if (changed) {
await Bun.write(containerMapPath, JSON.stringify(map, null, 2));
console.log(' removed from terminal-containers.json');
} else {
console.log(' no entry found');
}
}
} catch {
console.log(' terminal-containers.json not found, skipping');
}
// ── Filesystem ──
console.log('\n── Filesystem ──');
const userDir = join(DATA_PATH, email);
try {
await rm(userDir, { recursive: true, force: true });
console.log(` removed ${userDir}`);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.log(` failed to remove ${userDir}: ${msg}`);
}
console.log('\nDone. User auth preserved, all data wiped.');
process.exit(0);
-88
View File
@@ -1,88 +0,0 @@
import { ImapFlow } from 'imapflow';
import { getUserByEmail, getUserIntegration, getServerIntegration } from 'officerdb';
import { openEmailDb, setSyncMeta } from '../src/servers/sidecar/email/store';
const userEmail = process.argv[2];
if (!userEmail) {
console.error('Usage: bun run scripts/seed-imap-uids.ts <email>');
process.exit(1);
}
// ── Load credentials ──
const dbUser = await getUserByEmail(userEmail);
if (!dbUser) throw new Error('User not found');
const userGoogle = await getUserIntegration(dbUser.id, 'google');
const config = userGoogle?.config as Record<string, unknown> | undefined;
if (!config?.accessToken) throw new Error('No OAuth tokens found');
// Refresh token if needed
let accessToken = config.accessToken as string;
const expiresAt = config.expiresAt as number | undefined;
if (!expiresAt || expiresAt < Date.now() + 60_000) {
console.log('Refreshing expired token...');
const serverGoogle = await getServerIntegration('google');
const serverConfig = serverGoogle?.config as Record<string, unknown>;
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: serverConfig.clientId as string,
client_secret: serverConfig.clientSecret as string,
refresh_token: config.refreshToken as string,
grant_type: 'refresh_token',
}),
});
if (!res.ok) throw new Error(`Token refresh failed: ${await res.text()}`);
const data = (await res.json()) as { access_token: string };
accessToken = data.access_token;
}
// ── Connect IMAP ──
const client = new ImapFlow({
host: 'imap.gmail.com',
port: 993,
secure: true,
auth: { user: config.email as string, accessToken },
logger: false,
});
await client.connect();
console.log('Connected to IMAP');
const GMAIL_PREFIX_RE = /^\[(?:Gmail|Google Mail)\]\//;
const SKIP_SUFFIXES = new Set(['All Mail', 'Trash', 'Spam', 'Bin']);
const folders = await client.list();
const db = openEmailDb(userEmail, config.email as string);
let seeded = 0;
for (const folder of folders) {
const suffix = folder.path.replace(GMAIL_PREFIX_RE, '');
const isGmailFolder = suffix !== folder.path;
if (isGmailFolder && SKIP_SUFFIXES.has(suffix)) continue;
if (folder.specialUse && ['\\Trash', '\\Junk', '\\All'].includes(folder.specialUse)) continue;
try {
const status = await client.status(folder.path, { uidNext: true, uidValidity: true });
const lastUid = (status.uidNext ?? 1) - 1;
const uidValidity = String(status.uidValidity);
setSyncMeta(db, `imap_lastuid:${folder.path}`, String(lastUid));
setSyncMeta(db, `imap_uidvalidity:${folder.path}`, uidValidity);
console.log(` ${folder.path}: lastUid=${lastUid}, uidValidity=${uidValidity}`);
seeded++;
} catch (err) {
console.log(` ${folder.path}: skipped (${err instanceof Error ? err.message : err})`);
}
}
db.close();
await client.logout();
console.log(`\nSeeded ${seeded} folders. Next sync will only fetch new messages.`);
process.exit(0);
@@ -7,7 +7,7 @@ set -euo pipefail
# capture an Xorg server, NOT a Wayland compositor — so we install the full GNOME desktop but force GDM # capture an Xorg server, NOT a Wayland compositor — so we install the full GNOME desktop but force GDM
# onto the Xorg session (WaylandEnable=false). Auto-login is enabled so a user session owns :0 for the # onto the Xorg session (WaylandEnable=false). Auto-login is enabled so a user session owns :0 for the
# mirror to attach to. Switching the display manager takes effect on the next reboot. # mirror to attach to. Switching the display manager takes effect on the next reboot.
# Usage: ./scripts/setup-desktop.sh # Usage: ./scripts/setup/setup-desktop.sh
echo "=== Officer Remote Desktop Setup (Ubuntu GNOME on Xorg) ===" echo "=== Officer Remote Desktop Setup (Ubuntu GNOME on Xorg) ==="
echo "" echo ""
@@ -4,12 +4,14 @@
# Outputs parseable key=value lines to stdout; all prompts go to stderr. # Outputs parseable key=value lines to stdout; all prompts go to stderr.
# #
# Usage: # Usage:
# bash scripts/setup-dockers.sh # bash scripts/setup/setup-dockers.sh
# eval "$(bash scripts/setup-dockers.sh)" # eval "$(bash scripts/setup/setup-dockers.sh)"
# #
# Environment overrides: # Environment overrides:
# SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none") # SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none")
# SETUP_DOCKER_NETWORK="services" — docker network name # SETUP_DOCKER_NETWORK="services" — docker network name
# SETUP_NPM_BIND="100.64.0.8" — host address Nginx Proxy Manager publishes on. Defaults to this
# node's Tailscale IPv4; set it explicitly to bind somewhere else.
set -e set -e
@@ -47,6 +49,44 @@ prompt_value() {
# ─── docker network ───────────────────────────────────────────────────────── # ─── docker network ─────────────────────────────────────────────────────────
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}" DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
# ─── Nginx Proxy Manager bind address ───────────────────────────────────────
#
# NPM is the only service here that ever published on 0.0.0.0, and a published Docker port is not
# behind the firewall: Docker writes its DNAT rules directly into the nat table, which UFW's INPUT
# chain never sees. `ufw default deny incoming` does not cover 80/443/81 — that is what the host's
# ufw-docker-rules.conf exists to patch, and patching a rule is weaker than never opening the socket.
#
# So bind to the tailnet address instead. The kernel then refuses the socket on every other interface
# and the firewall stops being load-bearing for this. The address is read at run time rather than
# passed in, because by the time this script runs the host provisioning has already done `tailscale up`.
resolve_npm_bind() {
if [[ -n "${SETUP_NPM_BIND:-}" ]]; then
echo "$SETUP_NPM_BIND"
return
fi
local ip
ip=$(tailscale ip -4 2>/dev/null | head -1)
# 100.64.0.0/10 — the CGNAT range both Tailscale and Headscale allocate from. Anything outside it
# means `tailscale ip` answered with something unexpected, and a bind address is not a value to
# guess at: the whole point is that it is NOT reachable from the internet.
if [[ "$ip" =~ ^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\. ]]; then
echo "$ip"
return
fi
echo ""
}
NPM_BIND="$(resolve_npm_bind)"
# Binding to an address that belongs to another service's interface makes that service a boot-order
# dependency: if tailscaled has not brought tailscale0 up yet, the container cannot get its socket and
# Docker falls back on the restart policy to retry. That converges, but only if the tailnet comes up
# at all on its own.
if [[ -n "$NPM_BIND" ]] && ! systemctl is-enabled --quiet tailscaled 2>/dev/null; then
warn "tailscaled is not enabled at boot — NPM binds $NPM_BIND, which will not exist after a reboot"
warn "until the tailnet is up. Fix with: sudo systemctl enable tailscaled"
fi
# Ensure network exists # Ensure network exists
if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1 docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
@@ -94,6 +134,14 @@ MAILHOG_SELECTED=false
for svc in $SERVICES; do for svc in $SERVICES; do
case "$svc" in case "$svc" in
1) 1)
if [[ -z "$NPM_BIND" ]]; then
fail "Nginx Proxy Manager selected, but no Tailscale IPv4 was found on this host."
echo " Bring the tailnet up first (the host provisioning does this), or choose the" >&2
echo " address deliberately: SETUP_NPM_BIND=<ip> bash scripts/setup/setup-dockers.sh" >&2
echo " Publishing it on 0.0.0.0 is not offered — Docker bypasses UFW, so that would put" >&2
echo " 80/443/81 on every interface the host has." >&2
exit 1
fi
COMPOSE_SERVICES+=("nginx-proxy-manager") COMPOSE_SERVICES+=("nginx-proxy-manager")
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
nginx-proxy-manager: nginx-proxy-manager:
@@ -101,9 +149,9 @@ for svc in $SERVICES; do
container_name: nginx-proxy-manager container_name: nginx-proxy-manager
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "$NPM_BIND:80:80"
- "443:443" - "$NPM_BIND:443:443"
- "81:81" - "$NPM_BIND:81:81"
volumes: volumes:
- ./npm_data:/data - ./npm_data:/data
- ./npm_letsencrypt:/etc/letsencrypt - ./npm_letsencrypt:/etc/letsencrypt
@@ -238,6 +286,10 @@ fi
# ─── output parseable values to stdout ─────────────────────────────────────── # ─── output parseable values to stdout ───────────────────────────────────────
echo "COMPOSE_DIR=$COMPOSE_DIR" echo "COMPOSE_DIR=$COMPOSE_DIR"
if [[ " ${COMPOSE_SERVICES[*]} " == *" nginx-proxy-manager "* ]]; then
echo "NPM_BIND=$NPM_BIND"
fi
if [[ -n "$PG_PASSWORD" ]]; then if [[ -n "$PG_PASSWORD" ]]; then
echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}" echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}"
fi fi
+258
View File
@@ -0,0 +1,258 @@
#!/bin/bash
# Officer — host dependencies for the optional, sidecar-backed features.
#
# Usage:
# bash scripts/setup/setup-sidecars.sh
#
# WHAT THIS IS
# Everything here was part of setup.sh and is not any more. setup.sh installs what the app needs to
# serve itself; this installs what a handful of *optional* features need on the host, and it is never
# invoked by setup.sh — running it is a deliberate act.
#
# The sections keep the numbering they had in setup.sh so the two files can be read against each
# other:
#
# 1 (was 8) Rust
# 2 (was 9) PulseAudio + audio dev headers
# 3 (was 10) cliamp
# 4 (was 13) yt-dlp
# 5 (was 17) Remote desktop (delegates to setup-desktop.sh)
#
# There is no `light`/`full` profile here. In setup.sh these sections were the ones `light` skipped,
# so gating them again would only mean "run this script and have it do nothing" — running it at all
# IS the opt-in.
#
# ORDER MATTERS: section 3 needs Go, which setup.sh installs. Run setup.sh first.
# Section 5 rewrites GRUB and switches the display manager — it takes effect on the next reboot.
set -e
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; }
skip() { echo -e " - $1 (already installed)"; }
has() { command -v "$1" &>/dev/null; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# setup.sh installed Go and rustup into the user's home and exported them for its own run only. A
# fresh shell has neither on PATH, which would make `has go` false (silently skipping the cliamp
# build) and `has rustc` false (re-running rustup over an existing toolchain). Put them back.
export PATH="$HOME/.local/go/bin:$HOME/.cargo/bin:$PATH"
# ─── detect package manager ────────────────────────────────────────────────────
if has apt; then
PM=apt
elif has pacman; then
PM=pacman
elif has brew; then
PM=brew
else
fail "No supported package manager found (apt, pacman, brew)"
exit 1
fi
install_pkg() {
case $PM in
apt) sudo apt install -y "$@" ;;
pacman) sudo pacman -S --noconfirm "$@" ;;
brew) brew install "$@" ;;
esac
}
echo ""
echo "═══════════════════════════════════════════"
echo " Officer — optional host dependencies ($PM)"
echo "═══════════════════════════════════════════"
# ─── 1. Rust (was setup.sh section 8) ─────────────────────────────────────────
echo ""
echo "── Rust ──"
if has rustc && has cargo; then
skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))"
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
export PATH="$HOME/.cargo/bin:$PATH"
if has rustc; then ok "rust installed"; else warn "rust install failed"; fi
fi
# ─── 2. PulseAudio (headless audio for cliamp) (was section 9) ────────────────
echo ""
echo "── PulseAudio (headless audio) ──"
PULSE_PKGS=()
if has pulseaudio; then skip "pulseaudio"; else
case $PM in
apt) PULSE_PKGS+=(pulseaudio) ;;
pacman) PULSE_PKGS+=(pulseaudio) ;;
brew) warn "PulseAudio: brew install pulseaudio (cliamp audio won't work without it)" ;;
esac
fi
# pulseaudio-utils provides parec and pactl
if has parec && has pactl; then skip "pulseaudio-utils (parec, pactl)"; else
case $PM in
apt) PULSE_PKGS+=(pulseaudio-utils) ;;
pacman) ;; # included in pulseaudio package
brew) ;; # included in pulseaudio formula
esac
fi
# ALSA dev headers (needed to compile cliamp's Go audio library)
case $PM in
apt)
if dpkg -s libasound2-dev &>/dev/null 2>&1; then skip "libasound2-dev"; else PULSE_PKGS+=(libasound2-dev); fi
;;
pacman)
if pacman -Qi alsa-lib &>/dev/null 2>&1; then skip "alsa-lib"; else PULSE_PKGS+=(alsa-lib); fi
;;
brew) ;; # not needed on macOS
esac
# Vorbis/OGG/FLAC dev headers (needed by cliamp's Go dependencies)
case $PM in
apt)
for pkg in libvorbis-dev libogg-dev libflac-dev; do
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
done
;;
pacman)
for pkg in libvorbis libogg flac; do
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
done
;;
brew) ;; # not needed on macOS
esac
if [ ${#PULSE_PKGS[@]} -gt 0 ]; then
install_pkg "${PULSE_PKGS[@]}"
ok "Installed: ${PULSE_PKGS[*]}"
fi
# ─── 3. cliamp (music player) (was section 10) ────────────────────────────────
echo ""
echo "── cliamp ──"
# Export GOPATH (not just PATH) so `go install` lands in GOPATH_BIN — even when Go was already present
# this run and install_go (which sets GOPATH) never ran. Otherwise go uses its default ~/go/bin and the
# check below wrongly reports a build failure.
export GOPATH="${GOPATH:-$HOME/.local/go-path}"
GOPATH_BIN="$GOPATH/bin"
export PATH="$GOPATH_BIN:$PATH"
if has cliamp; then
skip "cliamp ($(command -v cliamp))"
else
if ! has go; then
warn "Go not installed — skipping cliamp build (run setup.sh first)"
else
echo " Building cliamp from source..."
TMPDIR=$(mktemp -d)
git clone --depth=1 https://github.com/bjarneo/cliamp.git "$TMPDIR/cliamp"
(cd "$TMPDIR/cliamp" && go install .)
rm -rf "$TMPDIR"
if [ -f "$GOPATH_BIN/cliamp" ]; then
ok "cliamp installed at $GOPATH_BIN/cliamp"
else
warn "cliamp build failed"
fi
fi
fi
# ─── 4. yt-dlp (video/audio download) (was section 13) ────────────────────────
echo ""
echo "── yt-dlp ──"
# Always install/upgrade via pip to get the latest version (apt repos are outdated).
# Remove apt version first if present, then install via pip to /usr/local/bin.
if has pip3; then
# Remove outdated apt version if installed
case $PM in
apt)
if dpkg -s yt-dlp &>/dev/null 2>&1; then
echo " Removing outdated apt version..."
sudo apt remove -y yt-dlp > /dev/null 2>&1
fi
;;
esac
echo " Installing/upgrading yt-dlp via pip..."
sudo pip3 install --break-system-packages --upgrade yt-dlp 2>/dev/null
if has yt-dlp; then ok "yt-dlp $(yt-dlp --version) installed"; else warn "yt-dlp pip install failed"; fi
else
case $PM in
apt) install_pkg yt-dlp 2>/dev/null && ok "yt-dlp installed (apt — may be outdated)" || warn "yt-dlp not available" ;;
pacman) install_pkg yt-dlp && ok "yt-dlp installed" ;;
brew) install_pkg yt-dlp && ok "yt-dlp installed" ;;
esac
fi
# ─── 5. remote desktop (Ubuntu Desktop + VNC) (was section 17) ────────────────
echo ""
echo "── Remote Desktop (Ubuntu Desktop + VNC) ──"
# No "already installed" guard here on purpose. This used to skip on `dpkg -s ubuntu-desktop`, which
# treats one package being present as proof the whole remote desktop is configured — and those are very
# different things. A host can have ubuntu-desktop and still be missing every part that makes the mirror
# work: GDM auto-login, the forced Xorg session, the captured EDID and its kernel command line, the
# login-time mode setter. That was not hypothetical; it was this machine on 2026-08-02, where the guard
# reported "skip" while five of setup-desktop.sh's steps had never run and /desktop could not survive a
# reboot. setup-desktop.sh is idempotent throughout — every step either no-ops or is individually
# guarded — so letting it run each time converges a partially configured host instead of trusting a
# proxy for state it never actually checked.
#
# This is by far the most expensive section — it pulls the whole ubuntu-desktop meta-package, rewrites
# /etc/default/grub and switches the display manager.
case $PM in
apt)
bash "$SCRIPT_DIR/setup-desktop.sh"
;;
*)
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
;;
esac
# ─── verification ─────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
echo " Verification"
echo "═══════════════════════════════════════════"
echo ""
check() {
if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi
}
echo "Rust:"
check rustc
check cargo
echo ""
echo "Audio (cliamp):"
check pulseaudio
check parec
check pactl
check cliamp
echo ""
echo "Download:"
check yt-dlp
echo ""
echo "═══════════════════════════════════════════"
echo " Done"
echo "═══════════════════════════════════════════"
echo ""
echo "Notes:"
echo " • PulseAudio null sink starts automatically with the server"
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
echo " • Make sure ~/.local/go-path/bin is in your PATH for Go-installed tools (cliamp)"
echo " • REBOOT to switch into the GNOME-on-Xorg session the remote desktop mirrors"
echo ""
+1179
View File
File diff suppressed because it is too large Load Diff
+50 -337
View File
@@ -3,8 +3,8 @@
# Run once on a fresh Ubuntu/Debian host before launching the server. # Run once on a fresh Ubuntu/Debian host before launching the server.
# #
# Usage: # Usage:
# bash scripts/setup.sh # full server install # bash scripts/setup/setup.sh # full server install
# OFFICER_PROFILE=light bash scripts/setup.sh # light install # OFFICER_PROFILE=light bash scripts/setup/setup.sh # light install
# #
# PROFILES # PROFILES
# full Everything: the self-hosted estate, the remote desktop, the music/audio stack, the shell # full Everything: the self-hosted estate, the remote desktop, the music/audio stack, the shell
@@ -13,13 +13,24 @@
# Claude/opencode chat — on a Linux host. Installs only what those need: node, bun, ffmpeg, # Claude/opencode chat — on a Linux host. Installs only what those need: node, bun, ffmpeg,
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs. # Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
# #
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust, # Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, and Go.
# PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and # Of the Docker services only Postgres is brought up.
# the remote desktop. Of the Docker services only Postgres is brought up.
# #
# The app itself is identical — every API route stays mounted, so the features whose sidecars # The app itself is identical — every API route stays mounted, so the features whose sidecars
# are not running report themselves unavailable rather than disappearing. A profile changes # are not running report themselves unavailable rather than disappearing. A profile changes
# which processes start, not which code ships. # which processes start, not which code ships.
#
# NOT INSTALLED HERE — and the gaps in the section numbers are where these used to be
# Moved to scripts/setup/setup-sidecars.sh, which nothing below invokes; run it deliberately, and
# only after this script: 8 Rust, 9 PulseAudio, 10 cliamp, 13 yt-dlp, 17 remote desktop.
#
# Removed outright, because the host provisioning already installs them and two installers racing
# for the same binaries is worse than one: 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit),
# 14 npm globals (the ~/.local npm prefix, Claude Code, pm2).
#
# That makes node, npm, pm2 and the agent CLIs PREREQUISITES of this script rather than products of
# it. Section 19 warns and skips rather than failing if pm2 is absent, so a host that never ran the
# provisioning will finish "successfully" with nothing listening — check the verification block.
set -e set -e
@@ -48,7 +59,10 @@ is_light() { [ "$OFFICER_PROFILE" = "light" ]; }
if is_light; then ECOSYSTEM_FILE="ecosystem.light.config.cjs"; else ECOSYSTEM_FILE="ecosystem.config.cjs"; fi if is_light; then ECOSYSTEM_FILE="ecosystem.light.config.cjs"; else ECOSYSTEM_FILE="ecosystem.config.cjs"; fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # ../.. — this lives in scripts/setup/, so the repo root is two levels up, not one. Nothing here fails
# loudly if that is wrong: PROJECT_DIR is where .env is written, where `bun install` and `db:push` run and
# where pm2 is pointed, so an off-by-one level silently sets up scripts/ instead of the repo.
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
# Resolve the real user's home even when running under sudo # Resolve the real user's home even when running under sudo
if [[ -n "${SUDO_USER:-}" ]]; then if [[ -n "${SUDO_USER:-}" ]]; then
@@ -135,7 +149,7 @@ if has make && has gcc; then skip "build tools (make, gcc, g++)"; else
esac esac
fi fi
# pkg-config — needed by cgo-based Go packages (e.g. ebitengine/oto for cliamp) # pkg-config — needed by cgo-based Go packages (e.g. ebitengine/oto for cliamp, in setup-sidecars.sh)
if has pkg-config; then skip "pkg-config"; else if has pkg-config; then skip "pkg-config"; else
case $PM in case $PM in
apt) CORE_PKGS+=(pkg-config) ;; apt) CORE_PKGS+=(pkg-config) ;;
@@ -515,8 +529,9 @@ fi
# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain # prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain
# one on exactly the installs most likely to have members. # one on exactly the installs most likely to have members.
# #
# One static binary and one config file. oh-my-zsh, eza and lazygit stay in section 12, where `light` skips # One static binary and one config file, which is why it survived the cull that removed the rest of the
# them: those are host comforts, and the shell template treats each as optional. # terminal tooling: oh-my-zsh, eza and lazygit are host comforts the provisioning installs, and the shell
# template treats each as optional. Starship it does not — the prompt would visibly degrade.
echo "" echo ""
echo "── Prompt (starship) ──" echo "── Prompt (starship) ──"
@@ -529,9 +544,7 @@ else
fi fi
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on # Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
# every run, silently — the nvim step below already gets this right by guarding on the config's # every run, silently. Converge when there is nothing to lose, keep what the user wrote when there is.
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
# wrote when there is.
mkdir -p "$HOME/.config" mkdir -p "$HOME/.config"
STARSHIP_DEST="$HOME/.config/starship.toml" STARSHIP_DEST="$HOME/.config/starship.toml"
if [ ! -f "$STARSHIP_DEST" ]; then if [ ! -f "$STARSHIP_DEST" ]; then
@@ -540,17 +553,15 @@ if [ ! -f "$STARSHIP_DEST" ]; then
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
skip "starship config" skip "starship config"
else else
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)" warn "starship config kept — yours differs (cp scripts/setup/starship.toml ~/.config/ to take this one)"
fi fi
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and # Go is a host comfort rather than anything the app needs to serve a file browser, a terminal and a
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host # chat, so `light` skips it. It is the only section left in this block — 8-13 were removed or moved.
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
# terminal and a chat.
if is_light; then if is_light; then
echo "" echo ""
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp" omit "Go"
else else
# ─── 7. Go ───────────────────────────────────────────────────────────────────── # ─── 7. Go ─────────────────────────────────────────────────────────────────────
@@ -604,279 +615,18 @@ else
if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi if has go; then ok "go $(go version | awk '{print $3}') installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi
fi fi
# ─── 8. Rust ────────────────────────────────────────────────────────────────── # 8 Rust, 9 PulseAudio, 10 cliamp and 13 yt-dlp are in setup-sidecars.sh.
echo "" # 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit) and 14 npm globals are gone entirely — the host
echo "── Rust ──" # provisioning owns node, npm, pm2, Claude Code, Neovim and the shell, and this script duplicating
# them meant two installers racing for the same binaries.
if has rustc && has cargo; then fi # end of the light-profile skip, which is now section 7 alone
skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))"
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
export PATH="$HOME/.cargo/bin:$PATH"
if has rustc; then ok "rust installed"; else warn "rust install failed"; fi
fi
# ─── 9. PulseAudio (headless audio for cliamp) ──────────────────────────────── # Kept from the removed section 14: nothing here installs into ~/.local/bin any more, but section 19
echo "" # still asks `has pm2` and the agent still resolves `claude` off PATH. A host that installed either
echo "── PulseAudio (headless audio) ──" # user-locally would otherwise look like it has neither.
PULSE_PKGS=()
if has pulseaudio; then skip "pulseaudio"; else
case $PM in
apt) PULSE_PKGS+=(pulseaudio) ;;
pacman) PULSE_PKGS+=(pulseaudio) ;;
brew) warn "PulseAudio: brew install pulseaudio (cliamp audio won't work without it)" ;;
esac
fi
# pulseaudio-utils provides parec and pactl
if has parec && has pactl; then skip "pulseaudio-utils (parec, pactl)"; else
case $PM in
apt) PULSE_PKGS+=(pulseaudio-utils) ;;
pacman) ;; # included in pulseaudio package
brew) ;; # included in pulseaudio formula
esac
fi
# ALSA dev headers (needed to compile cliamp's Go audio library)
case $PM in
apt)
if dpkg -s libasound2-dev &>/dev/null 2>&1; then skip "libasound2-dev"; else PULSE_PKGS+=(libasound2-dev); fi
;;
pacman)
if pacman -Qi alsa-lib &>/dev/null 2>&1; then skip "alsa-lib"; else PULSE_PKGS+=(alsa-lib); fi
;;
brew) ;; # not needed on macOS
esac
# Vorbis/OGG/FLAC dev headers (needed by cliamp's Go dependencies)
case $PM in
apt)
for pkg in libvorbis-dev libogg-dev libflac-dev; do
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
done
;;
pacman)
for pkg in libvorbis libogg flac; do
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else PULSE_PKGS+=("$pkg"); fi
done
;;
brew) ;; # not needed on macOS
esac
if [ ${#PULSE_PKGS[@]} -gt 0 ]; then
install_pkg "${PULSE_PKGS[@]}"
ok "Installed: ${PULSE_PKGS[*]}"
fi
# ─── 10. cliamp (music player) ────────────────────────────────────────────────
echo ""
echo "── cliamp ──"
# Export GOPATH (not just PATH) so `go install` lands in GOPATH_BIN — even when Go was already present
# this run and install_go (which sets GOPATH) never ran. Otherwise go uses its default ~/go/bin and the
# check below wrongly reports a build failure.
export GOPATH="${GOPATH:-$HOME/.local/go-path}"
GOPATH_BIN="$GOPATH/bin"
export PATH="$GOPATH_BIN:$PATH"
if has cliamp; then
skip "cliamp ($(command -v cliamp))"
else
if ! has go; then
warn "Go not installed — skipping cliamp build"
else
echo " Building cliamp from source..."
TMPDIR=$(mktemp -d)
git clone --depth=1 https://github.com/bjarneo/cliamp.git "$TMPDIR/cliamp"
(cd "$TMPDIR/cliamp" && go install .)
rm -rf "$TMPDIR"
if [ -f "$GOPATH_BIN/cliamp" ]; then
ok "cliamp installed at $GOPATH_BIN/cliamp"
else
warn "cliamp build failed"
fi
fi
fi
# ─── 11. Neovim ──────────────────────────────────────────────────────────────
echo ""
echo "── Neovim ──"
if has nvim; then
skip "neovim ($(nvim --version 2>/dev/null | head -1))"
else
case $PM in
apt)
echo " Installing Neovim from GitHub releases..."
ARCH=$(uname -m)
case $ARCH in
x86_64) NVIM_ARCH=x86_64 ;;
aarch64) NVIM_ARCH=aarch64 ;;
*) NVIM_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" -o /tmp/nvim.tar.gz
sudo tar -C /opt -xzf /tmp/nvim.tar.gz
sudo ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim
rm /tmp/nvim.tar.gz
;;
pacman) install_pkg neovim ;;
brew) install_pkg neovim ;;
esac
if has nvim; then ok "neovim installed"; else warn "neovim install failed"; fi
fi
# LazyVim starter config
if [ -d "$HOME/.config/nvim" ]; then
skip "nvim config (already exists at ~/.config/nvim)"
else
echo " Installing LazyVim starter config..."
git clone --depth 1 https://github.com/LazyVim/starter "$HOME/.config/nvim"
rm -rf "$HOME/.config/nvim/.git"
ok "LazyVim starter installed at ~/.config/nvim"
fi
# ─── 12. Terminal tools (starship is section 6b, outside the light skip) ─────
echo ""
echo "── Terminal tools (oh-my-zsh, eza, lazygit) ──"
# Oh-My-Zsh
if [ -d "$HOME/.oh-my-zsh" ]; then
skip "oh-my-zsh (already at ~/.oh-my-zsh)"
else
git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git "$HOME/.oh-my-zsh"
ok "oh-my-zsh installed at ~/.oh-my-zsh"
fi
# eza
if has eza; then
skip "eza"
else
case $PM in
apt)
echo " Fetching latest eza version..."
EZA_VERSION=$(curl -fsSL "https://api.github.com/repos/eza-community/eza/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
if [ -z "$EZA_VERSION" ]; then warn "Could not fetch eza version — skipping"; else
ARCH=$(uname -m)
case $ARCH in
x86_64) EZA_ARCH=x86_64 ;;
aarch64) EZA_ARCH=aarch64 ;;
*) EZA_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${EZA_ARCH}-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz
tar -xzf /tmp/eza.tar.gz -C /tmp
sudo mv /tmp/eza /usr/local/bin/eza
sudo chmod +x /usr/local/bin/eza
rm -f /tmp/eza.tar.gz
fi
;;
pacman) install_pkg eza ;;
brew) install_pkg eza ;;
esac
if has eza; then ok "eza installed"; else warn "eza install failed"; fi
fi
# lazygit
if has lazygit; then
skip "lazygit"
else
case $PM in
apt)
echo " Fetching latest lazygit version..."
LAZYGIT_VERSION=$(curl -fsSL "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" | jq -r '.tag_name' | sed 's/^v//')
if [ -z "$LAZYGIT_VERSION" ]; then warn "Could not fetch lazygit version — skipping"; else
ARCH=$(uname -m)
case $ARCH in
x86_64) LG_ARCH=x86_64 ;;
aarch64) LG_ARCH=arm64 ;;
*) LG_ARCH=x86_64 ;;
esac
curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${LG_ARCH}.tar.gz" -o /tmp/lazygit.tar.gz
tar -xzf /tmp/lazygit.tar.gz -C /tmp
sudo mv /tmp/lazygit /usr/local/bin/lazygit
sudo chmod +x /usr/local/bin/lazygit
rm -f /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
fi
;;
pacman) install_pkg lazygit ;;
brew) install_pkg lazygit ;;
esac
if has lazygit; then ok "lazygit installed"; else warn "lazygit install failed"; fi
fi
# ─── 13. yt-dlp (optional — video/audio download) ────────────────────────────
echo ""
echo "── yt-dlp (optional) ──"
# Always install/upgrade via pip to get the latest version (apt repos are outdated).
# Remove apt version first if present, then install via pip to /usr/local/bin.
if has pip3; then
# Remove outdated apt version if installed
case $PM in
apt)
if dpkg -s yt-dlp &>/dev/null 2>&1; then
echo " Removing outdated apt version..."
sudo apt remove -y yt-dlp > /dev/null 2>&1
fi
;;
esac
echo " Installing/upgrading yt-dlp via pip..."
sudo pip3 install --break-system-packages --upgrade yt-dlp 2>/dev/null
if has yt-dlp; then ok "yt-dlp $(yt-dlp --version) installed"; else warn "yt-dlp pip install failed"; fi
else
case $PM in
apt) install_pkg yt-dlp 2>/dev/null && ok "yt-dlp installed (apt — may be outdated)" || warn "yt-dlp not available" ;;
pacman) install_pkg yt-dlp && ok "yt-dlp installed" ;;
brew) install_pkg yt-dlp && ok "yt-dlp installed" ;;
esac
fi
fi # end of the light-profile skip for sections 7-13
# ─── 14. npm global packages (user-local) ───────────────────────────────────
echo ""
echo "── npm global packages (user-local) ──"
# Ensure ~/.local/bin is in PATH for this session
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
if ! has npm; then
warn "npm not found — skipping global package installs"
else
# Set npm prefix to user-local so no sudo is needed for installs/updates
echo " Configuring npm global prefix to ~/.local..."
npm config set prefix "$HOME/.local"
ok "npm prefix set to $HOME/.local"
# Claude Code (uses Anthropic's own installer for auto-update support)
if has claude; then
skip "claude (claude-code)"
else
echo " Installing claude-code via Anthropic installer..."
curl -fsSL https://claude.ai/install.sh | bash # bash, not sh: a piped script ignores its shebang and install.sh is bash
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
fi
# No /usr/local/bin/claude symlink. That existed because the sidecar hardcoded that path, which in
# turn came from the bwrap-sandboxed architecture — the jail ro-bound /usr and could not see the
# installer's real target in ~/.local/bin. The sandbox is gone and claude-manager.ts now resolves the
# CLI itself: $CLAUDE_BIN, then PATH, then ~/.local/bin/claude, /usr/local/bin/claude and
# /opt/homebrew/bin/claude. The installer above puts it in ~/.local/bin, which is both on PATH and the
# first candidate, so the symlink was satisfying a requirement that no longer exists — at the cost of
# a sudo-owned link into /usr/local/bin, a directory macOS does not even ship.
# pm2 (process manager)
if has pm2; then
skip "pm2"
else
echo " Installing pm2..."
npm install -g pm2
if has pm2; then ok "pm2 installed"; else warn "pm2 install failed"; fi
fi
fi
# ─── 15. bun install (project dependencies) ────────────────────────────────── # ─── 15. bun install (project dependencies) ──────────────────────────────────
echo "" echo ""
echo "── Project dependencies ──" echo "── Project dependencies ──"
@@ -998,34 +748,7 @@ ENVFILE
ok ".env written to $PROJECT_DIR/.env" ok ".env written to $PROJECT_DIR/.env"
fi fi
# ─── 17. remote desktop (Ubuntu Desktop + VNC) ─────────────────────────────── # 17 remote desktop is in setup-sidecars.sh.
echo ""
echo "── Remote Desktop (Ubuntu Desktop + VNC) ──"
# No "already installed" guard here on purpose. This used to skip on `dpkg -s ubuntu-desktop`, which
# treats one package being present as proof the whole remote desktop is configured — and those are very
# different things. A host can have ubuntu-desktop and still be missing every part that makes the mirror
# work: GDM auto-login, the forced Xorg session, the captured EDID and its kernel command line, the
# login-time mode setter. That was not hypothetical; it was this machine on 2026-08-02, where the guard
# reported "skip" while five of setup-desktop.sh's steps had never run and /desktop could not survive a
# reboot. setup-desktop.sh is idempotent throughout — every step either no-ops or is individually
# guarded — so letting it run each time converges a partially configured host instead of trusting a
# proxy for state it never actually checked.
# The light profile does not run officer-vnc, so there is nothing to mirror. This is the single most
# expensive section — it pulls the whole ubuntu-desktop meta-package — and the one most clearly outside
# "file browser, terminal, chat".
if is_light; then
omit "remote desktop (ubuntu-desktop, GDM, x11vnc, Brave)"
else
case $PM in
apt)
bash "$SCRIPT_DIR/setup-desktop.sh"
;;
*)
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
;;
esac
fi
# ─── 18. project initialization ────────────────────────────────────────────── # ─── 18. project initialization ──────────────────────────────────────────────
echo "" echo ""
@@ -1113,17 +836,13 @@ check gcc
echo "" echo ""
echo "Dev tools:" echo "Dev tools:"
# Only the ones the light profile actually installs are checked under it — reporting Go and cliamp as # Only what this script still installs is checked. Reporting Go as NOT FOUND on a light install that
# NOT FOUND on an install that deliberately skipped them makes a clean run look broken. # deliberately skipped it makes a clean run look broken; so does checking for nvim, lazygit and eza,
# which this script no longer owns at all.
if ! is_light; then if ! is_light; then
check go check go
check rustc
check cargo
check nvim
check starship
check lazygit
check eza
fi fi
check starship
check zsh check zsh
check rg check rg
check fd check fd
@@ -1134,21 +853,15 @@ check tree
check btop check btop
check sqlite3 check sqlite3
if ! is_light; then # Neither of these is installed here any more — they come from the host provisioning. They are still
echo "" # checked because section 19 and every chat turn depend on them, and "NOT FOUND" here is the only
echo "Audio (cliamp):" # warning you get before the services silently do not start.
check pulseaudio
check parec
check pactl
check cliamp
fi
echo "" echo ""
echo "AI agents:" echo "AI agents (from host provisioning):"
check claude check claude
echo "" echo ""
echo "Process manager:" echo "Process manager (from host provisioning):"
check pm2 check pm2
echo "" echo ""
@@ -1158,7 +871,6 @@ check 7z
check unrar check unrar
check pgrep check pgrep
check fuser check fuser
if ! is_light; then check yt-dlp; fi
echo "" echo ""
echo "═══════════════════════════════════════════" echo "═══════════════════════════════════════════"
@@ -1186,8 +898,9 @@ fi
echo "" echo ""
echo "Notes:" echo "Notes:"
echo " • PulseAudio null sink starts automatically with the server"
echo " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools" echo " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools"
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications" echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
echo " • node, npm, pm2 and the agent CLIs come from the host provisioning, not from here"
echo " • Rust, PulseAudio, cliamp, yt-dlp and the remote desktop are NOT installed by this script:"
echo " run 'bash scripts/setup/setup-sidecars.sh' if you want them"
echo "" echo ""
@@ -1,14 +1,14 @@
#!/bin/bash #!/bin/bash
# Officer — macOS laptop setup. # Officer — macOS laptop setup.
# #
# The barebones counterpart to scripts/setup.sh (which targets an Ubuntu/Debian server and is left # The barebones counterpart to scripts/setup/setup.sh (which targets an Ubuntu/Debian server and is left
# alone). This installs only what a laptop workflow needs: the file browser, Claude/opencode chat, # alone). This installs only what a laptop workflow needs: the file browser, Claude/opencode chat,
# and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop, # and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop,
# no sudoers grant, no power-management changes. # no sudoers grant, no power-management changes.
# #
# EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively: # EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively:
# #
# SETUP_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/setup_mac_light.sh # SETUP_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/setup/setup_mac_light.sh
# #
# SETUP_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI # SETUP_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI
# SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode CLI # SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode CLI
@@ -22,7 +22,7 @@
# This script never calls sudo itself — everything lands under the Homebrew prefix or $HOME. Note # This script never calls sudo itself — everything lands under the Homebrew prefix or $HOME. Note
# that Homebrew's own installer does ask for an administrator password on a fresh Mac. # that Homebrew's own installer does ask for an administrator password on a fresh Mac.
# #
# Usage: bash scripts/setup_mac_light.sh # Usage: bash scripts/setup/setup_mac_light.sh
set -euo pipefail set -euo pipefail
@@ -48,7 +48,10 @@ FAILURES=()
note_failure() { FAILURES+=("$1"); fail "$1"; } note_failure() { FAILURES+=("$1"); fail "$1"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # ../.. — this lives in scripts/setup/. See the note in setup.sh: PROJECT_DIR is where .env is written
# and where bun install, gen:index, db:push and pm2 are pointed, and none of them fails loudly on the
# wrong directory.
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
PG_FORMULA="postgresql@18" PG_FORMULA="postgresql@18"
PG_DATABASE="officer_dev" PG_DATABASE="officer_dev"
@@ -103,7 +106,7 @@ echo "════════════════════════
step "Preflight" step "Preflight"
if [ "$(uname -s)" != "Darwin" ]; then if [ "$(uname -s)" != "Darwin" ]; then
fail "This script is macOS-only. On Linux use scripts/setup.sh." fail "This script is macOS-only. On Linux use scripts/setup/setup.sh."
exit 1 exit 1
fi fi
@@ -532,5 +535,5 @@ echo " • Not run on macOS: VNC desktop, email sync, music indexer, cliamp aud
echo " that front a container or an external service — vault, slskd, headscale, transmission," echo " that front a container or an external service — vault, slskd, headscale, transmission,"
echo " invoiceshelf, memos, photos, caldav, notify, wallet. See ecosystem.mac.light.config.cjs." echo " invoiceshelf, memos, photos, caldav, notify, wallet. See ecosystem.mac.light.config.cjs."
echo " • Pin a specific Claude CLI with CLAUDE_BIN=/path/to/claude in .env if you need to" echo " • Pin a specific Claude CLI with CLAUDE_BIN=/path/to/claude in .env if you need to"
echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup_mac_light.sh" echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup/setup_mac_light.sh"
echo "" echo ""
+707
View File
@@ -0,0 +1,707 @@
#!/bin/bash
# =============================================================================
# machine-setup — shared foundation
# =============================================================================
#
# Sourced by machine-setup.sh before anything runs. DEFINITIONS ONLY: this file
# declares state and functions and must never install, write or restart
# anything. Sourcing it has to be safe at any point, including from a step that
# is only being read for its variables.
#
# The one thing it expects from its caller, because they are facts about the
# entry point rather than about this library:
#
# SCRIPT_DIR directory of the script being run
# PROGRESS_FILE where completed step names are recorded
#
# Everything else below is owned here.
# Guard against being sourced twice — steps will eventually source this
# directly so they can be run on their own, and re-running it would reset
# SUMMARY and lose everything recorded so far.
[[ -n "${MACHINE_SETUP_BASE_LOADED:-}" ]] && return 0
MACHINE_SETUP_BASE_LOADED=1
# -----------------------------------------------------------------------------
# Shared state
# -----------------------------------------------------------------------------
SUMMARY=() # what was done, printed at the end
ERRORS=() # non-fatal failures, printed at the end
CURRENT_STEP=""
SKIP_STEP=false
# What machine this is. Filled in by detect_os() before any step runs; every step
# after that branches on these rather than assuming apt on x86_64.
OS="" # os-release ID: ubuntu | debian | arch | fedora | macos | …
OS_NAME="" # pretty name, for the banner
OS_VERSION="" # version id; empty on rolling releases
PM="" # apt | pacman | dnf | brew
ARCH="" # amd64 | arm64, normalised — upstream tarballs disagree on spelling
IS_WSL=false
# What this box is FOR. Asked once in pre-flight and consulted by the steps
# afterwards, because several of them have a different right answer per role and
# no way to work it out on their own:
#
# homelab a machine you physically control on a network you own
# vps rented, public IP, someone else's DHCP and console
# dev a laptop or desktop you sit at
#
# Set MACHINE_ROLE in the environment to answer it ahead of time — hence the
# :- default rather than a plain assignment, which would wipe what the caller
# passed in before ask_machine_role ever looked at it.
MACHINE_ROLE="${MACHINE_ROLE:-}"
# -----------------------------------------------------------------------------
# Output
# -----------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${CYAN}::${NC} $*"; }
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
fail() {
echo -e " ${RED}FAIL${NC}: $*"
exit 1
}
# -----------------------------------------------------------------------------
# Steps and resume
# -----------------------------------------------------------------------------
#
# A step announces itself, and is skipped when its name is already in the
# progress file. step_ok records it. The pattern at each call site is:
#
# step "Name"
# if ! skip; then
# …
# step_ok
# fi
# -----------------------------------------------------------------------------
# Remembering the answers
# -----------------------------------------------------------------------------
#
# Pre-flight asks four things — role, account, where Officer goes — and every
# section needs them. Asking again on every run made a resumed run re-answer
# questions it had already been told, and made --only unusable: four questions to
# reach one section.
#
# Saved beside the progress file, and loaded before anything is asked. The
# environment still wins, so SETUP_USERNAME=x on the command line overrides what
# was saved.
ANSWERS_FILE="${ANSWERS_FILE:-}"
save_answers() {
[[ -n "$ANSWERS_FILE" ]] || return 0
cat >"$ANSWERS_FILE" <<EOF
# Written by machine-setup. Delete this to be asked again.
MACHINE_ROLE=${MACHINE_ROLE}
SETUP_USERNAME=${USERNAME}
OFFICER_ROOT=${OFFICER_ROOT}
EOF
chmod 600 "$ANSWERS_FILE"
}
# Loaded as assignments, not sourced as a script: this file sits beside the
# script and is read by a root run, so it should not be able to execute anything.
load_answers() {
[[ -n "$ANSWERS_FILE" && -r "$ANSWERS_FILE" ]] || return 0
local key value
while IFS='=' read -r key value; do
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
[[ -n "$value" ]] || continue
# The environment wins over what was saved.
#
# Written as if/then rather than `[[ … ]] && assign`.
#
# That form returns non-zero when the test is false. Harmless on its own —
# `set -e` exempts the left side of an && list — but here it is the last
# thing the case runs, the case is the last thing the loop body runs, and the
# loop is the last thing THE FUNCTION runs. So load_answers returned
# non-zero, and calling a function that returns non-zero is a plain command
# failure, which does end the script.
#
# It needed the answers file to exist AND the variables to be set already, so
# it only appeared when running with env overrides. The trap reported "Step:
# unknown" at a line inside this library, before pre-flight had run.
#
# The general rule this is an instance of: a function whose last statement
# can return non-zero fails when it is called, however innocuous the
# statement looks.
case "$key" in
MACHINE_ROLE) if [[ -z "${MACHINE_ROLE:-}" ]]; then MACHINE_ROLE="$value"; fi ;;
SETUP_USERNAME) if [[ -z "${SETUP_USERNAME:-}" ]]; then SETUP_USERNAME="$value"; fi ;;
OFFICER_ROOT) if [[ -z "${OFFICER_ROOT:-}" ]]; then OFFICER_ROOT="$value"; fi ;;
esac
done <"$ANSWERS_FILE"
}
# Set by --only. When it is set, every step whose name does not match is passed
# over in silence, and the one that does matches runs regardless of the progress
# 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
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
else
SKIP_STEP=true
fi
return
fi
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
SKIP_STEP=true
return
fi
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
}
skip() { [[ "$SKIP_STEP" == true ]]; }
step_ok() {
# A single step run on its own is not progress through the script, and
# recording it would make the next full run skip it.
[[ -n "$ONLY_STEP" ]] && return 0
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
}
# Try a command, log error but don't exit
try() {
local label="$1"
shift
if "$@" 2>&1; then
ok "$label"
else
warn "$label — failed (non-critical, continuing)"
ERRORS+=("$label")
fi
}
# -----------------------------------------------------------------------------
# Input
# -----------------------------------------------------------------------------
prompt_value() {
local varname="$1" message="$2" default="$3"
# If env var already set, use it silently
if [[ -n "${!varname:-}" ]]; then
return
fi
local input
if [[ -n "$default" ]]; then
read -rp "$message [$default]: " input
eval "$varname=\"\${input:-$default}\""
else
read -rp "$message: " input
eval "$varname=\"\$input\""
fi
}
# Show long output a screen at a time.
#
# Only when there is a terminal to page on: with output redirected or piped —
# a transcript, a log, the test harness — it has to come through whole, and a
# pager would either block or mangle it. `more` rather than `less` because it
# exits at the end of the file instead of sitting there waiting to be quit,
# which is what you want for something you asked to read once.
page() {
if [[ -t 1 ]] && command -v more &>/dev/null; then
more
else
cat
fi
}
# Ask before acting. Every section that changes the machine goes through this, so
# a run is a sequence of things you agreed to rather than a wall of output you
# read afterwards to find out what happened.
#
# Enter means yes — unlike the machine-role question, which has no default. These
# are "do the thing you already asked for", and making twenty of them require a
# 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
# already asked for" and Enter should mean yes; a few are genuine extras, where
# defaulting to yes would have people agreeing to them by reflex.
local default="${2:-y}"
# Third is the name of a function that explains the question. Where one is
# given, `?` becomes an answer — so the explanation is available to whoever
# wants it without being in the way of whoever does not.
local help_fn="${3:-}"
local answer prompt
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
while true; do
# EOF is not a yes. Without this an unattended run without ASSUME_YES would
# spin here forever.
if ! read -rp " ${message} ${prompt}: " answer; then
echo ""
fail "No answer. Set ASSUME_YES=1 to run without prompts."
fi
[[ -z "$answer" ]] && answer="$default"
case "$answer" in
y | Y | yes | Yes) return 0 ;;
n | N | no | No) return 1 ;;
"?")
if [[ -n "$help_fn" ]]; then
echo ""
"$help_fn" | page
echo ""
else
warn "Answer y or n."
fi
;;
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
esac
done
}
# Which account this machine is being set up for.
#
# Asked at the top because two later questions default off it — where Officer is
# installed, and where the disk ballast goes — so it has to be settled before
# either is put to the user.
#
# Defaults to whoever invoked sudo. On a re-run, or on a machine that is already
# somebody's, that is the answer every time, and typing it again is a chance to
# typo it into creating a second account.
#
# SETUP_USERNAME in the environment answers it ahead of time. Deliberately not
# USERNAME: that name is set by some login environments, and a variable this
# script silently obeys should not be one that might already be in the
# environment for unrelated reasons.
ask_username() {
local default="${SUDO_USER:-}" answer
# root invoked the script directly rather than through sudo. It is never the
# account being set up, so there is nothing to suggest.
[[ "$default" == "root" ]] && default=""
if [[ -n "${SETUP_USERNAME:-}" ]]; then
answer="$SETUP_USERNAME"
else
echo ""
info "Which account is this machine for?"
echo " The account you log in and work as, day to day. It will be created"
echo " if it does not exist."
echo ""
warn "Strongly advised: use a normal account, not root."
echo " Working as root means everything runs with no safety net. A typo in"
echo " a path deletes instead of refusing, anything you run has the whole"
echo " machine, and nothing distinguishes you from a process that got out"
echo " of hand. sudo gives you the same power when you ask for it, and"
echo " only then — which is why root is not accepted as an answer here."
# Whether this was started FROM a root session, which usually means root is
# how they log in. That is exactly the situation the advice above is for, and
# the one where general advice is easiest to assume is aimed at somebody else.
#
# Two ways to be in it, and the second is the one that hides: no SUDO_USER at
# all, or a SUDO_USER that is itself uid 0. Some providers ship an image whose
# default account is uid 0 under an ordinary-looking name, so `sudo` from it
# sets SUDO_USER to something that looks like a normal user and is not.
local invoker_uid=""
[[ -n "${SUDO_USER:-}" ]] && invoker_uid="$(id -u "$SUDO_USER" 2>/dev/null || true)"
if [[ -z "${SUDO_USER:-}" || "$invoker_uid" == "0" ]]; then
echo ""
if [[ -n "${SUDO_USER:-}" ]]; then
warn "You are running this from '${SUDO_USER}', which is uid 0 — the root account."
else
warn "You are running this as root directly, not through sudo."
fi
echo " If that is how you normally log into this machine, now is the"
echo " moment to make an account and stop doing that."
fi
echo ""
while [[ -z "${answer:-}" ]]; do
if ! read -rp " Username${default:+ [$default]}: " answer; then
echo ""
fail "No answer. Set SETUP_USERNAME=<name> to answer this ahead of time."
fi
answer="${answer:-$default}"
[[ -z "$answer" ]] && warn "There is no default here — type a username."
done
fi
# The portable shape of a Linux account name. Worth checking rather than
# letting adduser refuse it later, because by then several questions have been
# answered against a name that was never going to work.
[[ "$answer" =~ ^[a-z_][a-z0-9_-]*\$?$ && ${#answer} -le 32 ]] ||
fail "'${answer}' is not a usable Linux username — lower case, starting with a letter or underscore."
# By uid, not by name. "root" is a label — what makes an account root is uid 0,
# and some providers ship an image whose default login is uid 0 under a
# friendlier name. Refusing only the string would let exactly that case through,
# which is the one worth catching.
local answer_uid
answer_uid="$(id -u "$answer" 2>/dev/null || true)"
if [[ "$answer_uid" == "0" ]]; then
if [[ "$answer" == "root" ]]; then
fail "root is not the account to set up here — see the warning above."
fi
fail "'${answer}' is uid 0 — the root account under another name, and not what to set up here."
fi
USERNAME="$answer"
# Looked up, not assumed. The original built "/home/$USERNAME", which is merely
# the usual answer — an account created with a different home, or one whose home
# was moved, would have every later step writing to a directory that is not
# theirs.
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
}
# Where Officer will live.
#
# Asked in pre-flight with the rest of the questions rather than at the point it
# is first needed, because it decides the shape of several later steps — the
# directory the repository is cloned into, where DATA_PATH sits beside it, and
# which filesystem the app store's containers bind-mount out of. Answering it
# once at the start also means the run can be described before it begins.
#
# One directory holding four, per docs/sidecar-app-store.md:
#
# <root>/platform/ the app
# <root>/data/ DATA_PATH
# <root>/dockers/ services the app store provisioned
# <root>/capabilities/ the file-based item store
#
# OFFICER_ROOT in the environment answers it ahead of time.
ask_officer_root() {
local default="${USER_HOME}/officerdev" answer
if [[ -n "${OFFICER_ROOT:-}" ]]; then
answer="$OFFICER_ROOT"
else
echo ""
info "Where should Officer be installed?"
echo " One directory holding the app, its data, the item store and any"
echo " containers the app store provisions — so it can be moved, backed"
echo " up or deleted as a unit."
echo ""
if ! read -rp " Path [${default}]: " answer; then
echo ""
fail "No answer. Set OFFICER_ROOT=<path> to answer this ahead of time."
fi
answer="${answer:-$default}"
fi
# A leading ~ arrives as a literal when it comes from a read or an environment
# variable — nothing expands it there — and would create a directory named "~".
answer="${answer/#\~/$USER_HOME}"
[[ "$answer" == /* ]] || fail "That needs to be an absolute path, starting with / — got '${answer}'"
OFFICER_ROOT="${answer%/}"
}
# The account's PRIMARY GROUP, asked of the system rather than assumed to be
# named after the user.
#
# Debian and Ubuntu create a group per user, so "pastilhas:pastilhas" is right on
# most machines — but not on one where the account came from LDAP, or was made
# with `useradd -g users`, or is a cloud image with a shared group. There
# `chown user:user` fails with "invalid group" and `install -g user` refuses,
# both of which abort the step.
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
# Run a block as the created user (login shell, inherits HOME)
as_user() {
sudo -u "$USERNAME" -i bash -c "$1"
}
# -----------------------------------------------------------------------------
# sudoers
# -----------------------------------------------------------------------------
# Grant an account passwordless sudo, safely.
#
# A malformed file in /etc/sudoers.d breaks sudo COMPLETELY — and you cannot sudo
# to repair it, so on a remote machine that is unrecoverable short of a rescue
# console. The same is true of one with loose permissions: sudo refuses to read
# its own configuration and every sudo on the box fails.
#
# The original wrote the file into /etc/sudoers.d first and validated it after,
# with a chmod later still. Both of those leave a window where a broken or
# world-readable sudoers file is live. This validates a temp file first and then
# places it with its mode in a single install(1) — so what lands in /etc is
# already known good and already 0440.
grant_passwordless_sudo() {
# Declared separately, deliberately. In `local a="$1" b="${a}"` bash expands
# $a before it has been assigned, so b comes out with the name missing — which
# here meant every account's rule landing in the same /etc/sudoers.d/99--nopasswd,
# each one silently overwriting the last, and has_passwordless_sudo never
# finding the file it was looking for.
local user="$1"
local dest="/etc/sudoers.d/99-${user}-nopasswd"
local tmp
tmp="$(mktemp)"
[[ -n "$user" ]] || fail "grant_passwordless_sudo needs a username"
echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"
if ! visudo -c -f "$tmp" >/dev/null 2>&1; then
rm -f "$tmp"
fail "visudo rejected the sudoers entry for '${user}' — not installing it"
fi
install -m 0440 -o root -g root "$tmp" "$dest"
rm -f "$tmp"
}
has_passwordless_sudo() {
local user="$1"
[[ -f "/etc/sudoers.d/99-${user}-nopasswd" ]] ||
grep -rqsE "^${user}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD" /etc/sudoers /etc/sudoers.d 2>/dev/null
}
# -----------------------------------------------------------------------------
# Operating system detection
# -----------------------------------------------------------------------------
#
# Read one key out of /etc/os-release without leaking the rest of it into this
# script. That file defines NAME, VERSION and ID — all generic enough to collide
# with something here — so it is sourced in a subshell and only the one value
# asked for comes back.
os_release() {
[[ -r /etc/os-release ]] || return 1
# shellcheck disable=SC1091
(
. /etc/os-release 2>/dev/null
printf '%s' "${!1:-}"
)
}
# Identify the machine, or refuse to guess.
#
# /etc/os-release rather than probing for a binary: a box can have more than one
# package manager on PATH (a Homebrew install on Linux, a leftover apt on a
# converted box), and only os-release can say which distribution the machine
# actually IS, or give a version worth reporting.
#
# ID_LIKE is the fallback so derivatives resolve without being listed by name —
# Pop!_OS, Mint and EndeavourOS all answer correctly without appearing below.
detect_os() {
local kernel like
kernel="$(uname -s)"
case "$kernel" in
Darwin)
OS="macos"
OS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)"
OS_NAME="macOS ${OS_VERSION}"
PM="brew"
;;
Linux)
OS="$(os_release ID || true)"
OS_NAME="$(os_release PRETTY_NAME || true)"
OS_VERSION="$(os_release VERSION_ID || true)"
like="$(os_release ID_LIKE || true)"
case "$OS" in
ubuntu | debian | linuxmint | pop | raspbian | elementary) PM="apt" ;;
arch | manjaro | endeavouros | cachyos | garuda) PM="pacman" ;;
fedora | rhel | centos | rocky | almalinux) PM="dnf" ;;
*)
case " $like " in
*" debian "* | *" ubuntu "*) PM="apt" ;;
*" arch "*) PM="pacman" ;;
*" fedora "* | *" rhel "*) PM="dnf" ;;
esac
;;
esac
# WSL reports itself as Linux, but has no real systemd session: masking
# sleep targets, restarting logind and anything touching the boot path
# either fail or silently do nothing. Worth knowing before those steps run.
if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true; fi
;;
MINGW* | MSYS* | CYGWIN*)
fail "Windows is not supported. Run this inside WSL2 with an Ubuntu image instead."
;;
*)
fail "Unrecognised kernel '$kernel' — cannot tell what this machine is."
;;
esac
# Normalised once here because upstream projects spell it differently:
# Neovim ships aarch64, Go and Docker ship arm64, and lazygit ships x86_64.
case "$(uname -m)" in
x86_64 | amd64) ARCH="amd64" ;;
aarch64 | arm64) ARCH="arm64" ;;
*) fail "Unsupported CPU architecture '$(uname -m)' — this script installs amd64/arm64 binaries only." ;;
esac
[[ -n "$OS" ]] || fail "Could not identify this distribution (no readable /etc/os-release)."
[[ -n "$OS_NAME" ]] || OS_NAME="$OS${OS_VERSION:+ $OS_VERSION}"
}
# -----------------------------------------------------------------------------
# Machine role
# -----------------------------------------------------------------------------
# The interface packets actually leave by, which is not always the first one up.
default_iface() {
ip route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
}
# Ask what this machine is, unless the environment already said.
#
# Asked in pre-flight rather than at the point of use so that the run knows its
# own shape before it starts: the steps that care are spread from swap through to
# the firewall, and being asked "is this a VPS?" for the fourth time halfway down
# a provisioning run is how people start answering without reading.
#
# NO DEFAULT, deliberately, and it is the only question in the script like that.
# A guessed default is right often enough to be trusted and wrong in exactly the
# case that costs the most: pinning a static IP on a rented box, or leaving the
# 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 ;;
*) fail "MACHINE_ROLE must be homelab, vps or dev — got '$MACHINE_ROLE'" ;;
esac
fi
echo ""
info "What is this machine? Several later steps depend on the answer."
echo " [1] homelab — yours, on a network you control"
echo " [2] vps — rented, public IP, provider's DHCP and console"
echo " [3] dev — a laptop or desktop you sit at"
echo ""
local choice
while [[ -z "$MACHINE_ROLE" ]]; do
# A failed read means EOF, not a wrong answer — without this the loop would
# spin forever when stdin is closed, which is how an unattended run hangs.
if ! read -rp " Which one? (1/2/3): " choice; then
fail "No answer, and this question has no default. Set MACHINE_ROLE=homelab|vps|dev to answer it ahead of time."
fi
case "$choice" in
1 | homelab) MACHINE_ROLE=homelab ;;
2 | vps) MACHINE_ROLE=vps ;;
3 | dev) MACHINE_ROLE=dev ;;
"") warn "There is no default here — pick 1, 2 or 3." ;;
*) warn "Not one of the options: '$choice'" ;;
esac
done
}
# Convenience for the steps that branch on it.
is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; }
is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }
+407
View File
@@ -0,0 +1,407 @@
#!/bin/bash
# =============================================================================
# machine-setup — the development environment
# =============================================================================
#
# Definitions only, like the other lib/ files.
[[ -n "${MACHINE_SETUP_DEV_LOADED:-}" ]] && return 0
MACHINE_SETUP_DEV_LOADED=1
# -----------------------------------------------------------------------------
# git
# -----------------------------------------------------------------------------
#
# Read and written as the account, not as root. `git config --global` writes to
# $HOME/.gitconfig, so running it under sudo without -H would write root's.
#
# ── Why this asks before touching an existing identity ──
#
# The original set all four values unconditionally on every run. Re-running it on
# a machine somebody already uses replaces the name and email they had with
# whatever is typed — and prompt_value accepts an empty answer, so pressing
# Enter twice wrote `user.name = ""`. An empty name is worse than none at all:
# unset makes git refuse to commit and say why, empty makes it commit with a
# blank author and never mention it.
#
# ── And why it is worth being careful about here in particular ──
#
# docs/agent-git-identity.md: every agent Officer runs commits AS THE OWNER,
# because it runs as the owner. So this is not only the human's identity — it is
# what `git log` will attribute every agent commit on this machine to.
# Run from / rather than wherever the script was launched.
#
# `git config --global` reads and writes $HOME/.gitconfig and needs no repository
# — but git still stats the working directory on the way, looking for one. The
# script is typically launched from somewhere under the invoking user's home,
# which is 0750, so the target account cannot stat it and every call dies with
#
# fatal: failed to stat '<cwd>': Permission denied
#
# Found because the writes failed silently: the section reported "written" while
# nothing had been. Both wrappers now run in a subshell from /, which every
# account can stat, and their exit status is checked by the caller.
# `git config --get` exits NON-ZERO when the key is simply unset, and
# `VAR="$(git_get …)"` propagates that under `set -e`. So on a machine where git
# has never been configured — the fresh machine this script exists for — reading
# the current value aborted the run before the section had printed anything.
# Missing a value is an answer here, not a failure.
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
# Is there anything configured at all?
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
# Ask for a value that must not be empty. The original's prompt accepted empty
# and wrote it; this re-asks.
ask_required() {
local __var="$1" message="$2" default="$3" answer=""
while [[ -z "$answer" ]]; do
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
echo ""
fail "No answer."
fi
answer="${answer:-$default}"
[[ -z "$answer" ]] && warn "This one cannot be left blank."
done
printf -v "$__var" '%s' "$answer"
}
# -----------------------------------------------------------------------------
# Shell
# -----------------------------------------------------------------------------
#
# ── One starship config, not two ──
#
# The platform deploys scripts/setup/starship.toml into every member's home
# (os-user-shell.ts), and the comment there calls it "the prompt config the
# owner's own install uses — one file, both audiences". That was not true: the
# original machine script wrote a DIFFERENT config inline, so the owner got one
# prompt and every member got another. This deploys the same file the platform
# does, which makes the comment true rather than aspirational.
#
# It lives one directory up because it is shared with the platform, not owned by
# this script.
STARSHIP_SRC="${STARSHIP_SRC:-$SCRIPT_DIR/../starship.toml}"
user_login_shell() { getent passwd "$USERNAME" | cut -d: -f7; }
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 ||
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" >/dev/null 2>&1
}
# -----------------------------------------------------------------------------
# Neovim
# -----------------------------------------------------------------------------
#
# From the upstream tarball rather than the distribution, which ships Neovim
# years behind — Ubuntu 24.04 has 0.9 where upstream is on 0.12, and LazyVim
# requires 0.9+ with most plugins wanting newer.
#
# The asset names are x86_64 and arm64. The original mapped aarch64 to
# "aarch64", which is not a name Neovim publishes: on an arm machine it
# downloaded a 404 and tar failed on the HTML error page.
nvim_asset() {
case "$ARCH" in
amd64) echo x86_64 ;;
arm64) echo arm64 ;;
esac
}
nvim_installed_version() { nvim --version 2>/dev/null | awk 'NR == 1 { print $2 }'; }
nvim_latest_version() {
curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest 2>/dev/null |
jq -r '.tag_name // empty'
}
# Downloaded to /tmp, not to whatever directory the script was launched from —
# the original used `curl -LO`, which drops the tarball beside the script and
# leaves it there if tar fails.
#
# The old install is removed only after the download has succeeded, so a failed
# fetch leaves the working copy alone.
nvim_install() {
local asset tarball dest
asset="$(nvim_asset)"
tarball="/tmp/nvim-linux-${asset}.tar.gz"
dest="/opt/nvim-linux-${asset}"
curl -fsSL -o "$tarball" \
"https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${asset}.tar.gz" || return 1
# A 404 comes back as an HTML page, and tar's failure on it is unhelpful.
# Checking here names the real problem.
tar -tzf "$tarball" >/dev/null 2>&1 || {
rm -f "$tarball"
warn "the download is not a tarball — the release asset may have been renamed"
return 1
}
rm -rf "$dest"
tar -C /opt -xzf "$tarball"
rm -f "$tarball"
ln -sf "${dest}/bin/nvim" /usr/local/bin/nvim
}
# Clone a Neovim config into the account's ~/.config/nvim.
#
# From `cd /` for the same reason git config does: the script's working directory
# is usually under the invoking user's home at 0750, which the target account
# cannot stat, and git fails there before it does anything useful.
nvim_clone_config() {
local repo="$1" dest="${USER_HOME}/.config/nvim"
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
# The starter is a template, not something to track. Left in place for a
# config of the user's own, which they will want to keep pulling.
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
return 0
}
# -----------------------------------------------------------------------------
# JavaScript runtimes
# -----------------------------------------------------------------------------
#
# Three of these are not optional, and it is worth being precise about why,
# because "we run on Bun" suggests Node could go and it cannot:
#
# node pm2 is a Node application (#!/usr/bin/env node), and pm2 supervises
# every process here. officer-pty imports node-pty, a native addon with
# no Linux prebuild — it compiles against the installed Node on every
# machine. Either one alone makes Node load-bearing.
# bun the platform itself and nineteen of the twenty pm2 apps.
# pm2 the process manager the ecosystem files are written for.
#
# Deno is not. Nothing in the platform imports it — checked across the whole
# tree — and it is offered only because it was in the original script and
# somebody may still want it.
# The current LTS major, asked of nodejs.org rather than hardcoded. The original
# pinned setup_22.x, which ages into "the version we happened to pick" the moment
# a new LTS lands.
node_lts_major() {
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
jq -r '[.[] | select(.lts != false)][0].version // empty' | sed 's/^v//; s/\..*//'
}
node_lts_label() {
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
jq -r '[.[] | select(.lts != false)][0] | "\(.version) (\(.lts))" // empty'
}
node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
install_node() {
local major="$1"
# NodeSource publishes one setup script per major. Checked before it is piped
# into a shell, because a 404 page piped to bash is a confusing way to fail.
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
warn "NodeSource has no setup script for Node ${major}"
return 1
}
curl -fsSL "https://deb.nodesource.com/setup_${major}.x" | bash - >/dev/null 2>&1
pkg_install_now nodejs
# Global installs land in /usr/local rather than in a path only root can write,
# so `npm i -g` works the same for the owner and for root.
npm config set prefix /usr/local >/dev/null 2>&1 || true
}
# Present anywhere: on PATH for this root shell, or in the account's own
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
# Asked of whichever copy exists. Before the symlink is made, root's PATH has no
# bun at all, so `bun --version` reports nothing on a machine that plainly has it.
bun_version() {
if command -v bun &>/dev/null; then
bun --version
elif [[ -x "${USER_HOME}/.bun/bin/bun" ]]; then
"${USER_HOME}/.bun/bin/bun" --version
fi
}
# The system-wide link, ensured on every run rather than only after an install.
#
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
# then fails to start on reboot while working perfectly when started by hand. A
# machine that already had bun before this script ran would never get the link if
# it were only made as part of installing.
#
# Safe across upgrades: a symlink resolves by path, and `bun upgrade` replaces
# the file at $BUN_INSTALL/bin/bun rather than moving it. The link only breaks if
# the home directory goes, which breaks bun anyway.
ensure_bun_symlink() {
local bin="${USER_HOME}/.bun/bin/bun"
[[ -x "$bin" ]] || return 1
[[ "$(readlink -f /usr/local/bin/bun 2>/dev/null)" == "$(readlink -f "$bin")" ]] && return 1
ln -sf "$bin" /usr/local/bin/bun
return 0
}
# Installed as the account, then symlinked system-wide. pm2 started at boot by
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
# symlink every bun-based sidecar fails to start on reboot and works fine when
# started by hand, which is a miserable thing to debug.
install_bun() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
}
pm2_installed() { command -v pm2 &>/dev/null; }
install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
install_deno() {
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
}
# -----------------------------------------------------------------------------
# Agent CLIs
# -----------------------------------------------------------------------------
#
# Claude Code goes in through Anthropic's own installer rather than npm, matching
# what the platform does for members (os-user-claude.ts) and chosen there for the
# auto-update the npm package does not do.
#
# Two things that installer insists on, both of which a naive port gets wrong:
#
# It REFUSES to run under sudo from a regular user's shell — it checks for uid 0
# with SUDO_USER set, because everything it installs goes under $HOME and under
# sudo that is root's home. So it must run AS the account, not as root.
#
# It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash.
# `| sh` fails on a dash-based /bin/sh, which is Ubuntu's.
#
# Both are recorded in os-user-claude.ts too, which found them first.
CLAUDE_INSTALL_URL="https://claude.ai/install.sh"
OPENCODE_INSTALL_URL="https://opencode.ai/install"
# Where each installer actually puts its binary. They disagree, and the platform
# depends on the difference:
#
# claude ~/.local/bin/claude — claude-manager.ts tries Bun.which then
# that exact path
# opencode ~/.opencode/bin/opencode — sidecar/opencode/index.ts:22 hardcodes
# join(homedir(), '.opencode', 'bin', …)
#
# Looking for opencode in ~/.local/bin, as an earlier version of this did,
# reports a perfectly good install as missing and then installs it again.
agent_bin() {
case "$1" in
claude) echo "${USER_HOME}/.local/bin/claude" ;;
opencode) echo "${USER_HOME}/.opencode/bin/opencode" ;;
*) echo "${USER_HOME}/.local/bin/$1" ;;
esac
}
# The directories those live in, for the account's PATH.
agent_bin_dirs() { echo "${USER_HOME}/.local/bin" "${USER_HOME}/.opencode/bin"; }
agent_installed() { [[ -x "$(agent_bin "$1")" ]] || command -v "$1" &>/dev/null; }
# Which copy answers, so the run can say where it came from. Claude installed
# from npm sits in /usr/local/lib/node_modules and does NOT auto-update, which is
# the whole reason the platform prefers Anthropic's installer.
agent_path() {
local name="$1" bin
bin="$(agent_bin "$name")"
[[ -x "$bin" ]] && {
echo "$bin"
return
}
command -v "$name" 2>/dev/null || true
}
agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" == */node_modules/* ]]; }
agent_version() {
local bin
bin="$(agent_path "$1")"
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
}
install_claude_code() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin claude)" ]]
}
install_opencode() {
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
[[ -x "$(agent_bin opencode)" ]]
}
install_pi() { npm install -g @mariozechner/pi-coding-agent >/dev/null 2>&1; }
# -----------------------------------------------------------------------------
# Default editor
# -----------------------------------------------------------------------------
#
# One preference, two mechanisms, and both are needed:
#
# EDITOR / VISUAL what the account's own shell hands to git, crontab -e,
# systemctl edit and anything else that opens an editor
# update-alternatives the system-wide `editor` command, which is what root and
# `sudoedit` use — an account's shell config cannot reach
# those
#
# This is the setting core.editor was deliberately left out in favour of: set it
# here and git follows, along with everything else.
editor_candidates() {
local e
for e in nvim vim nano; do command -v "$e" &>/dev/null && echo "$e"; done
}
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
# assignment from a function that exits non-zero aborts the run under `set -e`.
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
set_system_editor() {
local editor="$1" path
path="$(command -v "$editor")" || return 1
# Only where the alternatives system is in use. Absent on non-Debian systems,
# where there is nothing to set.
command -v update-alternatives &>/dev/null || return 0
update-alternatives --install /usr/bin/editor editor "$path" 100 >/dev/null 2>&1
update-alternatives --set editor "$path" >/dev/null 2>&1
}
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# =============================================================================
# machine-setup — using the whole disk
# =============================================================================
#
# Definitions only, like the other lib/ files.
#
# ── The problem this exists for ──
#
# Ubuntu Server's installer, left on its defaults, creates an LVM logical volume
# at a fixed size and leaves the rest of the disk as free extents in the volume
# group. On a 2TB drive you get a root filesystem of around 100GB and no
# indication anything is wrong: `lsblk` shows the whole disk, `df` shows 100G,
# and the two are never seen side by side until the day it fills.
#
# The same shape turns up two other ways:
#
# a virtual disk grown at the hypervisor or provider, where the partition still
# ends where it used to
#
# a partition that was resized without the filesystem inside it being told
#
# Three layers, and any one of them can be the short one:
#
# disk the physical or virtual device
# container the partition, or the logical volume
# filesystem what df reports
#
# So all three are measured and reported together. Seeing them in one place is
# most of the value; the fix is usually two commands once you know which layer is
# short.
#
# ── Only ever grows ──
#
# Nothing here shrinks anything, and nothing here creates or deletes a partition.
# ext4, xfs and btrfs all grow while mounted, so there is no unmount and no
# reboot, and a failure part-way leaves a smaller filesystem on a larger
# container — which is exactly the state it started in.
[[ -n "${MACHINE_SETUP_DISK_LOADED:-}" ]] && return 0
MACHINE_SETUP_DISK_LOADED=1
# -----------------------------------------------------------------------------
# What is where
# -----------------------------------------------------------------------------
root_device() { findmnt -no SOURCE / 2>/dev/null; }
root_fstype() { findmnt -no FSTYPE / 2>/dev/null; }
# Size of a block device in bytes.
dev_bytes() { lsblk -bndo SIZE "$1" 2>/dev/null || echo 0; }
# Bytes, formatted the way df and lsblk format them.
human_bytes() { numfmt --to=iec --suffix=B --format='%.1f' "$1" 2>/dev/null || echo "${1}B"; }
# Is the root filesystem on a logical volume?
root_is_lvm() { [[ "$(lsblk -ndo TYPE "$(root_device)" 2>/dev/null)" == "lvm" ]]; }
# The whole disk a device ultimately sits on: /dev/sda1 -> /dev/sda, and through
# LVM as well, since PKNAME walks one level at a time.
parent_disk() {
local dev="$1" name
while true; do
name="$(lsblk -ndo PKNAME "$dev" 2>/dev/null)"
[[ -z "$name" ]] && break
dev="/dev/${name}"
done
echo "$dev"
}
# The partition immediately below a device — for LVM, the one holding the PV.
backing_partition() {
local dev="$1" name
while [[ "$(lsblk -ndo TYPE "$dev" 2>/dev/null)" != "part" ]]; do
name="$(lsblk -ndo PKNAME "$dev" 2>/dev/null)"
[[ -z "$name" ]] && return 1
dev="/dev/${name}"
done
echo "$dev"
}
# Split /dev/sda1 into "/dev/sda 1" — growpart wants them as separate arguments.
# The digits come off the end because that is where a partition number is, on
# /dev/sda1 and /dev/nvme0n1p2 alike.
partition_parts() {
local part="$1" num disk
num="${part##*[!0-9]}"
disk="${part%"$num"}"
disk="${disk%p}" # nvme0n1p2 -> nvme0n1
echo "$disk $num"
}
# -----------------------------------------------------------------------------
# Sizes of the three layers
# -----------------------------------------------------------------------------
# What the filesystem itself believes it is, which is the number df reports and
# the only one of the three that is asked of the filesystem rather than the
# kernel's block layer.
fs_bytes() {
local dev="$1"
case "$(root_fstype)" in
ext2 | ext3 | ext4)
local count size
count="$(tune2fs -l "$dev" 2>/dev/null | awk -F: '/^Block count:/ { gsub(/ /, "", $2); print $2 }')"
size="$(tune2fs -l "$dev" 2>/dev/null | awk -F: '/^Block size:/ { gsub(/ /, "", $2); print $2 }')"
[[ -n "$count" && -n "$size" ]] && echo $((count * size)) || echo 0
;;
xfs | btrfs)
# Both report through the mount rather than the device.
echo $(($(findmnt -bno SIZE / 2>/dev/null || echo 0)))
;;
*) echo 0 ;;
esac
}
# Unallocated extents in the volume group behind root. This is the Ubuntu
# installer case, and the one that is invisible without asking LVM directly.
vg_free_bytes() {
local vg
command -v vgs &>/dev/null || {
echo 0
return
}
vg="$(lvs --noheadings -o vg_name "$(root_device)" 2>/dev/null | tr -d ' ')"
[[ -z "$vg" ]] && {
echo 0
return
}
vgs --noheadings --nosuffix --units b -o vg_free "$vg" 2>/dev/null | tr -d ' ' || echo 0
}
# -----------------------------------------------------------------------------
# Can anything be reclaimed?
# -----------------------------------------------------------------------------
# growpart answers this better than arithmetic on sector counts: it exits 0 when
# it would change something and 1 with NOCHANGE when the partition already
# reaches the end of the disk. Needs cloud-guest-utils, which is not installed by
# default on every image.
partition_can_grow() {
local part="$1" disk num
command -v growpart &>/dev/null || return 1
read -r disk num <<<"$(partition_parts "$part")"
growpart --dry-run "$disk" "$num" &>/dev/null
}
ensure_growpart() {
command -v growpart &>/dev/null && return 0
info " installing cloud-guest-utils, which provides growpart"
pkg_install_now cloud-guest-utils
}
# -----------------------------------------------------------------------------
# Growing
# -----------------------------------------------------------------------------
grow_partition() {
local part="$1" disk num
read -r disk num <<<"$(partition_parts "$part")"
growpart "$disk" "$num"
}
# Tell LVM the partition under the physical volume got bigger.
grow_pv() { pvresize "$1"; }
# Take every free extent in the volume group.
grow_lv() { lvextend -l +100%FREE "$(root_device)"; }
# Grow the filesystem into whatever room it now has. All three do this online, so
# the root filesystem is grown while it is mounted and in use.
grow_fs() {
case "$(root_fstype)" in
ext2 | ext3 | ext4) resize2fs "$(root_device)" ;;
xfs) xfs_growfs / ;;
btrfs) btrfs filesystem resize max / ;;
*)
warn "do not know how to grow a $(root_fstype) filesystem"
return 1
;;
esac
}
+140
View File
@@ -0,0 +1,140 @@
#!/bin/bash
# =============================================================================
# machine-setup — Docker
# =============================================================================
#
# Definitions only, like the other lib/ files.
[[ -n "${MACHINE_SETUP_DOCKER_LOADED:-}" ]] && return 0
MACHINE_SETUP_DOCKER_LOADED=1
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
docker_is_installed() { command -v docker &>/dev/null; }
# The daemon, not just the binary. `docker --version` answers from the client
# alone and says nothing about whether there is anything to talk to.
docker_daemon_ok() { docker info &>/dev/null; }
user_in_docker_group() { id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx docker; }
docker_rootless_installed() { [[ -S "/run/user/$(id -u "$USERNAME" 2>/dev/null)/docker.sock" ]]; }
# The codename Docker's repository is actually published under.
#
# `lsb_release -cs` is what the original used, and it is wrong on every
# derivative: Mint reports "vanessa", Pop reports its own, and Docker publishes
# neither — so `apt update` fails on a repository that does not exist. os-release
# carries UBUNTU_CODENAME on exactly those systems for exactly this reason, so it
# is preferred and VERSION_CODENAME is the fallback.
docker_repo_codename() {
local c
c="$(os_release UBUNTU_CODENAME || true)"
[[ -z "$c" ]] && c="$(os_release VERSION_CODENAME || true)"
echo "$c"
}
# Which upstream to point at. A derivative is Ubuntu or Debian as far as Docker
# is concerned, and ID_LIKE is how it says which.
docker_repo_distro() {
case "$OS" in
ubuntu | debian) echo "$OS" ;;
*)
case " $(os_release ID_LIKE || true) " in
*" ubuntu "*) echo ubuntu ;;
*) echo debian ;;
esac
;;
esac
}
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)"
[[ -n "$codename" ]] || {
warn "could not work out this release's codename — cannot add the Docker repository"
return 1
}
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${distro}/gpg" |
gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${distro} ${codename} stable" \
>/etc/apt/sources.list.d/docker.list
pkg_refresh >/dev/null
# ── 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
# other by name. Harmless if it is already there.
ensure_docker_network() {
docker network inspect "$DOCKER_NETWORK" &>/dev/null && return 0
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
}
# ── Rootless, for the owner ──
#
# Works, and does not work with Officer's app store as it stands. Both are true
# and the second is the one nobody would find out until a container failed to
# provision, so it is stated at the prompt rather than left here.
#
# The app store spawns `docker` with no environment of its own —
# app-store/compose.ts, app-store/preflight.ts, api/system-monitor — so it talks
# to whatever socket the `officer` pm2 process's environment points at. That is
# /var/run/docker.sock unless DOCKER_HOST says otherwise, and nothing sets
# DOCKER_HOST for the owner: os-user-docker.ts sets it only for member commands.
#
# pm2 started at boot by systemd has no session either, so exporting it in a
# shell rc does not reach the process that matters.
install_docker_rootless() {
local uid
uid="$(id -u "$USERNAME")"
# Without lingering, the user manager stops when the last session ends and
# takes the daemon with it. Officer's shells are not login sessions.
loginctl enable-linger "$USERNAME" >/dev/null 2>&1
sudo -u "$USERNAME" \
XDG_RUNTIME_DIR="/run/user/${uid}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
PATH="/usr/bin:/usr/sbin:/bin:/sbin" \
dockerd-rootless-setuptool.sh install >/dev/null 2>&1 || return 1
sudo -u "$USERNAME" \
XDG_RUNTIME_DIR="/run/user/${uid}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
systemctl --user enable --now docker >/dev/null 2>&1
}
+173
View File
@@ -0,0 +1,173 @@
#!/bin/bash
# =============================================================================
# machine-setup — writing files into somebody's home
# =============================================================================
#
# Definitions only, like the other lib/ files.
#
# ── The rule ──
#
# A setup script may create a config file. It may not silently replace one the
# user wrote. The original did the second: `cp .tmux.conf $USER_HOME/` on every
# run, over whatever was there, and five separate `cat >>` into .zshrc with no
# guard — so a second pass duplicated the starship init, the nvim PATH, bun, deno
# and the aliases.
#
# Both of those are the same mistake in different shapes: writing without looking
# first. The two helpers here are the two safe shapes.
[[ -n "${MACHINE_SETUP_FILES_LOADED:-}" ]] && return 0
MACHINE_SETUP_FILES_LOADED=1
# Put a config file in place, asking before it replaces one the user has.
#
# Three outcomes, and the caller can tell them apart by the return code:
#
# 0 installed — there was nothing there, or the user chose to replace
# 1 identical — already exactly this, nothing done
# 2 kept — the user chose to keep theirs
#
# When the file exists and differs, this ASKS rather than deciding. Silently
# keeping theirs is safe but unhelpful — they never learn that a newer version
# exists — and silently replacing it is how a setup script eats somebody's
#configuration. So: keep, replace, or show the difference first, and a replaced file is
# always kept beside the new one.
#
# NOTE for callers: 1 and 2 are outcomes, not failures — but they are still
# non-zero, so calling this as a plain command under `set -e` ends the script
# before the result can be read. Always capture it:
#
# install_config "$src" "$dest" "$user" && rc=0 || rc=$?
install_config() {
local src="$1" dest="$2" owner="$3" answer
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
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."
# Never replace a file the user has without being told to. An unattended run
# answers "keep", because the alternative is destroying configuration nobody
# 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
while true; do
echo " [1] keep yours — nothing changes"
echo " [2] use ours — yours is kept as ${dest}.before-machine-setup"
echo " [3] show me the difference first"
echo ""
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)
echo ""
# yours on the left, ours on the right: - is what you would lose,
# + is what you would gain.
diff -u --label "yours: ${dest}" --label "ours: ${src}" "$dest" "$src" | page
echo ""
;;
*) warn "Pick 1, 2 or 3." ;;
esac
done
}
# Append a block to a file exactly once.
#
# The block is wrapped in markers naming what it is, so a second run recognises
# its own work instead of adding it again — and so a human reading the file can
# see which lines came from here and delete them as a unit.
#
# append_once ~/.zshrc bun <<'EOF'
# export PATH="$HOME/.bun/bin:$PATH"
# EOF
#
# Returns 0 if it wrote, 1 if the block was already there.
#
# One limitation, and it bites the author rather than the user: RENAMING a marker
# orphans the block that used the old name. append_once only recognises the name
# it is given, so the previous block stays in the file doing whatever it did.
# Changing a block's CONTENT has the same shape — the marker is found, so the new
# content is never written. Both need the old block removed by hand.
append_once() {
local file="$1" name="$2"
local begin="# >>> machine-setup: ${name} >>>"
local end="# <<< machine-setup: ${name} <<<"
if [[ -f "$file" ]] && grep -qF "$begin" "$file"; then
return 1
fi
{
echo ""
echo "$begin"
cat
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" ]]
}
+248
View File
@@ -0,0 +1,248 @@
#!/bin/bash
# =============================================================================
# machine-setup — network configuration
# =============================================================================
#
# Definitions only, like the other lib/ files.
[[ -n "${MACHINE_SETUP_NETWORK_LOADED:-}" ]] && return 0
MACHINE_SETUP_NETWORK_LOADED=1
# -----------------------------------------------------------------------------
# DNS
# -----------------------------------------------------------------------------
#
# ── What is actually being changed here ──
#
# On a machine running systemd-resolved there are two layers, and only one of
# them is ours to set:
#
# per-link what DHCP handed each interface, and what Tailscale installs on
# its own. These answer for that link's domains — the provider's
# internal names, and the tailnet — and are NOT touched here.
# Overriding them is how private networking quietly stops resolving.
#
# global the resolver used when no link claims the query. This is what the
# step sets.
#
# So this changes where public lookups go, and leaves the machine's own networks
# resolving exactly as they did.
#
# ── Drop-in, and note the sort order ──
#
# systemd reads drop-ins in lexical order and the LAST value wins, so 99- is what
# overrides. That is the opposite of sshd, three files away in this same
# directory, where the FIRST value wins and the drop-in has to sort early. Worth
# stating because getting it backwards fails silently in both directions.
#
# The original rewrote /etc/systemd/resolved.conf wholesale, which discards
# anything else in it — DNSSEC, DNSOverTLS, Domains, Cache — without mentioning
# that it had.
RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/99-machine-setup.conf
resolved_is_active() { systemctl is-active --quiet systemd-resolved 2>/dev/null; }
# The global resolvers in force, space separated, or empty if none are set.
dns_current_global() {
if resolved_is_active; then
resolvectl status 2>/dev/null | awk '/^ *DNS Servers:/ { $1 = ""; $2 = ""; print; exit }' | xargs
else
awk '/^nameserver/ { printf "%s ", $2 }' /etc/resolv.conf 2>/dev/null | xargs
fi
}
# What each interface was handed. Printed, never changed — the point is to show
# that this step is not touching them.
dns_per_link() {
resolved_is_active || return 0
resolvectl status 2>/dev/null |
awk '/^Link [0-9]+ \(/ { link = $3; gsub(/[()]/, "", link) }
/^ *DNS Servers:/ && link { $1 = ""; $2 = ""; printf "%s:%s\n", link, $0; link = "" }'
}
dns_set_global() {
local primary="$1" fallback="$2"
if resolved_is_active; then
install -d -m 0755 "$(dirname "$RESOLVED_DROPIN")"
cat >"$RESOLVED_DROPIN" <<EOF
# Written by machine-setup. 99- so it sorts last: systemd drop-ins are
# last-value-wins. Only the GLOBAL resolvers are set here — per-link DNS from
# DHCP and from Tailscale is left alone, so internal names keep resolving.
[Resolve]
DNS=${primary}
FallbackDNS=${fallback}
EOF
chmod 644 "$RESOLVED_DROPIN"
# resolv.conf has to point at the stub for any of this to be consulted. A
# machine where something replaced the symlink with a static file bypasses
# resolved entirely, and the drop-in would have no effect at all.
local target
target="$(readlink -f /etc/resolv.conf 2>/dev/null || true)"
if [[ "$target" != /run/systemd/resolve/*resolv.conf ]]; then
cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/dev/null || true
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
fi
systemctl restart systemd-resolved
else
# No resolved: write resolv.conf directly, and say plainly that anything
# managing the interface may put its own back.
cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/dev/null || true
if lsattr /etc/resolv.conf 2>/dev/null | cut -c1-20 | grep -q i; then
chattr -i /etc/resolv.conf
fi
{
echo "# Written by machine-setup."
local ns
for ns in $primary $fallback; do echo "nameserver ${ns}"; done
} >/etc/resolv.conf
fi
}
# Does name resolution actually work now? Asked after the change rather than
# assumed, because a resolver that does not answer is the one failure that makes
# everything after it look broken for unrelated reasons.
dns_works() { getent hosts one.one.one.one >/dev/null 2>&1 || getent hosts example.com >/dev/null 2>&1; }
# -----------------------------------------------------------------------------
# The address this machine gets
# -----------------------------------------------------------------------------
#
# ── Why a fresh Ubuntu box takes a new IP on every reboot ──
#
# Not a router fault, and not something a static IP is the right answer to.
# systemd-networkd's ClientIdentifier defaults to `duid` — an RFC 4361 client ID
# built from an IAID and a DUID — so the machine introduces itself to DHCP by
# that, and `networkctl status` shows it as "DHCP4 Client ID: IAID:0x…/DUID".
#
# Consumer routers key their leases and their reservations on the MAC address.
# The two never match, so the router does not recognise the machine as a client
# it has seen before and hands out the next free address instead. A reservation
# pinned to the MAC never takes effect, which is the part that makes it look like
# the router is broken.
#
# `dhcp-identifier: mac` in netplan sets ClientIdentifier=mac, and the router then
# sees what it expects. DHCP keeps working, the reservation starts being honoured,
# and nothing is pinned on the machine itself — which is why this is offered ahead
# of a static address rather than beside it.
NETPLAN_DHCP_ID=/etc/netplan/99-machine-setup-dhcp-identifier.yaml
NETPLAN_STATIC=/etc/netplan/99-machine-setup-static.yaml
# What the machine is sending as its DHCP identity: "mac", "duid", or empty when
# the link is not on DHCP at all.
dhcp_client_identifier() {
local iface="$1"
local id
# Everything after the FIRST colon, not field 2 of a colon split: the value is
# itself "IAID:0x…/DUID", so splitting on colons yields "IAID" and the DUID
# test silently answers backwards.
id="$(networkctl status "$iface" 2>/dev/null | awk '/DHCP4 Client ID/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }')"
[[ -z "$id" ]] && return 0
if [[ "$id" == *DUID* ]]; then echo duid; else echo mac; fi
}
# Already asked for by some netplan file?
dhcp_identifier_is_mac() { grep -rqs "dhcp-identifier:[[:space:]]*mac" /etc/netplan/ 2>/dev/null; }
iface_ipv4() { ip -4 addr show "$1" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}/\d+' | head -1; }
iface_gateway() { ip route | awk '/^default/ { print $3; exit }'; }
iface_is_dhcp() { networkctl status "$1" 2>/dev/null | grep -q "DHCP4"; }
# Ask for MAC-based identity, as its own netplan file.
#
# Netplan reads /etc/netplan in lexical order and merges, so a 99- file adds this
# one key to whatever the installer or cloud-init already wrote, without this
# script having to parse and rewrite their YAML.
set_dhcp_identifier_mac() {
local iface="$1"
cat >"$NETPLAN_DHCP_ID" <<EOF
# Written by machine-setup.
#
# Identify to DHCP by MAC rather than by DUID, so the router recognises this
# machine across reboots and any reservation pinned to its MAC is honoured.
# Merged with whatever else is in /etc/netplan; 99- so it is read last.
network:
version: 2
ethernets:
${iface}:
dhcp-identifier: mac
EOF
chmod 600 "$NETPLAN_DHCP_ID"
# 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
}
# Freeze the current lease into a static address.
write_static_netplan() {
local iface="$1" cidr="$2" gateway="$3"
cat >"$NETPLAN_STATIC" <<EOF
# Written by machine-setup. Delete this file and run 'netplan apply' to go back
# to DHCP.
network:
version: 2
ethernets:
${iface}:
dhcp4: false
addresses:
- ${cidr}
routes:
- to: default
via: ${gateway}
EOF
chmod 600 "$NETPLAN_STATIC"
# 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
}
netplan_check() { netplan generate 2>&1; }
# -----------------------------------------------------------------------------
# Firewall
# -----------------------------------------------------------------------------
#
# Last in the run, for the reason the original gave: enabling a firewall is the
# one step that can cut the connection it is being run over. Everything else
# should be done and working first.
#
# ── The bug in the shipped Docker rules ──
#
# ufw-docker-rules.conf hardcodes eth0. Docker publishes ports by writing its own
# iptables rules, which bypass ufw entirely — DOCKER-USER is the hook that lets
# ufw have a say. But every rule in that file names eth0, so on a machine with
# predictable interface names (ens18, enp1s0, and most VPS images) they match
# nothing, the final DROP never fires, and every published container port is open
# to the internet while `ufw status` says active. A firewall that reports itself
# working and is not is worse than none.
UFW_AFTER_RULES=/etc/ufw/after.rules
ufw_is_active() { ufw status 2>/dev/null | grep -q "^Status: active"; }
ufw_allows_ssh() { ufw status 2>/dev/null | grep -qiE "^(22/tcp|OpenSSH)"; }
ufw_has_rule() { ufw status 2>/dev/null | grep -qF "$1"; }
ufw_docker_rules_applied() { grep -q "DOCKER-USER" "$UFW_AFTER_RULES" 2>/dev/null; }
# The shipped rules, with eth0 replaced by the interface this machine actually
# uses. Appended once — the DOCKER-USER marker is the guard.
apply_ufw_docker_rules() {
local src="$1" iface
iface="$(default_iface)"
[[ -n "$iface" ]] || return 1
[[ -r "$src" ]] || return 1
{
echo ""
echo "# Appended by machine-setup. Interface substituted for the one this"
echo "# machine actually uses; the shipped file hardcodes eth0."
sed "s/-i eth0/-i ${iface}/g" "$src"
} >>"$UFW_AFTER_RULES"
}
+304
View File
@@ -0,0 +1,304 @@
#!/bin/bash
# =============================================================================
# machine-setup — distro packages
# =============================================================================
#
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
#
# ── The rule: install what is missing, never touch what is there ──
#
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
# the repository has a newer one. On a machine somebody already uses, that can
# move a version they chose deliberately, and the setup script is the last thing
# that should be doing that behind their back.
#
# So every install here goes through pkg_install, which queries the package
# database first, installs only the subset that is genuinely absent, and prints
# both lists before doing it. A package already present is never named on a
# command line at all.
#
# ── Why per-package-manager lists rather than a translation table ──
#
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
# and some packages are not a package elsewhere at all: apt-transport-https,
# lsb-release and software-properties-common are apt concepts. A canonical-name
# table with per-manager overrides hides both of those behind indirection. A
# plain `case $PM` says what each system actually gets, in one place, and matches
# the shape scripts/setup-old/setup.sh already used.
[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
MACHINE_SETUP_PACKAGES_LOADED=1
# What the last pkg_install/tools_install actually put on the machine, as opposed
# to what it was asked for. Read by the caller to write an honest summary line:
# without it every section reports its whole list as installed, including the
# packages it deliberately left alone.
LAST_INSTALLED=()
LAST_KEPT=()
LAST_SKIPPED=()
# -----------------------------------------------------------------------------
# The sections
# -----------------------------------------------------------------------------
# Core: what this script itself would break without, plus the command-line tools
# that make a machine worth sitting at.
#
# The first six are load-bearing and each is used by a later step — curl fetches
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
# archive, and ca-certificates is what makes any of the fetching work. The rest
# are the environment: nothing calls them, they are here because a box you use
# should have them.
#
# Four entries earn a note.
#
# python3 is not a tool anybody here calls — it is node-gyp's build dependency,
# and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for
# darwin and win32 ONLY, so on Linux its install script always falls through to
# `node-gyp rebuild` and compiles from source. Without python3 that fails, and
# the failure surfaces as a broken terminal sidecar rather than as a missing
# package. build-essential below is the other half of the same requirement.
#
# unattended-upgrades installs updates on a timer with nobody watching. apt only:
# it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman
# has no equivalent at all, so it is not a name to translate. Installing the
# package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades
# is what the apt-daily timers read, and on this host no package owns that file.
# The section makes sure it is there.
#
# fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu
# ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`.
# Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the
# moment it installs, an address failing to log in five times in ten minutes is
# blocked for ten, including yours. That is the point of it and it is worth
# having by default, but it is why it belongs in this comment rather than being
# thought of as one more binary. An existing install with its own jails is
# untouched, because pkg_install never names a package that is already there.
#
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
# distribution's default alongside it. It stays in core because anything that
# compiles a native module needs it, but it is the one to move out first if that
# ever bites.
pkgs_core() {
case "$PM" in
apt)
# 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 brotli 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 brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip brotli 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 — see xcode_clt_*.
# brotli is here because macOS ships the library but not the CLI.
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
;;
esac
}
# -----------------------------------------------------------------------------
# Querying
# -----------------------------------------------------------------------------
# Is this package installed right now?
#
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
# package that was removed but left its config behind — that state would be read
# as "present" and the package would never be reinstalled.
pkg_is_installed() {
case "$PM" in
apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
pacman) pacman -Qi "$1" &>/dev/null ;;
dnf) rpm -q "$1" &>/dev/null ;;
brew) brew list --formula "$1" &>/dev/null ;;
*) return 1 ;;
esac
}
# -----------------------------------------------------------------------------
# Acting
# -----------------------------------------------------------------------------
# Refresh the package index.
#
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
# rather than at each call site, because forgetting one turns an unattended run
# into one that is silently waiting for a keypress.
pkg_refresh() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
pacman) pacman -Sy --noconfirm ;;
dnf) dnf makecache ;;
brew) brew update ;;
esac
}
# What an upgrade would actually move, one package name per line.
#
# Asked before the upgrade runs so the section can name what it is about to
# change rather than asking to be trusted. Needs a refreshed index to be
# accurate, which is why pkg_refresh runs first.
#
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
# which is the same calculation the real run does — as opposed to
# `apt list --upgradable`, which also lists packages that are held back and
# would not actually move.
pkg_upgradable() {
case "$PM" in
apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
brew) brew outdated --quiet 2>/dev/null ;;
esac
}
# Upgrade everything already installed. Separate from pkg_install on purpose:
# this one DOES move versions, so it is a deliberate step rather than something
# that happens as a side effect of installing a tool.
pkg_upgrade_all() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
pacman) pacman -Su --noconfirm ;;
dnf) dnf upgrade -y ;;
brew) brew upgrade ;;
esac
}
# The raw install, with no presence check. Use pkg_install instead.
pkg_install_now() {
case "$PM" in
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
pacman) pacman -S --noconfirm --needed "$@" ;;
dnf) dnf install -y "$@" ;;
brew) brew install "$@" ;;
esac
}
# Announce a section, then install only what is absent from it.
#
# pkg_install "Core packages" $(pkgs_core)
#
# Prints both lists before touching anything, so the run says what it is about to
# do to this machine and what it is deliberately leaving alone. Returns 0 when
# there was nothing to do.
pkg_install() {
local label="$1"
shift
local pkg
local -a missing=() present=()
LAST_SKIPPED=()
for pkg in "$@"; do
if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
done
LAST_INSTALLED=("${missing[@]}")
LAST_KEPT=("${present[@]}")
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.
#
# Takes the NAMES of the two arrays rather than their contents, because a list
# passed by value cannot be told apart from an empty one once it has been through
# word splitting.
#
# Returns non-zero when there is nothing to do, or when the answer was no — in
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
# refusal so the summary does not claim work that never happened.
announce_plan() {
local label="$1"
local -n _present="$2"
local -n _missing="$3"
echo ""
info "${label} — installs what is missing, keeps what you already have"
((${#_present[@]})) && echo " already here: ${_present[*]}"
if ((${#_missing[@]} == 0)); then
echo " to install: nothing, all present"
return 1
fi
echo " to install: ${_missing[*]}"
if ! confirm "Proceed?"; then
warn "skipped by request"
LAST_INSTALLED=()
LAST_SKIPPED=("${_missing[@]}")
return 1
fi
return 0
}
# One summary line describing what a section actually did, from LAST_INSTALLED
# and LAST_KEPT. Call straight after pkg_install or tools_install.
summarise_last() {
local label="$1"
if ((${#LAST_SKIPPED[@]})); then
SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
elif ((${#LAST_INSTALLED[@]} == 0)); then
SUMMARY+=("$label: already present, nothing installed")
elif ((${#LAST_KEPT[@]} == 0)); then
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
else
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
}
+163
View File
@@ -0,0 +1,163 @@
#!/bin/bash
# =============================================================================
# machine-setup — ssh keys and ssh hardening
# =============================================================================
#
# Definitions only, like the other lib/ files.
#
# ── Why the original's hardening did not work, and could not be seen not to ──
#
# It sed'd /etc/ssh/sshd_config directly. Two things make that wrong on a modern
# Ubuntu, and both fail silently:
#
# Ubuntu's sshd_config has `Include /etc/ssh/sshd_config.d/*.conf` on line 12,
# and sshd takes the FIRST value it obtains for a keyword — not the last. Cloud
# images ship 50-cloud-init.conf containing `PasswordAuthentication yes`, which
# is read before anything further down the main file. So the sed edits a line
# sshd never reaches, the script reports "SSH hardened", and password login is
# still on.
#
# It also sed'd ChallengeResponseAuthentication, which OpenSSH renamed to
# KbdInteractiveAuthentication in 8.7. On 24.04 the old name appears nowhere in
# the file, so that substitution matched nothing at all.
#
# So the settings go in a drop-in named to sort FIRST — 01- beats 50-cloud-init —
# which is the only placement that actually wins under first-value-wins.
#
# ── And the reason it is dangerous ──
#
# Step 8 of the original could warn-and-skip (no ssh-keys.zip, or an unrecognised
# menu choice, since its case had no default arm) and still mark itself done.
# Step 9 then disabled password authentication and root login regardless. No key,
# no password, no root: locked out at the next disconnect, on a machine that may
# be in a datacentre. Nothing here disables password authentication without first
# confirming a usable key is in place.
[[ -n "${MACHINE_SETUP_SSH_LOADED:-}" ]] && return 0
MACHINE_SETUP_SSH_LOADED=1
SSHD_DROPIN=/etc/ssh/sshd_config.d/01-machine-setup.conf
# -----------------------------------------------------------------------------
# Keys
# -----------------------------------------------------------------------------
user_ssh_dir() { echo "${USER_HOME}/.ssh"; }
user_authorized_keys() { echo "${USER_HOME}/.ssh/authorized_keys"; }
# How many usable keys the account can log in with.
#
# Counted by asking ssh-keygen to parse the file rather than by counting lines:
# comments, blanks and a half-pasted key all look like lines, and "there is a
# file" is not the same fact as "there is a key that works".
authorized_key_count() {
local file
file="$(user_authorized_keys)"
[[ -r "$file" ]] || return 0
ssh-keygen -l -f "$file" 2>/dev/null | grep -c . || true
}
has_authorized_key() { (($(authorized_key_count) > 0)); }
# Everything about ~/.ssh that has to be true for sshd to use it at all. sshd
# ignores an authorized_keys file that is group- or world-writable, and does so
# silently from the client's point of view — the login just fails.
fix_ssh_permissions() {
local dir
dir="$(user_ssh_dir)"
[[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$dir"
chmod 700 "$dir"
[[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys"
find "$dir" -maxdepth 1 -type f -name 'id_*' ! -name '*.pub' -exec chmod 600 {} +
chown -R "${USERNAME}:$(user_group)" "$dir"
# 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
}
# Add a public key, once. Appending blindly is how authorized_keys ends up with
# the same key four times after four runs.
add_authorized_key() {
local key="$1" file
file="$(user_authorized_keys)"
# Validated before it is stored. A truncated paste or a private key pasted by
# mistake would otherwise sit there looking like a key and never work.
if ! ssh-keygen -l -f /dev/stdin <<<"$key" >/dev/null 2>&1; then
warn "that does not parse as an ssh public key — nothing added"
return 1
fi
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(user_ssh_dir)"
touch "$file"
# Compare on the key body, not the whole line: the trailing comment differs
# between machines and is not part of the identity.
local body
body="$(awk '{print $2}' <<<"$key")"
if [[ -n "$body" ]] && grep -qF "$body" "$file" 2>/dev/null; then
info " that key is already authorised"
return 0
fi
printf '%s\n' "$key" >>"$file"
fix_ssh_permissions
}
# Generate a keypair for the account and authorise it.
generate_user_key() {
local comment="$1" key
key="$(user_ssh_dir)/id_ed25519"
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(user_ssh_dir)"
sudo -u "$USERNAME" ssh-keygen -t ed25519 -C "$comment" -f "$key" -N "" >/dev/null
add_authorized_key "$(cat "${key}.pub")"
}
# -----------------------------------------------------------------------------
# Hardening
# -----------------------------------------------------------------------------
# What sshd actually resolves a setting to, across the main file and every
# drop-in. The only honest way to report the current state: reading the config
# files tells you what is written, not what wins.
sshd_effective() { sshd -T 2>/dev/null | awk -v k="${1,,}" 'tolower($1) == k { print $2; exit }'; }
# Write the drop-in, verify it, and only then reload.
#
# Returns non-zero without touching the running daemon if the result would not
# parse — the alternative is a config that sshd refuses, at which point it will
# not come back after a restart and the machine has no ssh at all.
harden_sshd() {
local backup=""
[[ -f "$SSHD_DROPIN" ]] && backup="$(mktemp)" && cp "$SSHD_DROPIN" "$backup"
install -d -m 0755 /etc/ssh/sshd_config.d
cat >"$SSHD_DROPIN" <<'EOF'
# Written by machine-setup.
#
# Named 01- deliberately: sshd uses the FIRST value it obtains for a keyword, and
# Ubuntu includes this directory from the top of sshd_config. A file sorting
# after 50-cloud-init.conf would be read too late to override it.
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
EOF
chmod 644 "$SSHD_DROPIN"
if ! sshd -t 2>/dev/null; then
warn "sshd rejected the new configuration — reverting, nothing changed"
if [[ -n "$backup" ]]; then cp "$backup" "$SSHD_DROPIN"; else rm -f "$SSHD_DROPIN"; fi
[[ -n "$backup" ]] && rm -f "$backup"
return 1
fi
[[ -n "$backup" ]] && rm -f "$backup"
# Reload rather than restart: existing sessions keep their sshd, so the
# connection this is being run over is not the thing being experimented on.
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || systemctl restart ssh
}
+556
View File
@@ -0,0 +1,556 @@
#!/bin/bash
# =============================================================================
# machine-setup — system configuration
# =============================================================================
#
# Definitions only, like the other lib/ files. Locale, and the system-level
# settings that follow it.
[[ -n "${MACHINE_SETUP_SYSTEM_LOADED:-}" ]] && return 0
MACHINE_SETUP_SYSTEM_LOADED=1
# -----------------------------------------------------------------------------
# Locale
# -----------------------------------------------------------------------------
#
# Two separate facts, and the original only handled one of them:
#
# what a new login shell is told to use — LANG in /etc/default/locale
# whether that locale actually exists — whether it has been generated
#
# Setting LANG to a locale that was never generated is the state that produces
# "setlocale: LC_ALL: cannot change locale" on every ssh login and every perl
# invocation. Both are checked, so the step can say which one is missing.
# What a new login shell will be handed, or empty if nothing is configured.
locale_current() {
if [[ -r /etc/default/locale ]]; then
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/default/locale
elif [[ -r /etc/locale.conf ]]; then
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/locale.conf
fi
}
# Has this locale actually been built?
#
# `locale -a` prints en_US.utf8 where the configuration spells it en_US.UTF-8,
# so both sides are folded to lower case with the dashes removed before
# comparing. A literal match here would report a perfectly good locale missing.
locale_is_generated() {
local want="${1,,}"
want="${want//-/}"
locale -a 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -d '-' | grep -qx "$want"
}
locale_set() {
local want="$1"
local escaped="${want//./\\.}"
case "$PM" in
apt)
# locale-gen comes from the `locales` package, which minimal images and
# most cloud base images do not ship. Without this the step fails with
# "locale-gen: command not found" halfway through.
if ! pkg_is_installed locales; then
info " installing locales, which provides locale-gen"
pkg_install_now locales
fi
# Uncomment it if it is there commented out, add it if it is absent.
# Editing the file rather than passing the name to locale-gen is what makes
# it survive: a locale generated by argument alone is lost the next time
# anything regenerates from /etc/locale.gen.
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
elif ! grep -qE "^${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
# The charset is the part after the dot: en_US.UTF-8 -> UTF-8
echo "${want} ${want##*.}" >>/etc/locale.gen
fi
locale-gen
update-locale LANG="$want"
;;
pacman)
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
fi
locale-gen
echo "LANG=${want}" >/etc/locale.conf
;;
dnf)
# No locale.gen here — the locales come prebuilt in langpack packages.
pkg_install_now "glibc-langpack-${want%%_*}"
localectl set-locale "LANG=${want}"
;;
brew)
warn "macOS has no system locale to set — it is per-user, from the terminal's settings"
return 1
;;
esac
}
# -----------------------------------------------------------------------------
# Swap
# -----------------------------------------------------------------------------
SWAPFILE=/swapfile
# Rounded to nearest, not floored: a 4 GiB swapfile is 4194300 kB, which floors
# to 3 and reads as though a gigabyte went missing. Same for RAM, where 3.7 GiB
# reporting as "3G" makes the sizing tiers look wrong.
kb_to_gb_rounded() { echo $((($1 + 524288) / 1048576)); }
# Total active swap in GiB, 0 if there is none.
#
# From /proc/meminfo rather than by grepping swapon's output for a slash, which
# is what the original did to spot a swap FILE — that test reports no swap at all
# on a machine using zram or a swap partition, and the step would then add a
# swapfile beside perfectly good swap.
swap_active_gb() { kb_to_gb_rounded "$(awk '/^SwapTotal:/ { print $2 }' /proc/meminfo)"; }
ram_gb() { kb_to_gb_rounded "$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)"; }
# Free space on the filesystem that would hold the swapfile, in GiB. Floored
# rather than rounded, deliberately: this one decides how much to allocate, and
# rounding up invents space that is not there.
disk_free_gb() { echo $(($(df -Pk "$(dirname "$SWAPFILE")" | awk 'NR == 2 { print $4 }') / 1024 / 1024)); }
# How much swap this machine should have.
#
# The tiers are the original's. What is new is that the answer is capped by what
# is actually on the disk — the original would try to fallocate 8G on a VPS with
# 4G free, fail, and take the run down with it.
swap_recommended_gb() {
local ram size
ram="$(ram_gb)"
if ((ram <= 2)); then
size=2
elif ((ram <= 8)); then
size=4
else
size=8
fi
# Leave a few gigabytes behind. A swapfile that fills the disk is a worse
# problem than no swapfile.
local room=$(($(disk_free_gb) - 5))
((room < size)) && size="$room"
((size < 1)) && size=0
echo "$size"
}
# How eagerly the kernel swaps, by role.
#
# 10 on a server: swapping is the emergency valve, not a routine, and the cost of
# a page fault on a request path is latency somebody is waiting for. A desktop is
# the opposite case — swapping out an application nobody has touched in an hour
# is exactly what you want — so dev keeps the kernel default of 60.
swappiness_for_role() { if is_server; then echo 10; else echo 60; fi; }
swap_create() {
local gb="$1"
# fallocate is instant but produces a file some filesystems refuse to swap on
# (btrfs without the right attributes, zfs at all). dd is slow and always
# works, so it is the fallback rather than the default.
if ! fallocate -l "${gb}G" "$SWAPFILE" 2>/dev/null; then
info " fallocate is not usable here — writing the file with dd, which is slower"
dd if=/dev/zero of="$SWAPFILE" bs=1M count=$((gb * 1024)) status=none
fi
chmod 600 "$SWAPFILE"
mkswap "$SWAPFILE" >/dev/null
swapon "$SWAPFILE"
grep -qs "^${SWAPFILE}[[:space:]]" /etc/fstab || echo "${SWAPFILE} none swap sw 0 0" >>/etc/fstab
}
# Written as a drop-in rather than by rewriting /etc/sysctl.conf in place. The
# original sed'd that file, which means the setting is tangled up with whatever
# else lives there and is invisible to anyone looking for what this script did.
swappiness_set() {
echo "vm.swappiness=$1" >/etc/sysctl.d/99-machine-setup-swappiness.conf
sysctl -q -w "vm.swappiness=$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.
return 0
}
# -----------------------------------------------------------------------------
# Emergency disk ballast
# -----------------------------------------------------------------------------
#
# The same idea as swap, one layer down. Swap is the valve for memory pressure;
# this is the valve for disk pressure.
#
# A junk file holding no data, sized at 10% of free disk. Its only job is to be
# deleted when the filesystem is about to fill, buying enough headroom to log in
# and clean up properly instead of meeting a wedged box — Docker, journald and
# postgres all misbehave badly at 100% full, and some of them do not recover on
# their own.
#
# A one-shot valve: once spent, it has to be recreated.
#
# ── Moved out of the user's home ──
#
# The original put the checker in $USER_HOME/.local/bin and ran it from a root
# cron. A root cron executing a script inside a directory its owner can write is
# a privilege escalation waiting to be noticed — moot on a box where that user
# already has passwordless sudo, but wrong, and not something to carry forward.
# Both the script and the file now live in root-owned system paths.
# Where it goes is asked rather than decided. A ballast only protects the
# filesystem it is ON — the checker measures its own directory — so the choice is
# also a choice of which mount is being protected. Defaults to the user's home,
# which on most machines is the same filesystem as / and is the easiest place to
# find it again months later.
BALLAST_FILE=""
BALLAST_CHECKER=/usr/local/sbin/emergency-disk-check
BALLAST_CRON=/etc/cron.d/emergency-disk-check
BALLAST_THRESHOLD=10
BALLAST_NAME=emergency-disk-ballast.bin
ballast_exists() { [[ -n "$BALLAST_FILE" && -f "$BALLAST_FILE" ]]; }
ballast_size_human() { du -h "$BALLAST_FILE" 2>/dev/null | cut -f1; }
# The nearest directory that exists, walking up. A path being chosen for the
# ballast does not mean anything has created it yet, and df cannot measure a
# directory that is not there.
existing_ancestor() {
local dir="$1"
while [[ ! -d "$dir" && "$dir" != "/" ]]; do dir="$(dirname "$dir")"; done
echo "$dir"
}
# Free space in KiB on whichever filesystem would hold this path.
ballast_free_kb() { df -Pk "$(existing_ancestor "$1")" | awk 'NR == 2 { print $4 }'; }
ballast_create() {
local mb="$1"
mkdir -p "$(dirname "$BALLAST_FILE")"
# fallocate reserves real blocks. A sparse file made with truncate would
# reserve nothing and free nothing when deleted, which is the entire point.
if ! fallocate -l "${mb}M" "$BALLAST_FILE" 2>/dev/null; then
info " fallocate is not usable here — writing with dd, which is slower"
dd if=/dev/zero of="$BALLAST_FILE" bs=1M count="$mb" status=none
fi
chmod 600 "$BALLAST_FILE"
}
ballast_install_checker() {
mkdir -p "$(dirname "$BALLAST_FILE")"
cat >"$BALLAST_CHECKER" <<CHECKER
#!/usr/bin/env bash
#
# Emergency disk ballast checker. Installed by machine-setup.
#
# Deletes the pre-allocated ballast file when free space falls below the
# threshold, buying headroom to log in and clean up. Run with --status to see
# where things stand without changing anything.
set -euo pipefail
BALLAST="${BALLAST_FILE}"
THRESHOLD=${BALLAST_THRESHOLD}
TAG="emergency-disk"
# Walk up to a directory that exists. The ballast's own directory is gone if
# somebody cleaned up after the valve was spent, and df failing under
# \`set -e\` would make cron mail an error every ten minutes.
MOUNT_DIR="\$(dirname "\$BALLAST")"
while [[ ! -d "\$MOUNT_DIR" && "\$MOUNT_DIR" != "/" ]]; do MOUNT_DIR="\$(dirname "\$MOUNT_DIR")"; done
USE_PCT="\$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { gsub(/%/, "", \$5); print \$5 }')"
FREE_PCT=\$((100 - USE_PCT))
if [[ "\${1:-}" == "--status" ]]; then
echo "Mount: \$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { print \$6 }')"
echo "Free: \${FREE_PCT}% (threshold: \${THRESHOLD}%)"
if [[ -f "\$BALLAST" ]]; then
echo "Ballast: present, \$(du -h "\$BALLAST" | cut -f1) — \$BALLAST"
else
echo "Ballast: ABSENT (already spent) — \$BALLAST"
fi
exit 0
fi
# Everything urgent goes through here, so there is one place to add a second
# channel later. Today it is syslog only, which means the message is in the
# journal and nowhere else — nobody finds out until they go looking, which is
# exactly the wrong moment. Push, mail or Officer's own notify sidecar hook in
# here.
notify() {
logger -t "\$TAG" -p user.crit "\$1"
# A copy on stderr as well, so a human running this by hand sees it.
echo "\$1" >&2
}
((FREE_PCT < THRESHOLD)) || exit 0
if [[ -f "\$BALLAST" ]]; then
FREED="\$(du -h "\$BALLAST" | cut -f1)"
rm -f "\$BALLAST"
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — deleted ballast, reclaimed \${FREED}. CLEAN UP NOW: this valve is spent."
else
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — ballast already spent, no headroom left to reclaim."
fi
CHECKER
chown root:root "$BALLAST_CHECKER"
chmod 755 "$BALLAST_CHECKER"
cat >"$BALLAST_CRON" <<CRON
# Emergency disk ballast — deletes the ballast file if free space drops below ${BALLAST_THRESHOLD}%.
# Installed by machine-setup. Check status: ${BALLAST_CHECKER} --status
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/10 * * * * root ${BALLAST_CHECKER}
CRON
chmod 644 "$BALLAST_CRON"
}
# -----------------------------------------------------------------------------
# earlyoom
# -----------------------------------------------------------------------------
#
# What happens when swap runs out too.
#
# The kernel's own OOM killer waits until allocation genuinely fails, and by then
# the machine has usually spent minutes thrashing — unresponsive, ssh refusing to
# connect, nothing to do but reset it. earlyoom watches free memory and kills the
# largest consumer while there is still enough left to stay reachable.
earlyoom_is_active() { systemctl is-active --quiet earlyoom 2>/dev/null; }
earlyoom_install() {
pkg_is_installed earlyoom || pkg_install_now earlyoom
systemctl enable --now earlyoom >/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.
return 0
}
# -----------------------------------------------------------------------------
# Resource limits
# -----------------------------------------------------------------------------
#
# inotify watches: how many files one user can have the kernel watching. The
# stock limit is small enough that one file watcher walking
# node_modules. Every watcher on the machine draws from the same pool.
#
# The failure is silent, which is what makes it worth setting in advance: nothing
# errors, the watcher simply stops noticing changes. Hot reload goes quiet, a
# build stops rebuilding, and the reason is never on screen.
#
# Mostly a development concern, but not exclusively — anything running `bun
# --watch` or serving a file browser is a watcher too.
INOTIFY_WATCHES=524288
INOTIFY_INSTANCES=1024
inotify_current_watches() { sysctl -n fs.inotify.max_user_watches 2>/dev/null || echo 0; }
inotify_raise() {
cat >/etc/sysctl.d/99-machine-setup-inotify.conf <<EOF
# Raised by machine-setup: the 8192 default is exhausted by file watchers, and
# the failure is silent — the watcher stops noticing changes without an error.
fs.inotify.max_user_watches=${INOTIFY_WATCHES}
fs.inotify.max_user_instances=${INOTIFY_INSTANCES}
EOF
sysctl -q -w "fs.inotify.max_user_watches=${INOTIFY_WATCHES}"
sysctl -q -w "fs.inotify.max_user_instances=${INOTIFY_INSTANCES}"
# 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
}
# -----------------------------------------------------------------------------
# Sleep and suspend
# -----------------------------------------------------------------------------
#
# A server that suspends is a server that is off. The machine stops answering,
# and on a box with no keyboard attached there is nothing to wake it — which is
# the whole failure: it looks like a crash, and the only fix is physical.
#
# Two independent mechanisms, and both have to be dealt with:
#
# the sleep targets what suspend/hibernate hang off. Masking them means
# nothing can trigger a sleep, including a stray
# `systemctl suspend`
# logind's handlers what closing a lid, pressing the power button or going
# idle DO. These are what a desktop image sets, and they
# act before anything reaches a target
#
# Written as a drop-in rather than by editing logind.conf in place, so what this
# script set is one file that can be read or deleted on its own.
SLEEP_TARGETS=(sleep.target suspend.target hibernate.target hybrid-sleep.target)
LOGIND_DROPIN=/etc/systemd/logind.conf.d/99-machine-setup.conf
# The value actually in force for a logind setting, or empty for the default.
# Drop-ins override the main file and later ones override earlier, so the last
# match wins — reading only logind.conf would miss a setting made by a drop-in
# and report the machine as unconfigured when it is not.
logind_effective() {
local key="$1"
{
[[ -r /etc/systemd/logind.conf ]] && grep -hE "^${key}=" /etc/systemd/logind.conf
for f in /etc/systemd/logind.conf.d/*.conf; do
[[ -r "$f" ]] && grep -hE "^${key}=" "$f"
done
} 2>/dev/null | tail -1 | cut -d= -f2-
}
sleep_targets_masked() {
local t
for t in "${SLEEP_TARGETS[@]}"; do
[[ "$(systemctl is-enabled "$t" 2>/dev/null)" == "masked" ]] || return 1
done
}
# What the machine should be set to. RuntimeDirectorySize is deliberately NOT
# here: the original set it to 10% alongside these, which is both unrelated to
# sleeping — it is the size of /run — and systemd's own default, so the line
# never did anything.
logind_wanted() {
cat <<'EOF'
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
HandlePowerKey=ignore
IdleAction=none
EOF
}
# Is every wanted setting already in force?
logind_is_configured() {
local line key value
while IFS= read -r line; do
key="${line%%=*}"
value="${line#*=}"
[[ "$(logind_effective "$key")" == "$value" ]] || return 1
done < <(logind_wanted)
}
disable_sleep() {
systemctl mask "${SLEEP_TARGETS[@]}" >/dev/null 2>&1
mkdir -p "$(dirname "$LOGIND_DROPIN")"
{
echo "# Written by machine-setup: this machine is a server and must not sleep."
echo "[Login]"
logind_wanted
} >"$LOGIND_DROPIN"
# Only restart when something actually changed — a needless restart of logind
# disturbs live sessions, and this step runs on every pass.
systemctl restart systemd-logind
# 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
}
# -----------------------------------------------------------------------------
# Boot hang
# -----------------------------------------------------------------------------
#
# systemd-networkd-wait-online blocks boot until the network is up. Where
# systemd-networkd actually manages the network — a server or cloud image, via
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
#
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
# but the wait-online unit is still enabled and waits for a link that will never
# be configured. It gives up after its full timeout, on every boot.
#
# So the fix is masking one unit, and only on the second stack. Do NOT
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
# networkd-managed box that brings it up with no network on the next boot, no
# ssh, and nothing but the provider's rescue console.
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
network_manager_name() {
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
echo "NetworkManager"
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
echo "systemd-networkd"
else
echo "neither — unclear"
fi
}
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
# Anything else, including "cannot tell", is left alone.
wait_online_is_spurious() {
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
}
# What that unit actually cost on this boot, straight from systemd's own
# accounting. Worth printing rather than asking somebody whether boot "feels
# slow": the answer is either 14ms or two minutes, and there is no arguing with
# it. Empty when the unit did not run.
wait_online_boot_time() {
systemd-analyze blame 2>/dev/null | awk -v u="$WAIT_ONLINE_UNIT" '$NF == u { $NF = ""; sub(/[[:space:]]+$/, ""); print; exit }'
}
# Returns 0 whatever happens — see swappiness_set for why an optional step must
# not be able to abort the run.
mask_wait_online() {
systemctl mask --now "$WAIT_ONLINE_UNIT" >/dev/null 2>&1
return 0
}
# -----------------------------------------------------------------------------
# Timezone
# -----------------------------------------------------------------------------
# The shortlist offered at the prompt. Any zone name can be typed instead, so
# this is a convenience rather than a limit.
TZ_OPTIONS=(UTC Europe/Lisbon Europe/London Europe/Berlin Europe/Stockholm US/Eastern US/Pacific Asia/Tokyo)
# What the machine is set to now.
#
# Three sources because they disagree about availability rather than about the
# answer: timedatectl is absent without systemd (containers, WSL), /etc/timezone
# is Debian-specific, and the /etc/localtime symlink is the one thing that is
# always true when any of them are.
timezone_current() {
if command -v timedatectl &>/dev/null && timedatectl show -p Timezone --value 2>/dev/null | grep -q .; then
timedatectl show -p Timezone --value 2>/dev/null
elif [[ -r /etc/timezone ]]; then
tr -d '[:space:]' </etc/timezone
elif [[ -L /etc/localtime ]]; then
readlink -f /etc/localtime | sed 's|.*/zoneinfo/||'
fi
}
# Checked against the zoneinfo database before it is used. `timedatectl
# set-timezone` on a name that does not exist fails, and under `set -e` that
# takes the whole run down over a typo.
timezone_is_valid() { [[ -f "/usr/share/zoneinfo/$1" ]]; }
timezone_set() {
local tz="$1"
case "$PM" in
brew) systemsetup -settimezone "$tz" >/dev/null ;;
*)
# timedatectl where there is a systemd to talk to; the files directly
# otherwise, which is the same thing it would have written.
if command -v timedatectl &>/dev/null && [[ "$IS_WSL" != true ]]; then
timedatectl set-timezone "$tz"
else
ln -sf "/usr/share/zoneinfo/${tz}" /etc/localtime
echo "$tz" >/etc/timezone
fi
;;
esac
}
@@ -0,0 +1,310 @@
#!/bin/bash
# =============================================================================
# machine-setup — Tailscale
# =============================================================================
#
# Definitions only, like the other lib/ files.
#
# ── Why this runs early ──
#
# It is a second way into the machine. The section that can lock you out is SSH
# hardening, and everything after this one can break networking in some smaller
# way; having the tailnet up first means a mistake is recoverable rather than a
# trip to a rescue console.
#
# ── Why it matters to Officer specifically ──
#
# The platform's CLAUDE.md is explicit: the perimeter IS the tailnet. Origin
# checking was removed outright on 2026-08-13 because the tailnet stands in its
# place, so a valid token plus the tailnet IS the lock — not one layer of two.
# An Officer install with no tailnet is missing the half the design assumes.
#
# ── Why the original hung ──
#
# It passed --authkey unconditionally, and its prompt accepted an empty answer.
# `tailscale up --authkey ""` falls back to interactive login: it prints a URL and
# blocks, with no timeout, forever. Nothing here passes an empty key, every call
# has a timeout, and the state is read before anything is run.
[[ -n "${MACHINE_SETUP_TAILSCALE_LOADED:-}" ]] && return 0
MACHINE_SETUP_TAILSCALE_LOADED=1
TS_EXIT_SYSCTL=/etc/sysctl.d/99-tailscale-exit.conf
TS_DISPATCHER=/etc/networkd-dispatcher/routable.d/50-tailscale-exit
# Printed only when asked for. The section leads with the question rather than
# with ten lines of explanation: somebody who runs Tailscale already does not need
# to be told what it is, and somebody who does not can type ?.
tailscale_help() {
echo " Tailscale is a private network between your own machines, over"
echo " WireGuard. Every device you enrol gets a stable 100.x address and"
echo " can reach every other, wherever they are — through NAT, across"
echo " providers, without either end having a public address."
echo ""
echo " Nothing is published to the open internet to make that work: no"
echo " port forwarding, no exposed ports, no holes in the firewall."
echo ""
echo " For Officer it is not a convenience. The platform is built assuming"
echo " the tailnet IS the perimeter, and there is no origin checking behind"
echo " it — a valid token plus the tailnet is the whole lock. Without the"
echo " tailnet you are running with half of it missing."
echo ""
echo " It is installed at this point in the run, before anything that can"
echo " lock you out of the machine, so there is always a second way in."
}
# The menu itself, in a function because it is shown twice — once to ask, and
# again after ? has printed the long answer, so the reader is not dropped back at
# a bare prompt having forgotten what the options were.
tailscale_network_menu() {
info "Which network should this machine join?"
echo ""
echo " [1] set up your own network — offscale"
echo " Your own coordination server. The protocol on the wire is"
echo " Tailscale's and the encryption is WireGuard's; offscale changes"
echo " neither — it runs headscale's open-source code. What changes is"
echo " the work: managed from an app rather than a terminal, and"
echo " enrolling a device is a link and a tap."
echo " offscale — just like headscale, and just like Tailscale's own"
echo " service — needs to run on a publicly reachable server of its"
echo " own. A small VPS is enough. Not this machine, not behind a home"
echo " router: every device that joins has to find it, including phones"
echo " on mobile data. The only difference from option 3 is who runs"
echo " that server."
echo " Follow that setup through first, then come back here with its"
echo " address and a key."
echo " https://officer.dev/infrastructure/offscale.html#install"
echo ""
echo " [2] use a network you already run — headscale or offscale"
echo " You already have a coordination server somewhere. Point this"
echo " machine at it and it joins that network alongside the rest."
echo ""
echo " [3] the easy route — tailscale.com"
echo " Tailscale runs the coordination for you. Nothing to host and"
echo " nothing to maintain, free for personal use; the trade is that"
echo " the list of your machines lives with them."
echo ""
echo " [4] no private network at all"
echo " This machine is reached over the open internet, or not at all."
echo " Everything the tailnet was doing becomes yours to do."
echo ""
echo " [?] what are tailscale, headscale and offscale?"
echo ""
}
# The long answer, printed when somebody types ?. Covers all three names,
# because the menu offers all three and two of them are not words anyone outside
# this project would know.
tailscale_networks_help() {
echo " Tailscale, headscale and offscale are three answers to one question:"
echo " who keeps the list of your machines and hands out the keys they use"
echo " to find each other."
echo ""
echo " The network itself is the same in all three cases. Machines talk"
echo " directly to each other over WireGuard, encrypted end to end. What"
echo " differs is only the coordination server — the thing that knows which"
echo " machines are yours. It never carries your traffic."
echo ""
echo " TAILSCALE"
echo " The company's own coordination server. Nothing to run, nothing to"
echo " maintain, free for personal use. You sign in with an existing"
echo " identity and your machines appear in their admin console."
echo " The trade is that the list of your machines lives with them."
echo ""
echo " HEADSCALE"
echo " An open-source coordination server you run yourself. The same"
echo " Tailscale clients connect to it, so the machines behave identically;"
echo " the difference is that nobody else holds the list. The cost is that"
echo " it is now a service you host, and it needs to be reachable."
echo ""
echo " OFFSCALE"
echo " Our own distribution of headscale, which is to say: headscale. The"
echo " protocol on the wire is Tailscale's and the encryption is"
echo " WireGuard's, and offscale changes neither — it runs the same"
echo " open-source project. A machine on an offscale network behaves"
echo " exactly as it would on either of the other two. There is no offscale"
echo " protocol to be locked into, because there is no offscale protocol."
echo ""
echo " Clients: stock Tailscale on computers. On iPhone, iPad and Android"
echo " there is our own app — the Tailscale client, our branding, and one"
echo " real difference: it takes an invite from the server directly. That"
echo " is the part of running headscale people give up at, because the"
echo " official app has to be talked into using a server that is not"
echo " Tailscale's. Desktop apps of our own are not there yet; on a"
echo " computer you point the official client at your own server."
echo ""
# Where it runs matters more than how it installs, and is the thing people
# get wrong: a coordination server at home is unreachable from exactly the
# devices a private network exists to reach.
echo " Where it runs: on a publicly reachable server of its own — a small"
echo " VPS is enough. Not on this machine, and not behind a home router."
echo " Every device that joins has to find it, including phones on mobile"
echo " data and laptops in other buildings, so it needs an address that"
echo " resolves from anywhere."
echo ""
echo " This is not something offscale asks for and the others do not. It"
echo " is true of headscale, and it is true of Tailscale — their"
echo " coordination server is publicly reachable too, they simply run it"
echo " for you. That is the whole of the difference between choosing"
echo " option 3 and choosing to host it yourself."
echo ""
echo " What it does that plain headscale does not:"
echo " · installs in one command on that server, certificates included"
echo " · health, logs, restarts and access policies from the app,"
echo " instead of a config file and a CLI"
echo " · enrolling a device is a link and a tap — the key is minted"
echo " and handed over for you"
echo " · several networks at once, and services reachable across them"
echo ""
echo " https://officer.dev/infrastructure/offscale.html"
echo ""
echo " FOR OFFICER"
echo " Whichever you pick, the tailnet is what Officer treats as its"
echo " perimeter, and it is not one layer of two — there is no origin"
echo " checking behind it. Installed at this point in the run,"
echo " before anything that can lock you out, so there is always a second"
echo " way in."
echo ""
echo " Officer also administers it. Its Headscale app talks to headscale"
echo " and offscale servers alike: register as many as you run, see which"
echo " are actually up — each is probed, not remembered — and switch"
echo " between them. On whichever is active you get the nodes, the users,"
echo " the pre-auth keys, the invites and the ACL policy, with an"
echo " assistant for writing it, plus a console and diagnostics. So the"
echo " server this section sets up is managed from the same place as"
echo " everything else on this machine, rather than over ssh and a CLI."
}
tailscale_is_installed() { command -v tailscale &>/dev/null; }
# NeedsLogin, Running, Stopped, NoState… Read before acting, because the original's
# failure was running `up` blindly against a node that was already up.
tailscale_state() {
tailscale status --json 2>/dev/null | awk -F'"' '/"BackendState"/ { print $4; exit }'
}
tailscale_ip() { tailscale ip -4 2>/dev/null | head -1; }
# Which control plane this node is talking to. Empty means Tailscale's own.
tailscale_control_url() {
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
}
# 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.
#
# Passed explicitly even when it is the default, because `tailscale up` with no
# --login-server keeps whatever ControlURL is already stored. On a node already
# pointed at a self-hosted server, choosing "the easy route" would otherwise
# leave it exactly where it was — no error, no message, wrong answer.
TS_DEFAULT_CONTROL_URL="https://controlplane.tailscale.com"
# Moving a node between coordination servers is not something `up` will do while
# it is logged in to one. Logging out first is the documented way, and doing it
# unasked would be worse than saying so.
# What choosing "no private network" actually hands you, said before it is
# chosen rather than discovered afterwards.
tailscale_none_warning() {
echo " Without a tailnet, everything it was doing becomes yours:"
echo ""
echo " · Anything you want to reach remotely has to be published to the"
echo " open internet deliberately, and kept closed otherwise."
echo " · TLS certificates are yours to obtain and to keep renewed."
echo " · Every exposed service needs its own authentication, because"
echo " there is no longer a network boundary in front of it."
echo " · This machine will be found. Anything listening on a public"
echo " address is scanned within minutes and attacked continuously."
echo ""
echo " For Officer specifically, this removes a layer that cannot be put"
echo " back from a setting:"
echo ""
echo " There is no origin checking in the platform. It was removed"
echo " because the tailnet is the perimeter, so a valid token plus the"
echo " tailnet is the entire lock. With no tailnet, the token is the"
echo " only thing left. Put an HTTPS reverse proxy in front of the"
echo " platform and restrict who can reach it at the network layer."
}
tailscale_needs_logout() {
local current="$1" target="$2"
[[ -n "$current" && -n "$target" && "$current" != "$target" ]]
}
# Routing has to be on before this machine can forward anyone else's packets,
# whether as an exit node or as a subnet router. Written as a drop-in so it is
# visible as this script's doing.
enable_ip_forwarding() {
cat >"$TS_EXIT_SYSCTL" <<'EOF'
# Written by machine-setup: required to forward traffic for other tailnet nodes,
# as an exit node or as a subnet router.
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sysctl --system >/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.
return 0
}
# UDP GRO forwarding, which Tailscale documents as roughly doubling throughput on
# a node that forwards for others. Applied on every routable event rather than
# once, because the settings are per-interface and do not survive the link going
# down and back up.
install_exit_node_tuning() {
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
mkdir -p "$(dirname "$TS_DISPATCHER")"
cat >"$TS_DISPATCHER" <<'EOF'
#!/usr/bin/env bash
# Written by machine-setup. NIC offload settings for a Tailscale exit node or
# subnet router — Tailscale's own recommendation for forwarding throughput.
set -Eeuo pipefail
IF="${IFACE:-}"
if [[ -z "${IF}" ]]; then
IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')"
fi
[[ -n "${IF}" ]] || exit 0
command -v ethtool >/dev/null 2>&1 || exit 0
ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true
ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true
ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true
exit 0
EOF
chmod 755 "$TS_DISPATCHER"
systemctl enable --now networkd-dispatcher >/dev/null 2>&1 || true
# And once now, for the interface that is already up.
IFACE="$(default_iface)" bash "$TS_DISPATCHER" >/dev/null 2>&1 || true
# 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
}
# The LAN this machine sits on, as a CIDR — the useful default for a subnet
# router, and the number nobody remembers offhand.
lan_cidr() {
local iface
iface="$(default_iface)"
ip -4 route show dev "$iface" 2>/dev/null |
awk '$1 ~ /\// && $1 !~ /^default/ { print $1; exit }'
}
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# =============================================================================
# machine-setup — command-line tools that do not come from the distribution
# =============================================================================
#
# Definitions only, like the other lib/ files.
#
# These four were buried inside "System Update & Essentials", after the package
# install and with no announcement, so a run appeared to be installing system
# packages and then started downloading tarballs and printing a shell tutorial.
# They are their own concern: upstream binaries, fetched from upstream, on their
# own release cadence.
#
# Each one is checked before it is fetched. The original re-ran every installer
# on every run — which is how a machine that already had starship got it
# reinstalled, along with its "add this to your ~/.zshrc" instructions, which we
# do not want because this script writes the shell config itself.
[[ -n "${MACHINE_SETUP_TOOLS_LOADED:-}" ]] && return 0
MACHINE_SETUP_TOOLS_LOADED=1
# The set installed on every machine, in the order they are fetched.
#
# 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
# 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() {
case "$1" in
lazydocker) echo lazydocker ;;
lazygit) echo lazygit ;;
starship) echo starship ;;
*) echo "$1" ;;
esac
}
tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
# -----------------------------------------------------------------------------
# The installers
# -----------------------------------------------------------------------------
tool_install_lazydocker() {
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
DIR=/usr/local/bin bash
}
# The one that was actually broken on arm64: the asset name was hardcoded to
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
# two spellings ARCH uses, hence the mapping.
tool_install_lazygit() {
local version asset url
case "$ARCH" in
amd64) asset="x86_64" ;;
arm64) asset="arm64" ;;
esac
version="$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest | jq -r '.tag_name')"
# Strip only the leading v. The original used `tr -d 'v'`, which deletes every
# v in the string and would mangle any tag that had one anywhere else.
version="${version#v}"
[[ -n "$version" ]] || {
warn "could not read the latest lazygit version — skipping"
return 0
}
url="https://github.com/jesseduffield/lazygit/releases/download/v${version}/lazygit_${version}_Linux_${asset}.tar.gz"
curl -fsSLo /tmp/lazygit.tar.gz "$url"
tar -C /usr/local/bin -xzf /tmp/lazygit.tar.gz lazygit
rm -f /tmp/lazygit.tar.gz
}
# Quiet on purpose. The installer ends by printing how to add starship to bash,
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
# already writes the zsh config itself. Errors still come through.
tool_install_starship() {
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
}
# -----------------------------------------------------------------------------
# Acting
# -----------------------------------------------------------------------------
# Announce the section, then fetch only what is absent — same contract and same
# output shape as pkg_install, so the two read alike in a transcript.
tools_install() {
local label="$1"
shift
local tool
local -a missing=() present=()
LAST_SKIPPED=()
for tool in "$@"; do
if tool_is_installed "$tool"; then present+=("$tool"); else missing+=("$tool"); fi
done
LAST_INSTALLED=("${missing[@]}")
LAST_KEPT=("${present[@]}")
announce_plan "$label" present missing || return 0
for tool in "${missing[@]}"; do
info " installing ${tool}..."
"tool_install_${tool}"
done
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
# UFW Docker compatibility rules
# Append these to /etc/ufw/after.rules (after the existing COMMIT)
# Blocks all external access to Docker-published ports except:
# - Trusted IPs (add your own)
# - Explicitly allowed public ports (80, 443)
# - Docker internal and loopback traffic
*filter
:DOCKER-USER - [0:0]
# Allow established/related
-A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
# Allow loopback
-A DOCKER-USER -i lo -j RETURN
# Allow Docker internal networks
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
# Allow trusted external sources (add more lines as needed)
# -A DOCKER-USER -s <TRUSTED_IP> -j RETURN
# Allow public ports
-A DOCKER-USER -i eth0 -p tcp --dport 80 -j RETURN
-A DOCKER-USER -i eth0 -p tcp --dport 443 -j RETURN
# Drop everything else from external
-A DOCKER-USER -i eth0 -j DROP
# Return for non-external traffic
-A DOCKER-USER -j RETURN
COMMIT
+965
View File
@@ -0,0 +1,965 @@
#!/bin/bash
set -e
# =============================================================================
# officer-setup — the platform, on a machine that is already provisioned
#
# The second half of the install. machine-setup/ brings a blank box up to a
# usable machine; this puts Officer on top of it.
#
# Run as root: sudo scripts/setup/officer-setup.sh
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
ONLY_STEP=""
# 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)
ONLY_STEP="${2:-}"
shift 2
;;
--only=*)
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] [--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"
# shellcheck source=officer-setup/lib/repo.sh
source "$SCRIPT_DIR/officer-setup/lib/repo.sh"
# shellcheck source=officer-setup/lib/layout.sh
source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
# shellcheck source=officer-setup/lib/postgres.sh
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
# shellcheck source=officer-setup/lib/env.sh
source "$SCRIPT_DIR/officer-setup/lib/env.sh"
# shellcheck source=officer-setup/lib/secrets.sh
source "$SCRIPT_DIR/officer-setup/lib/secrets.sh"
# 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
# =============================================================================
# 1. Pre-flight
# =============================================================================
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Officer Setup ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
# ── 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
info "Read from machine-setup: ${MACHINE_ANSWERS}"
else
warn "machine-setup has not run on this machine"
echo " That is fine if you provisioned it another way — the questions it"
echo " would have answered are asked below instead."
fi
# ── the account ──
#
# A remembered answer can go stale: the account it names may have been renamed or
# removed since machine-setup ran. That is a reason to ask again, not a reason to
# stop — so the remembered value is checked before it is trusted, and a bad one
# is reported and replaced rather than ending the run.
if [[ -n "$USERNAME" ]] && ! owner_exists; then
warn "the remembered account '${USERNAME}' does not exist on this machine any more"
USERNAME=""
fi
while [[ -z "$USERNAME" ]] || ! owner_exists; do
echo ""
info "Which account owns this Officer install?"
echo " Its files, its node_modules and its pm2 process list all belong to"
echo " this account rather than to root."
echo ""
ask_required USERNAME "Username" "${SUDO_USER:-}"
owner_exists || warn "There is no account called '${USERNAME}' on this machine."
done
resolve_user_home
# ── where it goes ──
if [[ -z "$OFFICER_ROOT" ]]; then
echo ""
info "Where should Officer be installed?"
echo " One directory holding the app, its data, the item store and any"
echo " containers the app store provisions."
echo ""
ask_required OFFICER_ROOT "Path" "${USER_HOME}/officerdev"
fi
OFFICER_ROOT="${OFFICER_ROOT/#\~/$USER_HOME}"
[[ "$OFFICER_ROOT" == /* ]] || fail "That needs to be an absolute path — got '${OFFICER_ROOT}'"
OFFICER_ROOT="${OFFICER_ROOT%/}"
info "Account: ${USERNAME} (home ${USER_HOME})"
info "Officer: ${OFFICER_ROOT}"
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
# ── 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
# in, after a repository has been cloned and a database started, is a worse way
# to learn it.
echo ""
info "What Officer needs from this machine"
mapfile -t MISSING < <(missing_tools)
mapfile -t MISSING_OPT < <(missing_optional_tools)
for t in "${REQUIRED_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
else
printf ' %-6s %-10s %s\n' "$t" "MISSING" "$(tool_why "$t")"
fi
done
for t in "${OPTIONAL_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
else
printf ' %-6s %-10s %s\n' "$t" "absent" "$(tool_why "$t") — optional"
fi
done
if ((${#MISSING[@]} > 0)); then
echo ""
fail "Missing: ${MISSING[*]}. Run scripts/setup/machine-setup/machine-setup.sh first, or install them yourself."
fi
if ((${#MISSING_OPT[@]} > 0)); then
echo ""
warn "No Docker. Postgres will have to be one you already run, and the app"
echo " store cannot provision anything until Docker is installed."
fi
if [[ -f "$PROGRESS_FILE" ]]; then
echo ""
info "Resuming — $(wc -l <"$PROGRESS_FILE") step(s) already done, and they will be skipped"
echo " To start over instead: sudo rm ${PROGRESS_FILE}"
else
echo ""
echo " This can be stopped at any point and run again later. Completed"
echo " steps are remembered and skipped."
fi
# =============================================================================
# 2. Layout
# =============================================================================
#
# Before the repository, because the repository is cloned into it.
report_section "Layout"
step "Layout"
if ! skip; then
echo ""
info "Layout — everything Officer owns, under one root"
echo " ${OFFICER_ROOT}/"
echo " platform/ the app"
echo " data/ managed homes, attachments, job logs"
echo " dockers/ anything the app store provisions"
echo " capabilities/ skills, tools, tasks, processes"
echo ""
echo " Nothing here is configurable. The original asked separately for the"
echo " data directory and the item store, which were two answers that had"
echo " to agree with each other. One root now, and the rest follows."
echo ""
echo " To put data/ on a bigger volume later, symlink it — that is a"
echo " decision about storage rather than about how Officer is laid out."
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
if ((${#WRONG_OWNER[@]} > 0)); then
echo ""
warn "these exist but do not belong to ${USERNAME}:"
printf ' %s\n' "${WRONG_OWNER[@]}"
echo " Everything that writes into them runs as ${USERNAME} — the platform"
echo " under pm2, the app store's compose files, the item store the agent"
echo " authors into. Left as they are, those writes fail in a way that"
echo " reads as a bug in the platform."
if confirm "Give them to ${USERNAME}?"; then
for d in "${WRONG_OWNER[@]}"; do chown -R "${USERNAME}:$(user_group)" "$d"; done
ok "ownership corrected"
SUMMARY+=("Layout: ownership corrected on ${#WRONG_OWNER[@]} directory(ies)")
fi
fi
create_layout
ok "layout in place under ${OFFICER_ROOT}"
SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)")
step_ok
fi
# =============================================================================
# 3. Repository
# =============================================================================
report_section "Repository"
step "Repository"
if ! skip; then
PLATFORM_DIR="$(platform_dir)"
echo ""
info "Repository — where the platform's code lives"
echo " path: ${PLATFORM_DIR}"
if repo_exists; then
echo " remote: $(repo_remote)"
echo " branch: $(repo_branch)"
echo " working: $(repo_is_dirty && echo 'has uncommitted changes' || echo 'clean')"
# Reported, never silently corrected. Repointing somebody's remote is a
# decision about where their work goes, and this script is not entitled to
# make it quietly.
if [[ -n "$(repo_remote)" && "$(repo_remote)" != "$OFFICER_REPO" ]]; then
echo ""
warn "this checkout points somewhere other than ${OFFICER_REPO}"
echo " Left alone. To move it:"
echo " git -C ${PLATFORM_DIR} remote set-url origin ${OFFICER_REPO}"
fi
if repo_is_dirty; then
echo ""
echo " not pulling — there are uncommitted changes here, and a pull"
echo " would either fail or bury them"
SUMMARY+=("Repository: present at ${PLATFORM_DIR}, left alone (uncommitted changes)")
elif confirm "Pull the latest changes?"; then
if pull_repo; then
ok "up to date on $(repo_branch)"
SUMMARY+=("Repository: pulled, on $(repo_branch)")
else
# --ff-only, so this means the branch has diverged rather than that the
# network failed. Saying which matters.
warn "could not fast-forward — the local branch has diverged from the remote"
ERRORS+=("Repository: pull refused, branch diverged")
SUMMARY+=("Repository: present, pull refused (diverged)")
fi
else
SUMMARY+=("Repository: present at ${PLATFORM_DIR}")
fi
else
echo " nothing there yet"
echo ""
info "Clone from ${OFFICER_REPO}?"
echo " Cloned as ${USERNAME}, not as root — a repository owned by root is"
echo " one you cannot pull, commit in, or install into."
CLONE_URL="$OFFICER_REPO"
if confirm "Clone it now?"; then
if clone_repo "$CLONE_URL"; then
ok "cloned to ${PLATFORM_DIR} on $(repo_branch)"
SUMMARY+=("Repository: cloned from ${CLONE_URL}")
else
# GIT_TERMINAL_PROMPT=0 in clone_repo means this is a real failure rather
# than a prompt nobody answered.
fail "could not clone ${CLONE_URL} — nothing below can run without it."
fi
else
fail "Nothing below can run without the repository."
fi
fi
step_ok
fi
# =============================================================================
# 4. Dependencies
# =============================================================================
report_section "Dependencies"
step "Dependencies"
if ! skip; then
echo ""
info "Dependencies — bun install, as ${USERNAME}"
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
echo ""
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
echo " so a package.json that disagrees with it fails rather than quietly"
echo " picking newer versions. That friction is deliberate."
echo ""
echo " node-pty has no Linux prebuild, so this compiles it from source"
echo " every time — which is what build-essential and python3 are for."
if deps_installed && node_pty_built; then
ok "already installed, and node-pty is built"
SUMMARY+=("Dependencies: already installed")
elif confirm "Install them?"; then
if install_deps; then
if node_pty_built; then
ok "installed, node-pty built"
SUMMARY+=("Dependencies: installed")
else
# The install can succeed while the native module does not get built —
# bun skips a dependency's lifecycle scripts unless it trusts the
# package. Worth naming, because the symptom is a terminal that never
# comes up rather than an install error.
warn "installed, but node-pty has no built module at node_modules/node-pty/build/Release/"
echo " The terminal sidecar cannot start without it. Try:"
echo " cd $(platform_dir) && bun install --force"
ERRORS+=("Dependencies: node-pty not built")
SUMMARY+=("Dependencies: installed, node-pty NOT built")
fi
else
warn "bun install failed"
echo " If it complained about the lockfile, package.json and bun.lock"
echo " disagree — that is the frozen lockfile doing its job, and it"
echo " wants a human to look at the diff."
ERRORS+=("Dependencies: bun install failed")
SUMMARY+=("Dependencies: FAILED")
fi
else
warn "skipped by request"
SUMMARY+=("Dependencies: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 5. Database
# =============================================================================
#
# POSTGRES_URL is set here and written by the environment section below.
report_section "Database"
step "Database"
if ! skip; then
echo ""
info "Database — Postgres, the only one Officer has"
echo " It holds the account, passkeys, settings, dashboards, email"
echo " accounts and the job queue. Nothing else in the platform is a"
echo " database."
echo ""
# One network for everything Officer provisions. Created before the compose
# file references it, since it is declared external there.
if ensure_docker_network; then
ok "docker network '${OFFICER_NETWORK}' created"
SUMMARY+=("Docker network: ${OFFICER_NETWORK} created")
elif docker_network_exists; then
echo " network: ${OFFICER_NETWORK} (already there)"
fi
# The client goes on the HOST, before any of the container work, because it is the half
# that is not in the container. A member has their own Postgres role and no access to the
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
POSTGRES_URL=""
# An existing compose file means this ran before. Reuse its password rather
# than minting a new one, which would leave the container and the URL
# disagreeing about the credential.
if pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"; then
POSTGRES_URL="$(pg_url "$PG_EXISTING_PASSWORD")"
echo ""
echo " already provisioned here — reusing the password from $(pg_env_file)"
pg_container_running || {
info " starting it"
pg_compose_up >/dev/null 2>&1 || true
}
if pg_wait_ready; then
ok "postgres answering on 127.0.0.1:${PG_PORT}"
SUMMARY+=("Database: existing Postgres at ${PG_CONTAINER}")
else
warn "the container is not answering — check: docker logs ${PG_CONTAINER}"
ERRORS+=("Database: provisioned but not answering")
fi
else
echo ""
info "Which Postgres should Officer use?"
echo ""
echo " [1] provision one here"
echo " ${PG_IMAGE} in $(pg_service_dir), bound to 127.0.0.1 only."
echo " Docker publishes ports by writing iptables rules beneath ufw,"
echo " so a database published to every interface is reachable from"
echo " the internet whatever the firewall says. Loopback is all the"
echo " platform needs — it runs on this machine."
echo ""
echo " [2] use one you already run"
echo " Give the connection URL. Nothing is provisioned."
echo ""
DB_PICK=""
while [[ -z "$DB_PICK" ]]; do
if ! read -rp " Which one? (1/2) [1]: " DB_CHOICE; then
echo ""
fail "No answer."
fi
case "${DB_CHOICE:-1}" in
1)
if ! command -v docker &>/dev/null; then
warn "Docker is not installed, so there is nothing to provision into."
continue
fi
if pg_port_in_use; then
warn "something is already listening on ${PG_PORT} — provisioning here would fail to bind"
echo " If that is a Postgres you already run, pick 2 and give its URL."
continue
fi
DB_PICK=provision
;;
2) DB_PICK=existing ;;
*) warn "Pick 1 or 2." ;;
esac
done
if [[ "$DB_PICK" == provision ]]; then
PG_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
write_pg_compose "$PG_PASSWORD"
ok "compose written to $(pg_compose_file)"
if pg_compose_up && pg_wait_ready; then
POSTGRES_URL="$(pg_url "$PG_PASSWORD")"
ok "postgres answering on 127.0.0.1:${PG_PORT}, database '${PG_DATABASE}'"
SUMMARY+=("Database: provisioned at $(pg_service_dir)")
else
warn "the container did not come up — check: docker logs ${PG_CONTAINER}"
ERRORS+=("Database: container did not start")
SUMMARY+=("Database: provisioning FAILED")
fi
else
echo ""
ask_required POSTGRES_URL "Connection URL" "postgresql://user:password@host:5432/officer"
if pg_url_works "$POSTGRES_URL"; then
ok "reachable"
SUMMARY+=("Database: existing, ${POSTGRES_URL%%:*}://…")
else
# Not fatal. The URL may be right and the database not started yet, and
# refusing to continue over that would be worse than saying so.
warn "could not connect with that URL"
echo " Kept anyway — check it before running the schema step."
ERRORS+=("Database: the given URL did not answer")
SUMMARY+=("Database: existing URL kept, did not answer")
fi
fi
fi
step_ok
fi
# =============================================================================
# 6. Environment
# =============================================================================
report_section "Environment"
step "Environment"
if ! skip; then
echo ""
info "Environment — $(env_file)"
# Read back before anything is asked; existing values become the defaults.
ENV_PORT="$(env_get PORT)"
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
if env_exists; then
echo " exists — its values are the defaults below"
else
echo " does not exist yet"
fi
# ── what is asked ──
echo ""
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
echo ""
echo " 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}"
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"
echo " parent of the repo, so data/, capabilities/ and dockers/ follow from"
echo " ${OFFICER_ROOT} without anything having to agree with anything."
echo ""
if confirm "Write it?"; then
write_env
ok "written, 0600, owned by ${USERNAME}"
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
warn "skipped by request"
SUMMARY+=("Environment: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 7. Secrets
# =============================================================================
#
# The store creates keys on demand, so this section is not strictly required —
# the first `sign()` would mint the jwt key by itself. It runs anyway for two
# reasons: the file should exist with the right owner and mode before anything
# races to create it, and an install that finishes without ever saying the words
# "back this up" is one where nobody learns the file matters until it is gone.
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 ""
report_mark_complete
+140
View File
@@ -0,0 +1,140 @@
#!/bin/bash
# =============================================================================
# officer-setup — shared foundation
# =============================================================================
#
# Sourced by officer-setup.sh before anything runs. DEFINITIONS ONLY, the same
# rule machine-setup/lib holds to: nothing here installs, writes or restarts.
#
# ── Why this is a separate script from machine-setup ──
#
# They answer different questions. machine-setup asks what a MACHINE should be —
# users, ssh, firewall, runtimes — and is worth running on a box that will never
# see Officer. This one puts Officer on a machine that is already ready, and
# assumes nothing about how it got that way.
#
# The split also means the failure modes stay apart: a broken firewall rule and a
# failed database migration are not the same kind of problem and should not be
# in the same run.
[[ -n "${OFFICER_SETUP_BASE_LOADED:-}" ]] && return 0
OFFICER_SETUP_BASE_LOADED=1
SUMMARY=()
ERRORS=()
CURRENT_STEP=""
SKIP_STEP=false
USERNAME="${SETUP_USERNAME:-}"
USER_HOME=""
OFFICER_ROOT="${OFFICER_ROOT:-}"
MACHINE_ROLE="${MACHINE_ROLE:-}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${CYAN}::${NC} $*"; }
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
fail() {
echo -e " ${RED}FAIL${NC}: $*"
exit 1
}
ONLY_STEP="${ONLY_STEP:-}"
step() {
CURRENT_STEP="$1"
if [[ -n "$ONLY_STEP" ]]; then
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
else
SKIP_STEP=true
fi
return
fi
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
SKIP_STEP=true
return
fi
SKIP_STEP=false
echo ""
echo -e "${BOLD}── $1 ──${NC}"
}
skip() { [[ "$SKIP_STEP" == true ]]; }
step_ok() {
[[ -n "$ONLY_STEP" ]] && return 0
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
}
page() {
if [[ -t 1 ]] && command -v more &>/dev/null; then more; else cat; fi
}
confirm() {
local message="${1:-Proceed?}" default="${2:-y}" help_fn="${3:-}" answer prompt
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
while true; do
if ! read -rp " ${message} ${prompt}: " answer; then
echo ""
fail "No answer. Set ASSUME_YES=1 to run without prompts."
fi
[[ -z "$answer" ]] && answer="$default"
case "$answer" in
y | Y | yes | Yes) return 0 ;;
n | N | no | No) return 1 ;;
"?")
if [[ -n "$help_fn" ]]; then
echo ""
"$help_fn" | page
echo ""
else
warn "Answer y or n."
fi
;;
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
esac
done
}
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 ""
fail "No answer."
fi
answer="${answer:-$default}"
[[ -z "$answer" ]] && warn "This one cannot be left blank."
done
printf -v "$__var" '%s' "$answer"
}
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
# Run something as the account that owns the install. Officer's files, its
# node_modules and its pm2 process list all belong to that account, not to root —
# a repository cloned as root is one the owner cannot pull.
as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); }
+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"; }
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
# =============================================================================
# officer-setup — the environment file
# =============================================================================
#
# Definitions only.
#
# ── No secrets are written here ──
#
# Every encryption and signing key lives in the secret store — a 0600 SQLite file
# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first
# use. See docs/secret-store.md and the Secrets section of officer-setup.sh.
#
# So this file holds no credential except POSTGRES_URL, which is a connection
# string to a database bound to loopback.
#
# ── Derived, not asked ──
#
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone too, and this time nothing
# replaces them. The platform derives the install root as the parent of its own
# working directory, so data/, capabilities/ and dockers/ follow from the layout
# on disk, and the owner's home comes from the OS. They were three environment
# variables that had to agree with each other and with the directory tree.
[[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0
OFFICER_SETUP_ENV_LOADED=1
env_file() { echo "$(platform_dir)/.env"; }
env_exists() { [[ -f "$(env_file)" ]]; }
# One value out of an existing .env, without sourcing it — the file holds
# secrets and arbitrary shell would run as root.
env_get() {
[[ -r "$(env_file)" ]] || return 0
awk -F= -v k="$1" '
$1 == k {
v = substr($0, index($0, "=") + 1)
gsub(/^"|"$/, "", v)
print v
exit
}' "$(env_file)"
}
write_env() {
local dest
dest="$(env_file)"
[[ -f "$dest" ]] && cp -a "$dest" "${dest}.before-officer-setup"
# Restrictive from the moment it exists rather than chmod'd afterwards, so the
# secrets are never briefly world-readable. Restored straight after: umask is
# not scoped to a function, and leaving it at 077 would quietly make every file
# a later section creates owner-only.
local prior_umask
prior_umask="$(umask)"
umask 077
cat >"$dest" <<ENVF
# Written by officer-setup.
#
# Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it
# holds the token-signing secret and the database credential.
PORT="${ENV_PORT}"
# Where Officer is reached from a browser. Not derivable — see the section.
PUBLIC_URL="${ENV_PUBLIC_URL}"
POSTGRES_URL="${POSTGRES_URL}"
ENVF
umask "$prior_umask"
chown "${USERNAME}:$(user_group)" "$dest"
chmod 600 "$dest"
return 0
}
# -----------------------------------------------------------------------------
# A sensible default for PUBLIC_URL
# -----------------------------------------------------------------------------
#
# localhost is the wrong default on a machine with a tailnet, and quietly so:
# it works from the machine itself and from nowhere else, so the mistake shows up
# on the first phone, not during setup.
#
# The tailnet is where Officer is actually reached — it is the perimeter the
# whole security model rests on — so its address is the honest default.
#
# The SHORT MagicDNS name — `officer-dev`, not `officer-dev.ts.example.dev` and
# not the raw 100.x address. All three resolve inside the tailnet; the short one
# is the one anybody actually types, and PUBLIC_URL ends up baked into the page's
# OpenGraph tags by `bun gen:index`, so it is read by people as well as machines.
#
# It relies on the tailnet's search domain, which every Tailscale client sets when
# MagicDNS is on. A device that has somehow lost it resolves the FQDN and not the
# short name — the fix there is to type the longer one, not to default to it.
#
# Falls back to localhost when there is no tailnet, which is correct rather than
# merely tolerable: a machine with no private network has no other address that
# is any better a guess.
tailnet_hostname() {
local dns ip
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
}
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# =============================================================================
# officer-setup — the install layout
# =============================================================================
#
# Definitions only.
#
# ── One root, and nothing configurable underneath it ──
#
# $OFFICER_ROOT/
# platform/ the app — the git checkout
# data/ DATA_PATH: managed homes, attachments, job logs
# dockers/ services the app store provisioned
# capabilities/ the file-based item store — skills, tools, tasks, processes
#
# The original asked separately for DATA_PATH and for OFFICER_ITEMS_DIR, and left
# the app store's directory implicit. Three answers that had to agree with each
# other, given by somebody with no reason to know they had to.
#
# Now one question — where the root goes — and the rest follows. Anybody who wants
# data/ on a bigger volume can symlink it; that is a decision about storage, not
# about how Officer is laid out, and it does not need a prompt in a setup script.
#
# This is also what the code already assumes. app-store/paths.ts derives
# OFFICER_ROOT as dirname(DATA_PATH) and DOCKERS_DIR as OFFICER_ROOT/dockers, so
# setting DATA_PATH to <root>/data is the whole of what makes the layout correct.
[[ -n "${OFFICER_SETUP_LAYOUT_LOADED:-}" ]] && return 0
OFFICER_SETUP_LAYOUT_LOADED=1
layout_data_dir() { echo "${OFFICER_ROOT}/data"; }
layout_dockers_dir() { echo "${OFFICER_ROOT}/dockers"; }
layout_items_dir() { echo "${OFFICER_ROOT}/capabilities"; }
layout_dirs() {
echo "$OFFICER_ROOT"
echo "$(layout_data_dir)"
echo "$(layout_dockers_dir)"
echo "$(layout_items_dir)"
}
# Created owned by the account, because everything that writes into them runs as
# the account: the platform under pm2, the app store's compose files, the item
# store the agent authors into.
create_layout() {
local dir
while read -r dir; do
[[ -d "$dir" ]] || install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir"
done < <(layout_dirs)
return 0
}
# A directory that exists but belongs to somebody else is the failure this
# reports: it happens when an earlier run, or a hand-made directory, was created
# as root, and everything written into it afterwards fails in a way that reads as
# a permissions bug in the platform.
layout_wrong_owner() {
local dir
while read -r dir; do
[[ -d "$dir" ]] || continue
[[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir"
done < <(layout_dirs)
}
+220
View File
@@ -0,0 +1,220 @@
#!/bin/bash
# =============================================================================
# officer-setup — Postgres
# =============================================================================
#
# Definitions only.
#
# ── Only Postgres ──
#
# The original offered five containers. Of those, Redis and SearXNG are not
# referenced anywhere in the platform — no import, no environment variable, no
# mention — and Nginx Proxy Manager is a deployment choice rather than something
# a setup script should pick. Mailhog is a development convenience and is offered
# separately.
#
# Postgres is the only one Officer cannot run without: it is the single database,
# holding the account, passkeys, settings, dashboards, email accounts and the
# queue.
#
# ── Where it goes ──
#
# $OFFICER_ROOT/dockers/postgres/, which is the same convention the app store
# uses for anything it provisions: one directory per service, the compose file
# inside it, and RELATIVE bind mounts so the data sits beside the compose file
# where both a human and the platform can find it.
[[ -n "${OFFICER_SETUP_POSTGRES_LOADED:-}" ]] && return 0
OFFICER_SETUP_POSTGRES_LOADED=1
# One network for everything Officer provisions, so containers can reach each
# other by name. Postgres needs nothing from it today — the platform is a host
# process and reaches it over loopback — but a reverse proxy in front of the web
# UI, or any app-store service that talks to another, does. Creating it now means
# the later ones do not have to migrate onto it.
OFFICER_NETWORK="${OFFICER_NETWORK:-officerdev}"
PG_IMAGE="${PG_IMAGE:-postgres:18-alpine}"
PG_DATABASE="${PG_DATABASE:-officer}"
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
PG_PORT="${PG_PORT:-5432}"
# ── The CLIENT, on the host, matching the server in the container ──
#
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
# owner's Docker socket.
#
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
# version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the
# archive package is not merely old, it is unusable for dumps. That is also why this lives
# beside the server definition rather than in machine-setup's package list — one constant,
# one place to bump.
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
pg_client_installed() {
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
}
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
# just a more annoying one to operate.
install_pg_client() {
local major codename
major="$(pg_client_major)"
[[ -n "$major" ]] || {
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
return 1
}
if pg_client_installed; then
ok "psql ${major} already installed"
return 0
fi
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
[[ -n "$codename" ]] || {
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
return 1
}
install -d -m 0755 /usr/share/postgresql-common/pgdg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
warn "could not fetch the PostgreSQL signing key"
return 1
}
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
>/etc/apt/sources.list.d/pgdg.list
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
warn "postgresql-client-${major} did not install"
return 1
}
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
# matters is whether the binary is there AND is the version we asked for, because apt can
# succeed while holding an older client back.
pg_client_installed || {
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
return 1
}
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
}
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
ensure_docker_network() {
docker_network_exists && return 1
docker network create "$OFFICER_NETWORK" >/dev/null 2>&1
}
pg_service_dir() { echo "${OFFICER_ROOT}/dockers/postgres"; }
pg_compose_file() { echo "$(pg_service_dir)/docker-compose.yaml"; }
pg_env_file() { echo "$(pg_service_dir)/.env"; }
pg_compose_exists() { [[ -f "$(pg_compose_file)" ]]; }
pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q .; }
# Is something already answering on the port? A Postgres the user runs their own
# way is a perfectly good answer, and finding out by failing to bind is not.
pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; }
# Bound to loopback, deliberately, and the reason is worth keeping next to the
# line it explains.
#
# Publishing a port makes Docker write its own DNAT and ACCEPT rules into
# iptables, and those are evaluated BEFORE ufw sees the packet. So `ports:
# "5432:5432"` is reachable from the internet while `ufw status` reports
# everything denied. Binding to 127.0.0.1 sidesteps it entirely: the DNAT rule
# only matches traffic arriving on loopback.
#
# Loopback is not the whole story, though, and the password is not decoration.
# Every account ON this machine can open 127.0.0.1:5432 — including the per-user
# Linux accounts Officer gives its members. What stops them is that they cannot
# authenticate. The password is the boundary between the platform and anyone
# with a login here, which is why it is random and why both files holding it are
# 0600.
write_pg_compose() {
local password="$1" dir
dir="$(pg_service_dir)"
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir"
cat >"$(pg_compose_file)" <<COMPOSE
# Written by officer-setup. Officer's database.
#
# The port is bound to 127.0.0.1 on purpose. Docker publishes ports by writing
# iptables rules beneath ufw, so "5432:5432" would be reachable from the internet
# whatever the firewall reports. The platform runs on this machine, so loopback
# is all it needs.
services:
postgres:
image: ${PG_IMAGE}
container_name: ${PG_CONTAINER}
restart: unless-stopped
ports:
- "127.0.0.1:${PG_PORT}:5432"
environment:
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
POSTGRES_DB: ${PG_DATABASE}
PGDATA: /var/lib/postgresql/data
volumes:
- ./data:/var/lib/postgresql/data
- ./dumps:/dumps
networks:
- ${OFFICER_NETWORK}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
networks:
${OFFICER_NETWORK}:
external: true
COMPOSE
# The password lives beside the compose file rather than inside it, so the
# compose file can be read, copied or committed without carrying a credential.
umask 077
cat >"$(pg_env_file)" <<ENVF
# Written by officer-setup. Read by docker compose from this directory.
POSTGRES_PASSWORD=${password}
ENVF
chown "${USERNAME}:$(user_group)" "$(pg_compose_file)" "$(pg_env_file)"
chmod 600 "$(pg_env_file)"
return 0
}
pg_password_from_env_file() {
[[ -r "$(pg_env_file)" ]] || return 1
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
}
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
# Wait for it to answer, rather than assuming `up -d` means ready. Postgres
# initialises its data directory on first start, which takes several seconds, and
# everything after this — db:push especially — fails confusingly against a
# database that is still starting.
pg_wait_ready() {
local tries="${1:-30}"
while ((tries-- > 0)); do
docker exec "$PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1 && return 0
sleep 1
done
return 1
}
pg_url() { echo "postgresql://postgres:${1}@127.0.0.1:${PG_PORT}/${PG_DATABASE}"; }
# Does this URL actually answer? Asked of any URL, provisioned or given, because
# a database nobody can reach is the failure that makes every later section look
# broken for its own reasons.
pg_url_works() {
local url="$1"
as_owner "docker run --rm --network host ${PG_IMAGE} psql '${url}' -c 'select 1' >/dev/null 2>&1" /
}
@@ -0,0 +1,75 @@
#!/bin/bash
# =============================================================================
# officer-setup — is this machine ready
# =============================================================================
#
# Definitions only.
#
# ── Inherited, not re-asked ──
#
# machine-setup saves the account, the Officer path and the machine role beside
# itself. This reads the same file, so a normal run — machine-setup, then this —
# asks nothing at all. It only prompts on a machine where machine-setup never
# ran, which is a supported case rather than an error: somebody may have
# provisioned the box their own way.
[[ -n "${OFFICER_SETUP_PREFLIGHT_LOADED:-}" ]] && return 0
OFFICER_SETUP_PREFLIGHT_LOADED=1
# Where machine-setup keeps what it was told. Beside this script, one directory
# across.
MACHINE_ANSWERS="${MACHINE_ANSWERS:-${SCRIPT_DIR}/machine-setup/.setup-answers}"
# Read as assignments rather than sourced: the file is read by a root run and
# sourcing it would make it executable content.
load_machine_answers() {
[[ -r "$MACHINE_ANSWERS" ]] || return 1
local key value
while IFS='=' read -r key value; do
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
[[ -n "$value" ]] || continue
case "$key" in
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
esac
done <"$MACHINE_ANSWERS"
return 0
}
# What Officer needs to already be here, and what installs it.
#
# Checked together and reported together: finding out about a missing bun three
# sections in, after the repository has been cloned and a database started, is a
# worse way to learn it than being told at the start.
REQUIRED_TOOLS=(git node bun pm2)
OPTIONAL_TOOLS=(docker)
missing_tools() {
local t
for t in "${REQUIRED_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
}
missing_optional_tools() {
local t
for t in "${OPTIONAL_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
}
tool_why() {
case "$1" in
git) echo "to clone and update the platform" ;;
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
bun) echo "the platform itself and nineteen of the twenty processes" ;;
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
docker) echo "Postgres, and anything the app store provisions" ;;
*) echo "" ;;
esac
}
# The account has to exist before anything is written to its home.
owner_exists() { id "$USERNAME" &>/dev/null; }
resolve_user_home() {
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
}
+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
}
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# =============================================================================
# officer-setup — the repository
# =============================================================================
#
# Definitions only.
#
# ── Cloned as the owner, never as root ──
#
# A repository cloned by root is one the owner cannot pull, cannot commit in, and
# whose node_modules they cannot write. Every git operation here runs as the
# account, from a directory that account can stat.
[[ -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"; }
repo_exists() { [[ -d "$(platform_dir)/.git" ]]; }
repo_remote() { (cd "$(platform_dir)" 2>/dev/null && git remote get-url origin 2>/dev/null) || true; }
repo_branch() { (cd "$(platform_dir)" 2>/dev/null && git branch --show-current 2>/dev/null) || true; }
repo_is_dirty() { [[ -n "$(cd "$(platform_dir)" 2>/dev/null && git status --porcelain 2>/dev/null)" ]]; }
# Split an ssh:// URL into host and port, for the reachability check below.
repo_ssh_host() { sed -E 's|^ssh://[^@]*@([^:/]+).*|\1|' <<<"$1"; }
repo_ssh_port() { sed -nE 's|^ssh://[^@]*@[^:]+:([0-9]+)/.*|\1|p' <<<"$1"; }
# Can this account actually clone it?
#
# `git ls-remote` is the real question — not "does the host answer" but "can this
# account read this repository". Both prompts are disabled, because neither fails
# cleanly on its own: over https git asks for a username nobody is there to type,
# and over ssh it asks for a password or stops on host-key verification. With
# both off, an unreachable or unreadable repository is an immediate non-zero
# instead of a hang.
repo_reachable() {
as_owner "GIT_TERMINAL_PROMPT=0 \
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8' \
timeout 20 git ls-remote '$1' >/dev/null 2>&1" /
}
# The https form of the same repository, for a machine with no key.
repo_https_url() {
sed -E 's|^ssh://[^@]*@([^:/]+)(:[0-9]+)?/|https://\1/|' <<<"$1"
}
clone_repo() {
local url="$1" dest
dest="$(platform_dir)"
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$OFFICER_ROOT"
as_owner "GIT_TERMINAL_PROMPT=0 git clone '${url}' '${dest}'" /
}
pull_repo() { as_owner "git -C '$(platform_dir)' pull --ff-only" /; }
# -----------------------------------------------------------------------------
# Dependencies
# -----------------------------------------------------------------------------
#
# ── The lockfile is frozen, and that is the point ──
#
# bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from
# bun.lock and nothing else. A package.json that disagrees with the lockfile is a
# hard failure rather than a quiet resolution — which is deliberate: the friction
# exists so that an unexplained lockfile change shows up in a diff. See the
# supply-chain note in CLAUDE.md.
#
# So a failure here is usually one of two things, and they need different
# answers: the lockfile genuinely disagrees with package.json, or node-pty failed
# to build. Both are reported as such rather than as "install failed".
deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
# node-pty has no Linux prebuild, so `bun install` compiles it every time. This is
# the artefact that proves it worked, and its absence is why the terminal sidecar
# would not start.
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
@@ -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)" ]]
}
+114
View File
@@ -0,0 +1,114 @@
#!/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"
)
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}"
}
+100
View File
@@ -0,0 +1,100 @@
########## TPM AUTO-INSTALL + SESSION PERSISTENCE ##########
# Auto-install TPM if missing
if-shell '[ ! -d ~/.tmux/plugins/tpm ]' \
'run-shell "git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm"'
# Plugin list
set -g @plugin 'tmux-plugins/tpm'
# remap prefix from 'C-b' to 'C-a'
unbind C-b
set-option -g prefix C-a
bind-key C-a send-prefix
set -g base-index 1
# split panes using | and -
unbind '"'
unbind %
bind | split-window -h
bind - split-window -v
# reload config file (change file location to your the tmux.conf you want to use)
unbind r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
# switch panes using Alt-arrow without prefix
bind -n M-Left select-pane -L
bind -n M-Right select-pane -R
bind -n M-Up select-pane -U
bind -n M-Down select-pane -D
# switch panes using Alt-HJKL without prefix
bind -n M-h select-pane -L
bind -n M-l select-pane -R
bind -n M-k select-pane -U
bind -n M-j select-pane -D
# Enable mouse control (clickable windows, panes, resizable panes)
# don't rename windows automatically
set-option -g allow-rename off
######################
### DESIGN CHANGES ###
######################
# loud or quiet?
set -g visual-activity off
set -g visual-bell off
set -g visual-silence off
setw -g monitor-activity off
set -g bell-action none
# modes
setw -g clock-mode-colour colour12
setw -g mode-style 'fg=colour1 bg=colour18 bold'
# panes
set -g pane-border-style 'fg=colour19 bg=colour0'
set -g pane-active-border-style 'bg=colour0 fg=colour9'
# statusbar
set -g status-position bottom
set -g status-justify left
set -g status-style 'bg=colour2 fg=colour23'
# set -g status-left '#[fg=white,bg=black,bold] pastilhas #[default]'
set -g status-left '#[fg=#ffffff,bg=#000000,bold] #{USER}@#H #[default]'
# set -g status-left-length 20
set -g status-right '#[fg=#ffffff,bg=colour1] %d/%m #[fg=#ffffff,bg=colour8] %H:%M:%S '
set -g status-right-length 50
set -g status-left-length 20
setw -g window-status-current-style 'fg=colour1 bg=colour19 bold'
setw -g window-status-current-format ' #I#[fg=colour249]:#[fg=colour255]#W#[fg=colour249]#F '
setw -g window-status-style 'fg=colour9 bg=colour18'
setw -g window-status-format ' #I#[fg=colour237]:#[fg=colour250]#W#[fg=colour244]#F '
setw -g window-status-bell-style 'fg=colour255 bg=colour1 bold'
# ...existing code...
# messages
set -g message-style 'fg=#ffffff bg=red bold'
# Change the font color for the exit pane confirmation message
set -g message-command-style 'fg=#ffffff bg=red bold'
# ...existing code...
# messages
# set -g message-style 'fg=colour232 bg=colour16 bold'
##########################
### END DESIGN CHANGES ###
##########################
##########################
### EASY MOUSE SCROLL ###
##########################
set -g mouse on
set -ga terminal-overrides ',*256color*:smcup@:rmcup@'
+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.
+1 -2
View File
@@ -64,6 +64,7 @@ export function App() {
<Route path="/photos" element={<Dashboard.PhotosScreen />} /> <Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} /> <Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} /> <Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} /> <Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} /> <Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} /> <Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
@@ -91,8 +92,6 @@ export function App() {
<Route path="/tasks/:dirName" element={<Dashboard.Tasks />} /> <Route path="/tasks/:dirName" element={<Dashboard.Tasks />} />
<Route path="/processes" element={<Dashboard.Processes />} /> <Route path="/processes" element={<Dashboard.Processes />} />
<Route path="/processes/:dirName" 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" element={<Dashboard.JobsPage />} />
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} /> <Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} /> <Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
@@ -9,6 +9,7 @@ import { Card } from '@/components/Card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { WorkspaceLayout } from 'officerdev'; import { WorkspaceLayout } from 'officerdev';
import type { LayoutNode, PanelComponents } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev';
import { randomId } from 'helpers/random-id';
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number }; type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
@@ -571,7 +572,7 @@ export const PipelineJobDetail = () => {
const key = outputKey(msg.stepIndex, msg.iterationLabel); const key = outputKey(msg.stepIndex, msg.iterationLabel);
const text = msg.text || streamBuffers.current.get(key) || ''; const text = msg.text || streamBuffers.current.get(key) || '';
if (text) { if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text }); appendOutput(key, { id: randomId(), type: 'text', text });
} }
streamBuffers.current.delete(key); streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); 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 // Flush any streaming text before the tool call
flushStreamBuffer(key); flushStreamBuffer(key);
appendOutput(key, { appendOutput(key, {
id: crypto.randomUUID(), id: randomId(),
type: 'tool', type: 'tool',
toolCallId: msg.toolCallId, toolCallId: msg.toolCallId,
toolName: msg.toolName, toolName: msg.toolName,
@@ -630,7 +631,7 @@ export const PipelineJobDetail = () => {
const flushStreamBuffer = useCallback((key: string) => { const flushStreamBuffer = useCallback((key: string) => {
const text = streamBuffers.current.get(key); const text = streamBuffers.current.get(key);
if (text) { if (text) {
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text }); appendOutput(key, { id: randomId(), type: 'text', text });
streamBuffers.current.delete(key); streamBuffers.current.delete(key);
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; }); setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
} }
@@ -130,7 +130,6 @@ import {
FolderOpen, FolderOpen,
Code, Code,
LayoutGrid, LayoutGrid,
ScrollText,
FolderKanban, FolderKanban,
Monitor, Monitor,
Mail, Mail,
@@ -150,6 +149,7 @@ import {
Clapperboard, Clapperboard,
GitBranch, GitBranch,
Store, Store,
Puzzle,
} from 'lucide-react'; } from 'lucide-react';
/** /**
@@ -170,15 +170,20 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' }, { label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, { 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: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, { label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, { 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 // Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled. // things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' }, { label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
]; ];
/** /**
@@ -0,0 +1,25 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
//
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
// handler. This screen is the courtesy half of that.
//
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
export const PluginsScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
/>
</div>
);
};
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
// and destroy the side-by-side. See docs/navigation-audit.md.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'plugins-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
],
};
@@ -0,0 +1 @@
export * from './PluginsScreen';
@@ -6,6 +6,7 @@ import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; 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 // 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. // instead of a session everything shares.
@@ -39,7 +40,7 @@ const formatDate = (value: string | null) =>
const copy = async (text: string) => { const copy = async (text: string) => {
try { try {
await navigator.clipboard.writeText(text); await copyToClipboard(text);
toast.success('Key copied'); toast.success('Key copied');
} catch { } catch {
toast.error('Could not copy — select and copy manually'); 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
type RelayToken = { type RelayToken = {
token: string; token: string;
@@ -46,7 +47,7 @@ export const BrowserRelay = () => {
const handleCopy = async (value: string, field: string) => { const handleCopy = async (value: string, field: string) => {
try { try {
await navigator.clipboard.writeText(value); await copyToClipboard(value);
setCopiedField(field); setCopiedField(field);
toast.success('Copied to clipboard'); toast.success('Copied to clipboard');
setTimeout(() => setCopiedField(null), 2000); setTimeout(() => setCopiedField(null), 2000);
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { copyToClipboard } from 'helpers/clipboard';
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird). // 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) => { const copy = async (text: string, what: string) => {
try { try {
await navigator.clipboard.writeText(text); await copyToClipboard(text);
toast.success(`${what} copied`); toast.success(`${what} copied`);
} catch { } catch {
toast.error('Could not copy — select and copy manually'); 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 { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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. // 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) => { const copy = (value: string, what: string) => {
void navigator.clipboard.writeText(value); void copyToClipboard(value);
toast.success(`${what} copied`); toast.success(`${what} copied`);
}; };
@@ -283,7 +284,7 @@ export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
size="icon" size="icon"
disabled={!form.password} disabled={!form.password}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(form.password); void copyToClipboard(form.password);
toast.success('Password copied'); toast.success('Password copied');
}} }}
> >
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; 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 { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -16,6 +16,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm'; import { CreateUserForm } from './CreateUserForm';
import { copyToClipboard } from 'helpers/clipboard';
type ManagedUser = { type ManagedUser = {
id: number; id: number;
@@ -32,8 +33,6 @@ type ManagedUser = {
type UsersResponse = { type UsersResponse = {
users: ManagedUser[]; users: ManagedUser[];
/** False on a host without per-user Linux accounts, where those controls would only ever refuse. */
osUsersEnabled: boolean;
/** Every role, for displaying the owner's own value. */ /** Every role, for displaying the owner's own value. */
roles: string[]; roles: string[];
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */ /** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
@@ -48,6 +47,9 @@ export const UsersSection = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pendingId, setPendingId] = useState<number | null>(null); const [pendingId, setPendingId] = useState<number | null>(null);
const [confirmDelete, setConfirmDelete] = useState<ManagedUser | 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>({ const { data, isLoading, isError } = useQuery<UsersResponse>({
queryKey: USERS_KEY, queryKey: USERS_KEY,
@@ -107,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) => { const remove = async (user: ManagedUser) => {
setPendingId(user.id); setPendingId(user.id);
setConfirmDelete(null); setConfirmDelete(null);
@@ -183,7 +206,7 @@ export const UsersSection = () => {
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for {/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
anyone who has one (retry after fixing a host problem, or replace their key) — the anyone who has one (retry after fixing a host problem, or replace their key) — the
underlying operation is idempotent, so there is no state where pressing it is wrong. */} underlying operation is idempotent, so there is no state where pressing it is wrong. */}
{data.osUsersEnabled && !user.isOwner && ( {!user.isOwner && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -211,7 +234,7 @@ export const UsersSection = () => {
aria-label={`Copy ${user.email}'s SSH public key`} aria-label={`Copy ${user.email}'s SSH public key`}
title="Copy their SSH public key (add it to their Gitea account)" title="Copy their SSH public key (add it to their Gitea account)"
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(user.osSshPublicKey!); void copyToClipboard(user.osSshPublicKey!);
toast.success('Public key copied'); toast.success('Public key copied');
}} }}
> >
@@ -219,6 +242,22 @@ export const UsersSection = () => {
</Button> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -256,6 +295,69 @@ export const UsersSection = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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> </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>
);
};
@@ -1,4 +1,5 @@
export * from './AppStore'; export * from './AppStore';
export * from './Plugins';
export * from './Layout'; export * from './Layout';
export * from './Home'; export * from './Home';
export * from './PasskeyGate'; export * from './PasskeyGate';
@@ -6,7 +7,6 @@ export * from './Processes';
export * from './CapabilityPage'; export * from './CapabilityPage';
export * from './Settings'; export * from './Settings';
export * from './Skills'; export * from './Skills';
export * from './TaskLogs';
export * from './Tasks'; export * from './Tasks';
export * from './Files'; export * from './Files';
+3 -2
View File
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router';
import type { PageTitleOverride } from 'officerdev'; import type { PageTitleOverride } from 'officerdev';
import { usePageTitleOverride } from 'officerdev'; import { usePageTitleOverride } from 'officerdev';
import { useSessionState, writeSessionValue } from 'hooks/useSessionState'; import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
import { randomId } from 'helpers/random-id';
type TitleRule = { match: (p: string) => boolean; title: string }; type TitleRule = { match: (p: string) => boolean; title: string };
@@ -22,6 +23,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' }, { match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' }, { match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' }, { match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' }, { match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
@@ -34,7 +36,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' }, { match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' },
{ match: (p) => p.startsWith('/activity'), title: 'Activity' }, { match: (p) => p.startsWith('/activity'), title: 'Activity' },
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' }, { 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('/tasks'), title: 'Tasks' },
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' }, { match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
{ match: (p) => p.startsWith('/skills'), title: 'Skills' }, { match: (p) => p.startsWith('/skills'), title: 'Skills' },
@@ -139,7 +140,7 @@ function claimTabIdentity(): void {
/** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */ /** `randomUUID` needs a secure context; the id only has to be unique among open tabs. */
function newTabId(): string { 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. // 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/databases/officer_db/
├── src/ ├── src/
│ ├── db.ts # the connection │ ├── db.ts # the connection
│ ├── index.ts # public surface: re-exports queries, schema and drizzle helpers │ ├── index.ts # public surface: one `export * from './<feature>'` per line
│ ├── types.ts # every type export (Select / Insert / extended) │ ├── schema.ts # what db:push creates — see below
── schema/ ── types.ts # every type export (Select / Insert / extended)
├── index.ts # re-exports all schema files ├── crypto.ts # at-rest encryption, one key per purpose
└── *.ts # table definitions, grouped by domain ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
└── package.json # exports "." and "./types" │ └── <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`, **A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
`headscale`, `music`, `operations`, `pipeline-jobs`, `server`, `soulseek`, `user-data`, `vault`, else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
`wallet`. 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 ## Schema changes use `push`, not migrations
@@ -114,8 +134,8 @@ Organise `types.ts` by domain with section comments, mirroring the schema files.
## Queries ## Queries
Hand-written, one file per domain under `src/queries/`, importing tables from `../schema` and types from Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
`../types`: types from `../types`:
```ts ```ts
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
+1 -1
View File
@@ -15,7 +15,7 @@ try {
} catch {} } catch {}
export default defineConfig({ export default defineConfig({
schema: './src/schema/index.ts', schema: './src/schema.ts',
out: './migrations', out: './migrations',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
+3 -1
View File
@@ -7,7 +7,9 @@
".": "./src/index.ts", ".": "./src/index.ts",
"./types": "./src/types.ts", "./types": "./src/types.ts",
"./db": "./src/db.ts", "./db": "./src/db.ts",
"./schema": "./src/schema/index.ts" "./schema": "./src/schema.ts",
"./secret-store": "./src/secret-store.ts",
"./*": "./src/*.ts"
}, },
"scripts": { "scripts": {
"generate": "drizzle-kit generate --config=drizzle.config.ts", "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 { randomUUID } from 'crypto';
import { and, asc, eq } from 'drizzle-orm'; import { and, asc, eq } from 'drizzle-orm';
import { db } from '../db'; import { db } from '../db';
import { agentPanels } from '../schema'; import { agentPanels } from './schema';
import type { AgentPanelRow } from '../schema/agent-panels'; import type { AgentPanelRow } from './schema';
export type AgentPanel = AgentPanelRow; export type AgentPanel = AgentPanelRow;
@@ -1,5 +1,5 @@
import { pgTable, serial, text, integer, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core'; 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 * 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 { eq, and, isNull, sql } from 'drizzle-orm';
import { db } from '../db'; import { db } from '../db';
import { apiKeys, users } from '../schema'; import { apiKeys } from './schema';
import { users } from '../auth/schema';
import type { ApiKeySelect } from '../types'; import type { ApiKeySelect } from '../types';
// Every read here is scoped by userId except `findLiveApiKeyByHash`, which cannot be: authentication is // 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 { 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. // 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 { eq } from 'drizzle-orm';
import { db } from '../db'; 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 // 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. // userId and why `installed` and `enabled` are separate.

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