Author SHA1 Message Date
pastilhasandClaude Opus 5 8aaea6bfcc the cost that matters is the useless wake, not the useful one
Most waits find nothing almost always: a daily release check says no 360 days a year, and a branch
watcher wakes on every push including everyone else. So the number to optimise is the useless wake
times how many there will be.

The fix is not a cheaper wake, it is pushing the relevance test into the wait condition so that firing
implies relevance. Wait on a push THAT CONTAINS a COMMS file, not on a push. Wait on a version string
that differs, not on a page that changed. Both are shell tests with no model in them.

Three tiers, most events dying at the first: shell condition (free), fresh minimal agent (one small
cold read), escalate with real context (a full read of a long session). A context-inheriting fork that
returns nothing to the parent is tier two done well, but it is still a read, so it is the fallback for
when relevance needs judgement rather than the default.

For the platform this means a condition belongs in the declaration, not in the agent that wakes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:45:59 +00:00
pastilhasandClaude Opus 5 b9fce2aeb6 draft: waits as a primitive, not an agent-comms trick
The field report describes the watcher for one purpose — one agent waiting on another's push. That is
where it was discovered, not where it belongs. Same shape answers: wait for the job, wait for the
container, wait for the credential file, wait for a human to reply.

What this adds over the field report:

  the cost model as a formula   idle is free forever; a wait costs `fires x context-at-the-time`, and
                                every wake is uncached by construction because the prompt cache TTL is
                                ~5 min and nothing worth waiting for resolves that fast
  block > poll > model          most things Officer waits on can be blocked on rather than polled.
                                inotify for a file, tail --pid for a process, docker events, IMAP IDLE,
                                and — highest leverage and unbuilt — postgres LISTEN/NOTIFY, since
                                nearly everything here is already a row in one database
  an exit-code contract         0 fired / 1 timed out / 2 broke. 1 and 2 must not be conflated: "nothing
                                happened" and "I stopped being able to tell" are opposite facts, and
                                absence reads as reassurance
  lifetimes                     if the payload plus the repo is enough to act on, do not keep a session
                                alive to receive it. Event-spawned short-lived agents cost a constant
                                amount per event; resident ones cost more every time

Draft. One mechanism proven (the git poll, fired twice tonight); the contract and the Officer use-case
table are specification. Marked measured vs reasoned throughout.

On a branch, not master: the master checkout is pinned behind the remote while the server runs from it,
and pushing master would also trip the watcher currently armed on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:31:07 +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
122 changed files with 10551 additions and 1643 deletions
+38 -17
View File
@@ -1,18 +1,39 @@
# What officer-setup writes. Everything below this block is optional, or is on its way out.
PORT=9000
JWT_SECRET="<generate with: openssl rand -base64 32>"
BROWSER_RELAY_PORT=18792
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
MAIL_TRANSPORT="smtp://localhost:1025"
PUBLIC_URL=http://localhost:9000
# ── Moving to the secret store ─────────────────────────────────────────────────────────────────
# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without
# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key
# store (docs/secret-store.md), which is designed and not yet built, so an install made by the
# current script will not boot until it is. That is deliberate sequencing, not an oversight.
JWT_SECRET="<generate with: openssl rand -base64 32>"
# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key,
# encrypting every secret column in Postgres: Headscale admin API keys, app-store service
# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the
# owner passphrase that seals it.
#
# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the
# passphrase opens the inner envelope and this is the outer one.
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
# Where Officer is reached from a browser. Read by origin validation, the task API host check, and
# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https.
# PUBLIC_URL=https://officer.example.com
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
# "dev" or "development". Leave it unset or set it to "production" for a real deployment; only set
# it to "dev" on a local machine you trust, since that disables all three.
PUBLIC_BUILD_ENV=production
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
# only loads this file, so `bun dev` against a production .env runs fully hardened.
# PUBLIC_BUILD_ENV=dev
DATA_PATH=/path/to/data
OFFICER_ITEMS_DIR=/path/to/officer-items
HOME_DIR=/home/user
BROWSER_RELAY_PORT=18792
# 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 ────────────────────────────────────────────────────────────────────────────────────
# Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy
@@ -31,14 +52,14 @@ BROWSER_RELAY_PORT=18792
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
# X-API-Key on every forwarded request.
# Vaultwarden (officer-vault). VAULT_STORE_KEY encrypts stored secrets at rest — any strong secret
# of 16+ chars works, and CHANGING IT MAKES EXISTING STORED SECRETS UNREADABLE.
VAULTWARDEN_URL=http://127.0.0.1:8222
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
# Vaultwarden (officer-vault). VAULT_STORE_KEY is at the top of this file — it is the platform's
# key, not Vaultwarden's, however much the name and its old position here suggested otherwise.
# VAULTWARDEN_URL=http://127.0.0.1:8222
# Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which
# lives in the host env rather than here.
# ANTHROPIC_PROXY_PORT=5051
# The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see
# src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either:
# 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
# http://localhost:8899.
+7
View File
@@ -51,3 +51,10 @@ src/apps/officer-web/index.gen.html
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
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
+22 -11
View File
@@ -72,7 +72,7 @@ src/
│ └── landing/ # marketing landing page
├── servers/
│ ├── 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
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine
@@ -92,7 +92,15 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
## 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.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
@@ -134,14 +142,14 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
## Security Model
- `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
limiting and password rules all key off it — they fail closed.
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
- **`ALLOW_ANY_ORIGIN` defaults to ON** — origin checking is off unless the var is explicitly `false`.
A deliberate inversion of the usual rule, safe only because the perimeter is the tailnet and a valid
token is still required on every protected route. It is defence in depth that is currently switched
off, not the lock.
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
rules key off it — they fail closed.
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
what came out was documented defence in depth that was already switched off. Origin was never
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and 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`).
**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.
@@ -151,7 +159,7 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
### Capabilities — read this before mounting a router
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
`userMiddleware`.
@@ -379,6 +387,9 @@ explaining why it was safe.
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
`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/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
+22 -8
View File
@@ -14,24 +14,38 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Multi-user
- [ ] **`deprovisionOsAccount` does not exist, and deleting a member leaves their whole Linux side.**
`deleteUserHandler` removes the row and cascades the database; `userdel` never runs. Observed on the
production host on 2026-08-12: a member deleted through the UI kept a working login shell, a running
Postgres container and 454M of data, and their uid was free for the next `useradd` to reissue. Spec in
`docs/deprovision-os-account.md`. The ordering that matters: reap processes explicitly (`terminate-user`
does **not** reap a stale shell, and `userdel` fails while one lives), then `chown -R` to the service
user, then `userdel` — sever before release, and abort if the `chown` fails.
- [ ] **`deprovisionOsAccount` is written but has never run against a real account.** Landed 2026-08-12 in
`os-user-deprovision.ts` and wired into `deleteUserHandler`, which now refuses to delete the row when
the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards
(`guardDeletable`, `guardMemberTree`, `parseSubUidEntry`) have tests; the reap loop, the `chown -R`
sever and `userdel` have been exercised by nobody. `docs/deprovision-os-account.md` → "What is still
unproven" has the five-step validation, and it has to happen on the production host with a throwaway
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`
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
`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
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
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`
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
+250
View File
@@ -0,0 +1,250 @@
# Waits: how an agent waits for something without burning context
**Status:** draft, 2026-08-13. One mechanism proven (git remote polling, run twice); everything else here is
specification and reasoning. Claims are marked **measured** or **reasoned** — do not let that slip.
`docs/two-agent-field-report-2026-08-12.md` describes this for one purpose: one agent waiting on another's
push. That was where it was discovered, not where it belongs. This file is about the primitive itself,
because the same shape answers "wait for CI", "wait for the job to finish", "wait for the container to go
healthy", "wait for a reply", and a dozen other things Officer already needs.
---
## The primitive
> A **wait** is a harness-owned process that blocks until a condition holds, then exits — and whose exit
> re-invokes the agent.
Three properties. Drop any one and it breaks in a way that is not visible from watching it run:
1. **The waiting happens below the model.** No inference per tick. The agent is suspended.
2. **The harness owns the process**, so its exit is an event the harness delivers. A process the harness is
not tracking can finish perfectly and tell nobody.
3. **It exits when it has something to say.** The exit *is* the notification. A wait that detects and keeps
running has informed no one.
Everything below follows from those three.
---
## The cost model, which decides everything else
This is the part that is easy to get half-right, and half-right is what leads people to build the expensive
version.
| | cost |
|---|---|
| a tick while waiting | **nothing** — no model runs |
| a thousand ticks | **nothing** |
| **each wake** | a full context read, uncached |
**Measured** (field report, 2026-08-12): an idle watcher produced 85 bytes over seven minutes with zero
inference. **Measured** tonight: two fires, each costing exactly one wake.
**Reasoned, and the part usually missed:** a wake re-reads the entire conversation, and conversations only
grow. So the cost of a wait is not `duration` — it is `fires × context-at-the-time`. Idle is free forever;
the tenth notification in a long session costs several times the first.
Worse, waits are the exact workload the prompt cache cannot help. The TTL is about five minutes; anything
worth waiting for takes longer than that. **Every wake is an uncached read, by construction.**
Two consequences that should drive design:
- **Say less on wake.** The output that survives to the wake enters the context permanently. One line per
tick over 24h is 2,880 lines that land at once and then stay.
- **Prefer many short sessions to one long one.** A wait in a fresh session costs a constant amount per
event. The same wait in an immortal session costs monotonically more. This is the single strongest
argument for event-driven agents over resident ones.
---
## Prefer blocking over polling. Prefer events over both.
The git watcher polls because a git remote can only be *asked*. Most things Officer waits on are not like
that, and a poll is the worst of the three options that usually exist.
**Tier 1 — block on the kernel.** Zero syscalls while waiting, and detection is immediate rather than
average-half-an-interval late.
| waiting for | how to block |
|---|---|
| a file appearing or changing | `inotifywait -q -e close_write,create,moved_to <path>` |
| a process to exit | `tail --pid=<pid> -f /dev/null` |
| a lock to release | `flock <file> true` |
| a line on a pipe or log | `read -r line < <fifo>` |
| an inbound HTTP callback | a listener that blocks on `accept()` |
| whichever of several finishes first | `wait -n` over background pids |
**Tier 2 — block on the service.** Some services will hold a connection open and tell you.
| waiting for | how |
|---|---|
| a row to change | Postgres `LISTEN` / `NOTIFY` — the connection blocks, the database pushes |
| new mail | IMAP `IDLE` |
| a container to change state | `docker events --filter …` (streams, blocks) |
| a systemd unit | `systemctl --wait` / journal follow |
Officer keeps almost everything in one Postgres. `LISTEN`/`NOTIFY` is therefore the highest-leverage
unbuilt piece here: job completion, a new chat message, a status flip, all become blocking waits with no
polling anywhere.
**Tier 3 — poll, because the source can only be asked.** A git remote, a third-party HTTP API, a health
endpoint. Then the rules are: read-only calls (`git ls-remote`, never `git fetch` — a fetch mutates refs
under a working tree that may be mid-edit), a `timeout` on every call so a hung network call cannot leave
the wait alive and blind, and an interval matched to how fast the thing actually changes.
**Never poll in the model.** A scheduled wake-up, a `/loop 30s`, a "check every minute" — these are the same
shape wearing the same clothes and they pay a full uncached context read *per tick* to learn nothing. This
is the intuitive design and its expense is invisible, which is why it needs saying first.
---
## Make firing mean something
The rest of this file is about how to wait cheaply. This section is about the other half, and it is the one
that decides whether a fleet of these is affordable.
**Most waits find nothing, almost always.** A daily release check answers "no" 360 days a year. A branch
watcher wakes on every push, including everyone else's. So the number that matters is not the cost of a
useful wake — it is the cost of a useless one, multiplied by how many there will be.
The fix is not a cheaper wake. It is to **push the relevance test into the wait condition**, so that firing
already implies relevance:
- **Do not** wait on "a push", then wake and check whether it carries a `COMMS/<branch>/NN-*.md`. Wait on a
push *that contains one* — a filename test the shell can do with no model at all.
- **Do not** wait on "the releases page changed", then wake and read it. Wait on "the version string differs
from my cursor" — a string compare.
Three tiers, and almost everything should die at the first:
| tier | cost | for |
|---|---|---|
| **shell condition** | zero | anything expressible as a filename, a diff, a version, a status |
| **fresh minimal agent** | one small cold read | relevance genuinely needs judgement, but not history |
| **escalate with real context** | a full read of a long session | the event has to be interpreted against what came before |
A session fork that inherits context but returns nothing to it (Claude Code's `/btw`) is tier two done well.
It is still a context read, so it is the fallback when a shell test cannot express relevance — not the
default.
**Corollary for the platform:** a wait's condition should be part of its declaration, not something the agent
evaluates after waking. `wait for: push to <branch> touching COMMS/**` is a cheaper and more honest thing to
build than `wait for: push` plus an agent that decides.
## The contract a wait must honour
Specification. None of this is built yet.
**Exit codes are the vocabulary.**
```
0 fired — the condition holds; payload on stdout
1 timed out — the bounded lifetime elapsed, nothing happened
2 broke — the wait itself failed and is no longer trustworthy
```
`1` and `2` must be distinguishable. "Nothing happened" and "I stopped being able to tell" are opposite
facts and a wait that conflates them is worse than no wait, because absence reads as reassurance.
**Output is a payload, not a log.** One line on arm so there is a record of what was watched; silence while
waiting; a minimal structured payload on fire. Everything printed is permanent context.
**A cursor, persisted.** The wait is armed at a position — a SHA, a byte offset, a row id, a timestamp — and
that position belongs on disk, not only in the process. Then a re-arm after a restart neither misses events
nor re-reports old ones. The git watcher currently holds its base only in memory, which is why a session
restart loses the thread.
**Bounded lifetime, and the bound is not "forever".** `seq 1 2880` is a runaway backstop, not a policy. A
wait that times out should re-arm from its cursor rather than die silently.
**Liveness must be externally checkable.** A dead wait and a quiet one are indistinguishable, and that
ambiguity has already cost two missed pushes. Cheapest fix: touch a heartbeat file each tick, so `mtime`
answers "is it alive" without asking the process. In a UI that shows running processes — as Officer's chat
does — the chip itself is the signal, which is a real advantage and should be kept.
**Idempotent re-arm, and self-trip protection.** An agent that acts and then wakes on its own action is a
loop. Re-arm from the position *after* your own change, and never run two waits on the same condition.
---
## Where this applies in Officer
The reason to generalise. Each of these is a place something currently either blocks a turn, gets polled by
a human, or is discovered late.
| wait | tier | notes |
|---|---|---|
| a pipeline/script job finishes | 1 or 2 | `data/jobs/<id>.log` is a file — inotify. Or `NOTIFY` on the row |
| a download completes | 1 | same, and the progress sentinel already exists |
| a container becomes healthy | 2 | `docker events` |
| a member logs into `claude` for the first time | 1 | `~/.claude/.credentials.json` appearing — currently polled by `/agent-status` |
| new mail arrives | 2 | IMAP IDLE, in the email sidecar |
| CI, a deploy, a remote build | 3 | poll, with a timeout |
| a push to any repo | 3 today, **event tomorrow** | Gitea is ours: a webhook removes the wait entirely |
| a long `db:push` or migration finishes | 1 | process wait |
| disk crosses a threshold | 3 | slow-moving; poll infrequently |
| **a human replies** | 1 | an approval gate: the agent arms a wait and stops costing anything until answered |
That last row is the one worth dwelling on. An agent that needs a decision currently either blocks a session
or asks and forgets. A wait makes "stopped, pending your answer" cost nothing while it lasts.
---
## Choosing a lifetime
| shape | when | cost |
|---|---|---|
| **wait inside a live session** | the agent holds context the event needs interpreting against | free while idle, growing per fire |
| **wait, then hand off** | context matters up to the fire, not after | one growing session, then reset |
| **no wait — event spawns a fresh agent** | the event carries everything needed (a SHA, a job id) | constant per event, forever |
The third is the destination for anything recurring. The first is right for tonight's watcher, where the
value is that I already know what the commits mean.
The rule: **if the payload plus the repo is enough to act on, do not keep a session alive to receive it.**
---
## Failure modes
| pattern | what it looks like |
|---|---|
| **launched outside the harness** | `nohup … &` — runs, detects, exits, and no one is told. Looks perfect |
| **model-driven poll** | correct behaviour, full context read per tick |
| **detects but does not exit** | prints "found it" into a file nobody reads |
| **chatty** | per-tick output, deferred, all landing at once on wake |
| **silent death** | session restarts, wait dies, quiet branch and dead watcher look identical |
| **self-trip** | agent's own push wakes it, usually because an old wait was never stopped |
| **timeout mistaken for quiet** | exit 1 treated as "nothing happened" when it means "I stopped looking" |
| **mutating poll** | `git fetch` in a loop, moving refs under a working tree |
---
## Open questions
1. **Is a wait a platform feature or an agent habit?** Officer has a job runner, a Gitea instance and a
sidecar pattern. `POST /waits {condition, payload}` returning when it fires is a plausible platform
primitive — and would make waits available to capabilities, not only to agents.
2. **What arms a wait for an agent that is not running?** The webhook shape needs the platform to spawn the
agent, which is `send-claude-code` plus a trigger. Most of that exists.
3. **Should waits be declarative?** `wait for: file:<path>` / `pg:notify:<channel>` / `git:<remote>/<branch>`
— a small vocabulary compiled to the right tier, so nobody hand-writes a poll for something inotify could
have blocked on.
4. **How does a wait survive a session restart** without either missing its event or re-firing on an old
one? The cursor answers half of it; the other half is who re-arms.
5. **What is the right granularity of notification?** One wake per push, or one wake per batch after a quiet
period? Batching trades latency for context, and context is the scarce thing.
---
## Provenance
The mechanism, the three properties and the four wrong ways to launch it come from
`docs/two-agent-field-report-2026-08-12.md`, which recorded them after they were learned the hard way. What
this file adds is the cost model stated as a formula rather than an anecdote, the block-over-poll hierarchy,
the exit-code contract, and the argument that the destination is event-spawned short-lived agents rather
than resident ones.
Nothing in "the contract" or "where this applies" has been implemented. The only thing running today is a
tier-3 git poll, which is the good version of the wrong shape.
+105 -7
View File
@@ -1,11 +1,15 @@
# Deprovisioning a member's Linux account
**Status:** specification. Not implemented. Written from a manual teardown performed on the production host on
2026-08-11, so the ordering constraints below are measured rather than reasoned.
**Status:** implemented 2026-08-12 in `src/servers/os-user-deprovision.ts`, called by `deleteUserHandler`.
**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
per-user Claude" — the risk opens when a real person has an account that might later be deleted, which may or
may not be the same moment.
The spec is kept as written rather than rewritten in the past tense: it is the reasoning the implementation
has to keep satisfying, and the failure modes it names are still the ones a change would reintroduce.
---
@@ -111,6 +115,26 @@ keeping every byte.
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
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
@@ -145,9 +169,37 @@ All of these must hold for the freed uid *and* its freed subuid range:
- `/var/lib/systemd/linger/<user>` absent
- `/run/user/<uid>` absent
- 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
as a test.
`scripts/assert-uid-free.sh` implements exactly this, deliberately **outside** the function: a checker the
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
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
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.
+2 -1
View File
@@ -213,7 +213,8 @@ both, so the endpoint cannot be used to discover whether an id exists.
## 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
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.
@@ -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
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
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
rule anyway.
@@ -279,7 +279,12 @@ Three bugs surfaced only by running it:
also *unreachable*.
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
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`
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
+225
View File
@@ -0,0 +1,225 @@
# The secret store
**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the
current code was checked against the tree on that date; the file:line references are live.
A small SQLite database, created during setup, holding every encryption and signing key the platform
uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses
instead of inventing its own.
---
## 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, for now
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
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
problem this design exists to avoid.
`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere
documented loudly enough that a backup script excludes it deliberately.
### 4. One secret remains outside
The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the
whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the
two processes that need it.**
`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly
one value — or comes from a file read on demand.
### 5. What moves in
- `VAULT_STORE_KEY` — the at-rest key for everything in the table above.
- `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: `encryptSecret` / `decryptSecret`. Only where the key comes from
changes, so no caller is touched.
- 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
Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** `setup.sh` calls
`setup-dockers.sh`, which invokes `docker compose` with no preflight, so a fresh host without Docker
fails partway through setup with a bare "command not found".
Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** The host installer
`scripts/setup/setup.sh` calls `scripts/setup/setup-dockers.sh`, which invokes `docker compose` with no
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
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,7 +1,7 @@
// 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
// else. Paired with `OFFICER_PROFILE=light bash scripts/setup/setup.sh`, which installs only what these
// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
//
// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
@@ -1,6 +1,6 @@
// 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
// Paired with scripts/setup/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
// chat; nothing else.
//
// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this
@@ -24,6 +24,27 @@
* @param {string[]} spec.include app names to run, in start order
* @param {Record<string,string>} spec.excluded app name why it is not in this profile
*/
// The directory holding the platform's package.json, found by walking up from this file. Independent of
// where in the tree this config is kept, and of where pm2 was invoked from.
function repoRoot() {
const { existsSync, readFileSync } = require('node:fs');
const { dirname, join } = require('node:path');
let dir = __dirname;
for (;;) {
const manifest = join(dir, 'package.json');
if (existsSync(manifest)) {
try {
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir;
} catch {
// Unparseable is not ours; keep walking.
}
}
const up = dirname(dir);
if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname);
dir = up;
}
}
function defineProfile({ file, include, excluded }) {
const full = require('./ecosystem.config.cjs');
const byName = new Map(full.apps.map((app) => [app.name, app]));
@@ -48,9 +69,17 @@ function defineProfile({ file, include, excluded }) {
// `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 })) };
// back to the default PORT with no POSTGRES_URL.
//
// It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of
// the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not
// fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it.
//
// This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside
// ecosystem.config.cjs". That stopped being true the moment these files were moved into
// ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is
// true wherever this file ends up living.
return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) };
}
module.exports = { defineProfile };
+2 -2
View File
@@ -8,7 +8,7 @@
"src/workspaces/*"
],
"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",
"predev": "bun run ./scripts/gen-index.ts",
"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:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/setup.sh"
"setup": "bash scripts/setup/officer-setup.sh"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
-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 ---
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"
if [ -f "$ENV_FILE" ]; then
sed -i '/^VNC_PASSWORD=/d; /^VNC_PORT=/d' "$ENV_FILE"
-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);
});
-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
# 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.
# Usage: ./scripts/setup-desktop.sh
# Usage: ./scripts/setup/setup-desktop.sh
echo "=== Officer Remote Desktop Setup (Ubuntu GNOME on Xorg) ==="
echo ""
@@ -4,12 +4,14 @@
# Outputs parseable key=value lines to stdout; all prompts go to stderr.
#
# Usage:
# bash scripts/setup-dockers.sh
# eval "$(bash scripts/setup-dockers.sh)"
# bash scripts/setup/setup-dockers.sh
# eval "$(bash scripts/setup/setup-dockers.sh)"
#
# Environment overrides:
# SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none")
# 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
@@ -47,6 +49,44 @@ prompt_value() {
# ─── docker network ─────────────────────────────────────────────────────────
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
if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
@@ -94,6 +134,14 @@ MAILHOG_SELECTED=false
for svc in $SERVICES; do
case "$svc" in
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")
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
nginx-proxy-manager:
@@ -101,9 +149,9 @@ for svc in $SERVICES; do
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "81:81"
- "$NPM_BIND:80:80"
- "$NPM_BIND:443:443"
- "$NPM_BIND:81:81"
volumes:
- ./npm_data:/data
- ./npm_letsencrypt:/etc/letsencrypt
@@ -238,6 +286,10 @@ fi
# ─── output parseable values to stdout ───────────────────────────────────────
echo "COMPOSE_DIR=$COMPOSE_DIR"
if [[ " ${COMPOSE_SERVICES[*]} " == *" nginx-proxy-manager "* ]]; then
echo "NPM_BIND=$NPM_BIND"
fi
if [[ -n "$PG_PASSWORD" ]]; then
echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}"
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.
#
# Usage:
# bash scripts/setup.sh # full server install
# OFFICER_PROFILE=light bash scripts/setup.sh # light install
# bash scripts/setup/setup.sh # full server install
# OFFICER_PROFILE=light bash scripts/setup/setup.sh # light install
#
# PROFILES
# 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,
# 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,
# PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and
# the remote desktop. Of the Docker services only Postgres is brought up.
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, and Go.
# Of the Docker services only Postgres is brought up.
#
# 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
# 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
@@ -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
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
if [[ -n "${SUDO_USER:-}" ]]; then
@@ -135,7 +149,7 @@ if has make && has gcc; then skip "build tools (make, gcc, g++)"; else
esac
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
case $PM in
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
# 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
# them: those are host comforts, and the shell template treats each as optional.
# One static binary and one config file, which is why it survived the cull that removed the rest of the
# 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 "── Prompt (starship) ──"
@@ -529,9 +544,7 @@ else
fi
# 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
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
# wrote when there is.
# every run, silently. Converge when there is nothing to lose, keep what the user wrote when there is.
mkdir -p "$HOME/.config"
STARSHIP_DEST="$HOME/.config/starship.toml"
if [ ! -f "$STARSHIP_DEST" ]; then
@@ -540,17 +553,15 @@ if [ ! -f "$STARSHIP_DEST" ]; then
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
skip "starship config"
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
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
# terminal and a chat.
# Go is a host comfort rather than anything the app needs to serve a file browser, a terminal and a
# chat, so `light` skips it. It is the only section left in this block — 8-13 were removed or moved.
if is_light; then
echo ""
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp"
omit "Go"
else
# ─── 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
fi
# ─── 8. Rust ──────────────────────────────────────────────────────────────────
echo ""
echo "── Rust ──"
# 8 Rust, 9 PulseAudio, 10 cliamp and 13 yt-dlp are in setup-sidecars.sh.
# 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit) and 14 npm globals are gone entirely — the host
# 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
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
fi # end of the light-profile skip, which is now section 7 alone
# ─── 9. PulseAudio (headless audio for cliamp) ────────────────────────────────
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
# ─── 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
# Kept from the removed section 14: nothing here installs into ~/.local/bin any more, but section 19
# still asks `has pm2` and the agent still resolves `claude` off PATH. A host that installed either
# user-locally would otherwise look like it has neither.
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) ──────────────────────────────────
echo ""
echo "── Project dependencies ──"
@@ -998,34 +748,7 @@ ENVFILE
ok ".env written to $PROJECT_DIR/.env"
fi
# ─── 17. remote desktop (Ubuntu Desktop + VNC) ───────────────────────────────
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
# 17 remote desktop is in setup-sidecars.sh.
# ─── 18. project initialization ──────────────────────────────────────────────
echo ""
@@ -1113,17 +836,13 @@ check gcc
echo ""
echo "Dev tools:"
# Only the ones the light profile actually installs are checked under it — reporting Go and cliamp as
# NOT FOUND on an install that deliberately skipped them makes a clean run look broken.
# Only what this script still installs is checked. Reporting Go as NOT FOUND on a light install that
# 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
check go
check rustc
check cargo
check nvim
check starship
check lazygit
check eza
fi
check starship
check zsh
check rg
check fd
@@ -1134,21 +853,15 @@ check tree
check btop
check sqlite3
if ! is_light; then
echo ""
echo "Audio (cliamp):"
check pulseaudio
check parec
check pactl
check cliamp
fi
# Neither of these is installed here any more — they come from the host provisioning. They are still
# checked because section 19 and every chat turn depend on them, and "NOT FOUND" here is the only
# warning you get before the services silently do not start.
echo ""
echo "AI agents:"
echo "AI agents (from host provisioning):"
check claude
echo ""
echo "Process manager:"
echo "Process manager (from host provisioning):"
check pm2
echo ""
@@ -1158,7 +871,6 @@ check 7z
check unrar
check pgrep
check fuser
if ! is_light; then check yt-dlp; fi
echo ""
echo "═══════════════════════════════════════════"
@@ -1186,8 +898,9 @@ fi
echo ""
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 ~/.cargo/bin is in your PATH for Rust tools"
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 ""
@@ -1,14 +1,14 @@
#!/bin/bash
# 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,
# and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop,
# no sudoers grant, no power-management changes.
#
# 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_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
# 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
@@ -48,7 +48,10 @@ FAILURES=()
note_failure() { FAILURES+=("$1"); fail "$1"; }
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_DATABASE="officer_dev"
@@ -103,7 +106,7 @@ echo "════════════════════════
step "Preflight"
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
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 " 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 " • 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 ""
+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@'
+621
View File
@@ -0,0 +1,621 @@
#!/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:-}"
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() {
# 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.
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() {
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" ]]; }
+398
View File
@@ -0,0 +1,398 @@
#!/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.
sudo -H -u "$USERNAME" sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
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.
set_login_shell() {
local shell="$1"
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
chsh -s "$shell" "$USERNAME"
# Returns 0 whatever happens. This is an optional improvement, and a
# function that ends on a failing command is fatal under `set -e` when it
# is called as a plain command — which would abort the remaining sections
# over something the run could simply report. The caller checks the outcome.
return 0
}
# -----------------------------------------------------------------------------
# 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
}
+112
View File
@@ -0,0 +1,112 @@
#!/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() {
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
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}
# 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
}
+127
View File
@@ -0,0 +1,127 @@
#!/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"
return 0
fi
cmp -s "$src" "$dest" && return 1
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)"
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"
return 2
fi
case "${answer:-1}" in
1)
echo " keeping yours"
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"
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"
}
+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"
}
+264
View File
@@ -0,0 +1,264 @@
#!/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, lsb-release and software-properties-common are not
# tools — they are what lets later steps add the Docker repository and the
# fastfetch PPA. They have no counterpart on the other systems.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools \
fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools \
fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools \
fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula.
echo gnupg git jq wget btop htop tree tmux ripgrep fd
;;
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 || return 0
pkg_install_now "${missing[@]}"
}
# 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
}
+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,299 @@
#!/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 }'
}
tailscale_install() { 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 }'
}
+126
View File
@@ -0,0 +1,126 @@
#!/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.
tools_default() { echo lazydocker lazygit starship fastfetch; }
# The command that proves a tool is already here. Same as the tool name for all
# four today, but kept as a mapping because that is not a rule — a package and
# 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 ;;
fastfetch) echo fastfetch ;;
*) 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
}
# A distribution package everywhere, but not always one the distribution ships:
# Ubuntu only picked fastfetch up in 24.10, so on noble and older the PPA is the
# only source. Checked rather than assumed, so the PPA stops being added the
# moment the archive has it.
tool_install_fastfetch() {
if [[ "$PM" == "apt" ]] && ! apt-cache policy fastfetch 2>/dev/null | grep -q 'Candidate: [0-9]'; then
info " fastfetch is not in this release's archive — adding the upstream PPA"
add-apt-repository -y ppa:zhangsongcui3371/fastfetch >/dev/null 2>&1 ||
{
warn "could not add the fastfetch PPA — skipping"
return 0
}
pkg_refresh >/dev/null 2>&1
fi
pkg_install_now fastfetch
}
# -----------------------------------------------------------------------------
# Acting
# -----------------------------------------------------------------------------
# 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
+508
View File
@@ -0,0 +1,508 @@
#!/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=""
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
ONLY_STEP="${2:-}"
shift 2
;;
--only=*)
ONLY_STEP="${1#*=}"
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0
;;
-h | --help)
echo "usage: officer-setup.sh [--only <step>] [--list]"
exit 0
;;
*) echo "unknown option: $1" >&2 && exit 2 ;;
esac
done
# shellcheck source=officer-setup/lib/base.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"
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}"
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./officer-setup.sh"
fi
# ── 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}"
# ── 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.
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
# =============================================================================
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
# =============================================================================
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.
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
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
# =============================================================================
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_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
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}"
ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}"
echo ""
echo " to write:"
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
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}"
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
SUMMARY+=("Environment: $(env_file)")
else
warn "skipped by request"
SUMMARY+=("Environment: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# NOT BUILT YET
# =============================================================================
# 6 Schema db:push
# 7 Build gen:index
# 8 Services pm2 startOrRestart · save · startup
# 9 Verify are the processes actually up
echo ""
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
echo ""
+132
View File
@@ -0,0 +1,132 @@
#!/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=""
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"); }
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# =============================================================================
# officer-setup — the environment file
# =============================================================================
#
# Definitions only.
#
# ── What is NOT here ──
#
# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite
# key store (docs/secret-store.md), and writing them here in the meantime would
# mean generating a value that the store then has to be reconciled with — two
# origins for one secret, which is the failure the store exists to end.
#
# The consequence is honest and deliberate: jwt.ts throws at module load without
# JWT_SECRET, so an install made by this script does not boot until the store
# lands. That sequencing was chosen rather than stumbled into.
#
# ── 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}"
# The browser relay listens on its own port, separate from the app.
BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
POSTGRES_URL="${POSTGRES_URL}"
ENVF
umask "$prior_umask"
chown "${USERNAME}:$(user_group)" "$dest"
chmod 600 "$dest"
return 0
}
+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)
}
+153
View File
@@ -0,0 +1,153 @@
#!/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}"
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}"
}
+82
View File
@@ -0,0 +1,82 @@
#!/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
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"; }
@@ -32,8 +32,6 @@ type ManagedUser = {
type UsersResponse = {
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. */
roles: string[];
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
@@ -183,7 +181,7 @@ export const UsersSection = () => {
{/* 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
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
{data.osUsersEnabled && !user.isOwner && (
{!user.isOwner && (
<Button
variant="ghost"
size="icon"
+8 -3
View File
@@ -3,6 +3,8 @@ import type { ServerWebSocket } from 'bun';
import { serve } from 'bun';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertCapabilityTotality } from './servers/capabilities/totality';
import { assertInstallLayout } from './servers/data-path';
import { PORT } from './servers/officer-url.mjs';
import { assertSecretsClosed } from './servers/os-user';
import { resolveHomeDir } from './servers/user-home';
import { resolveAuthToken } from './servers/auth-token';
@@ -22,7 +24,6 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
import { toShellUsername } from './servers/data-path';
const { PORT = '5000' } = process.env;
// Build static file routes from public/
const publicRoutes: Record<string, (req: Request) => Response> = {};
@@ -151,9 +152,13 @@ assertCapabilityTotality({
wsProviders: Object.keys(handlers),
});
// That the working directory is the repo, because every path derives from its parent. First of the three,
// since a wrong answer here makes the other two check the wrong files.
assertInstallLayout();
// And, when members have real Linux accounts, that they cannot read the credentials that would make those
// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable
// JWT_SECRET is worse than no isolation, because the model looks intact. No-op while the feature is off.
// JWT_SECRET is worse than no isolation, because the model looks intact.
await assertSecretsClosed(process.cwd());
async function upgradeWs(
@@ -245,7 +250,7 @@ async function upgradeWs(
}
const server = serve({
port: Number(PORT),
port: PORT,
idleTimeout: 60,
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
routes: {
@@ -0,0 +1,51 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../capabilities/authorize';
import { isExemptApiPath } from '../capabilities/totality';
// The global authorization gate: a valid NON-owner token may reach only what its ROLE has been granted.
//
// Mounted in hono.ts ahead of every router, and it re-verifies the token itself rather than trusting an
// earlier middleware — so it covers routes that never mount `userMiddleware`, which is most of the reason
// it exists. See auth-token.ts: the caller resolved here must be the same caller `userMiddleware` would
// resolve, or the two doors disagree about who someone is.
//
// A missing or invalid token passes straight through. Signin needs that, and `userMiddleware` rejects bad
// tokens on the protected routes. Only a VALID non-owner token is constrained here.
//
// ── What this file used to be ──
//
// This was `originScopeMiddleware`, the second half of `origin-validation.ts`, which also enforced
// per-origin path scoping: an app shipping a custom-scheme origin (`officer://<hex>` via
// OFFICER_<APP>_ORIGIN) could reach only `/api/auth` plus its own feature.
//
// All of that is gone as of 2026-08-13, along with ALLOW_ANY_ORIGIN and ALLOW_ANY_ORIGIN_MUSIC. Origin
// was never authentication here — the custom-scheme origin is chosen by the client, forgeable outside a
// browser, and extractable from a shipped app binary — and the flag that disabled it defaulted to ON, so
// on every real install none of it ran. It was documented as defence in depth that was switched off.
//
// This half was deliberately NOT under that flag, because it is account-based rather than origin-based.
// Its old comment called it "the airtight half", and separating the two is why the name changed: nothing
// in here looks at an Origin header any more.
export const capabilityGateMiddleware: MiddlewareHandler = async function (ctx, next) {
const path = ctx.req.path;
const authorization = ctx.req.header('authorization');
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
const payload = token ? await resolveAuthToken(token) : null;
const isOwner = payload ? await isSuperAdmin(payload) : false;
// Exempt paths (signin, the public pages, the Bitwarden door) are skipped because they are served above
// the account gate — the same list the boot check uses, deliberately.
//
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
// platform account, so there is nothing here to resolve.
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
}
return next();
};
+1 -1
View File
@@ -1,7 +1,7 @@
export * from './body-parser';
export * from './user-middleware';
export * from './origin-middleware';
export * from './origin-validation';
export * from './capability-gate';
export * from './rate-limiter';
export * from './known-users';
export * from './auth-audit';
@@ -1,37 +0,0 @@
import { test, expect } from 'bun:test';
// origin-validation reads PUBLIC_URL / PUBLIC_BUILD_ENV at module load, so set them before importing.
process.env.PUBLIC_URL = 'https://officer.example.com';
process.env.PUBLIC_BUILD_ENV = 'production';
// Bun loads the host's .env into tests, so an operational kill switch left on there would silently turn
// these assertions into no-ops — which is exactly what ALLOW_ANY_ORIGIN=true did. Pin the escape hatches
// off: this file's whole job is asserting that the checks reject things.
process.env.ALLOW_ANY_ORIGIN = 'false';
process.env.ALLOW_ANY_ORIGIN_MUSIC = 'false';
const { isOriginAllowed } = await import('./origin-validation');
test('accepts the configured origin', () => {
expect(isOriginAllowed('https://officer.example.com', 'officer.example.com')).toBe(true);
});
test('accepts the Host forwarded by the reverse proxy when there is no Origin', () => {
expect(isOriginAllowed(undefined, 'officer.example.com')).toBe(true);
});
test('rejects a foreign Origin', () => {
expect(isOriginAllowed('https://evil.com', 'officer.example.com')).toBe(false);
expect(isOriginAllowed('https://officer.example.com.evil.com', 'officer.example.com')).toBe(false);
});
// Regression: the Host branch used `configuredOrigin.endsWith(host)`, so any suffix of the origin —
// down to a bare TLD — authenticated as the real host.
test('rejects Hosts that are merely suffixes of the configured origin', () => {
for (const host of ['com', 'example.com', 'r.example.com', 'ficer.example.com', 'evil.com']) {
expect(isOriginAllowed(undefined, host)).toBe(false);
}
});
test('rejects a request carrying neither Origin nor Host', () => {
expect(isOriginAllowed(undefined, undefined)).toBe(false);
});
@@ -1,220 +0,0 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { IS_DEV_BUILD } from '../build-env';
import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../capabilities/authorize';
import { isExemptApiPath } from '../capabilities/totality';
const { PUBLIC_URL } = process.env;
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
// not a hardcoded domain.
const PUBLIC_ORIGIN = (() => {
try {
return PUBLIC_URL ? new URL(PUBLIC_URL).origin : undefined;
} catch {
return undefined;
}
})();
const WEB_ORIGINS: string[] = PUBLIC_ORIGIN ? [PUBLIC_ORIGIN] : [];
// Host authorities (`example.com`, or `example.com:8080` off the default port) for the same origins.
// Officer always sits behind an HTTPS reverse proxy, so the proxy's `Host` header is expected to
// match PUBLIC_URL's authority exactly.
const WEB_HOSTS: string[] = WEB_ORIGINS.map((o) => new URL(o).host);
const CHROME_EXTENSIONS: string[] = [
// 'chrome-extension://<id>'
];
// Every `OFFICER_<APP>_ORIGIN` in the environment is an allowed app origin — each app ships a
// custom-scheme origin with an embedded token (`officer://<hex>`), set on the host, never in the repo.
// Adding an app is adding an env var; no code change, which is the point. The slug is what sits between
// the two fixed words: OFFICER_MUSIC_ORIGIN → MUSIC.
const APP_ORIGIN_VAR = /^OFFICER_([A-Z0-9_]+)_ORIGIN$/;
// Apps that are the whole platform rather than one feature of it, so they get NO path scoping — the main
// Officer app needs every prefix the web app needs.
//
// These used to be `superAdminOnly`, which refused a non-owner outright, here and at signin. That was
// correct while single-user was the invariant and the only non-owner accounts were music-app accounts:
// there was no way to express "this person may use the platform, but only these parts of it", so the
// honest answer was to keep them out of it entirely.
//
// Capabilities express exactly that, per feature, at both doors. So the blunt version is gone — a member
// signs into the web app and sees what their role was granted. Keeping both would mean a member who has
// been granted Gitea still cannot reach the page, which is not a second layer of defence, just a bug.
const PLATFORM_APPS = new Set(['APP']);
// Where an app's API surface isn't `/api/<slug>`. Only exceptions belong here.
const APP_SCOPE_OVERRIDES: Record<string, string[]> = {
// OffTail signs in and mints a Headscale pre-auth key. The VPN's own traffic goes straight to
// Headscale and never through /api, so this is its entire platform surface.
TAIL: ['/api/vpn'],
};
type AppOrigin = { slug: string; origin: string };
const APP_ORIGIN_LIST: AppOrigin[] = Object.entries(process.env).flatMap(([key, value]) => {
const slug = key.match(APP_ORIGIN_VAR)?.[1];
return slug && value ? [{ slug, origin: value }] : [];
});
const APP_ORIGINS: string[] = APP_ORIGIN_LIST.map((a) => a.origin);
// The account backstop used to live here as two hand-written lists: NON_OWNER_PATHS, confining every
// non-owner to '/api/auth' + '/api/music', and NON_OWNER_WS_PROVIDERS doing the same for sockets. Both
// are gone, replaced by the capability registry (src/servers/capabilities/).
//
// They were not wrong, they were unscalable in one specific way: a hardcoded allow-list answers "which
// paths" but never "why", so onboarding anyone who needed anything other than music meant editing an
// array in a middleware file and hoping the socket half got edited too. The registry makes the two doors
// read the same declaration, and the boot-time totality check makes a THIRD door impossible to add
// without noticing. See capabilities/totality.ts for the incident that motivated it.
function pathAllowed(path: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}
// Per-origin access rules, enforced globally by originScopeMiddleware (mounted in hono.ts):
// - paths: this Origin may reach ONLY these path prefixes; anything else is 403.
// Origins with no rule keep full access. Rules no-op for env values that are unset.
//
// App rules are derived, not written: an app may reach /api/auth (it has to sign in) plus the one
// feature it is named for — OFFICER_VAULT_ORIGIN gets /api/auth + /api/vault.
type OriginRule = { paths?: string[] };
const ORIGIN_RULES: Record<string, OriginRule> = {};
for (const { slug, origin } of APP_ORIGIN_LIST) {
ORIGIN_RULES[origin] = PLATFORM_APPS.has(slug)
? {} // no path scoping; the capability backstop is what limits a platform app's caller
: { paths: ['/api/auth', ...(APP_SCOPE_OVERRIDES[slug] ?? [`/api/${slug.toLowerCase()}`])] };
}
// Last, so the web origin wins if an app ever declares the same one. No path scoping, for the same reason
// as PLATFORM_APPS above: the web app is the whole platform, and what its caller may reach is decided by
// their capabilities rather than by their Origin.
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = {};
// ── TEMPORARY: origin checking switched off ──
// ALLOW_ANY_ORIGIN=true accepts every Origin, everywhere, and skips the per-origin path scoping.
// ALLOW_ANY_ORIGIN_MUSIC=true is the narrower version, /api/music only — which is not enough on its own,
// because an app has to reach /api/auth to sign in before it ever calls its own feature.
//
// What still holds with these on: every protected route requires a valid token (userMiddleware), and the
// capability backstop below still confines a non-owner account to what its ROLE has been granted,
// whatever Origin it claims — that one is deliberately NOT disabled, since it is account-based, not
// origin-based.
//
// What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>`
// is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary.
// Both flags and their call sites come out once the tailnet is the perimeter.
// Defaults to ON — origin checking is off unless ALLOW_ANY_ORIGIN is explicitly 'false'. That is a
// deliberate inversion of the usual fail-closed rule, and it is safe only because of where this runs: the
// perimeter is the tailnet, devices are admitted by hand, and a valid token is still required on every
// protected route. Set ALLOW_ANY_ORIGIN=false to put the checks back.
const ALLOW_ANY_ORIGIN = (process.env.ALLOW_ANY_ORIGIN ?? 'true') !== 'false';
const ALLOW_ANY_ORIGIN_MUSIC = process.env.ALLOW_ANY_ORIGIN_MUSIC === 'true';
export function isOriginCheckDisabled(path?: string): boolean {
if (ALLOW_ANY_ORIGIN) return true;
return ALLOW_ANY_ORIGIN_MUSIC && !!path && pathAllowed(path, ['/api/music']);
}
/** @deprecated use isOriginCheckDisabled — kept so existing call sites read the same. */
export const isMusicOriginExempt = isOriginCheckDisabled;
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
if (IS_DEV_BUILD) return true;
if (ALLOW_ANY_ORIGIN) return true;
if (origin) {
if (origin.startsWith('chrome-extension://')) {
return CHROME_EXTENSIONS.includes(origin);
}
if (APP_ORIGINS.includes(origin)) {
return true;
}
return WEB_ORIGINS.includes(origin);
}
if (host) {
return WEB_HOSTS.includes(host);
}
return false;
}
export const originValidationMiddleware: MiddlewareHandler = function (ctx, next) {
const origin = ctx.get('origin') as string | undefined;
const host = ctx.req.header('host');
if (!isOriginAllowed(origin, host)) {
throw errors.FORBIDDEN('Invalid origin');
}
return next();
};
function resolveOrigin(headerOrigin: string | undefined, referer: string | undefined): string | undefined {
if (headerOrigin) return headerOrigin;
if (referer) {
try {
return new URL(referer).origin;
} catch {
return undefined;
}
}
return undefined;
}
// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
// alike — regardless of which routers mount originMiddleware. Two layers:
// 1. Capability backstop (origin-INDEPENDENT): a valid NON-owner token may reach only what its ROLE
// has been granted, no matter the origin. This is the airtight rule — it holds even if a client
// omits or forges the Origin header. It replaced a hardcoded "/api/auth + /api/music" list on
// 2026-08-07; that list was why a Member could not reach /api/gitea and no UI could change it.
// 2. Per-origin rules (ORIGIN_RULES): path scoping for the single-feature apps. Redundant with the
// backstop for the account dimension, but blocks unknown-path access from an app origin.
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
// protected routes). Only a VALID non-owner token is constrained.
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
const path = ctx.req.path;
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
// Resolve the caller once (if any); a missing/invalid/revoked credential stays null. This goes through
// the shared resolver rather than verifying a JWT here, so an API key is the same caller at this door as
// it is at userMiddleware — the two must never disagree about who someone is. See auth-token.ts.
const authorization = ctx.req.header('authorization');
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
const payload = token ? await resolveAuthToken(token) : null;
const isOwner = payload ? await isSuperAdmin(payload) : false;
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
// non-owner token may reach only what its ROLE has been granted, whatever Origin it claims and whether
// or not it sends one. Exempt paths (signin, the public pages, the Bitwarden door) are skipped because
// they are served above the account gate — the same list the boot check uses, deliberately.
//
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
// platform account, so there is nothing here to resolve.
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
}
// 2. Per-origin rules. Skipped entirely while origin checking is off (the account backstop above is
// account-based, not origin-based, so it deliberately still applies).
if (isOriginCheckDisabled(path)) return next();
const rule = origin ? ORIGIN_RULES[origin] : undefined;
if (rule) {
if (rule.paths && !pathAllowed(path, rule.paths)) {
throw errors.FORBIDDEN('Origin not permitted for this resource');
}
}
return next();
};
+3 -11
View File
@@ -1,7 +1,6 @@
import type { MiddlewareHandler } from 'hono';
import { resolveAuthToken } from '@@/auth-token';
import * as errors from '@@/custom-errors';
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
import { isLockdown, noteBlocked } from '../api/auth/panic';
import { getUserById, isTokenBlacklisted } from 'officerdb';
@@ -24,16 +23,9 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
}
if (!token) throw errors.UNAUTHORIZED();
// Validate origin for authenticated routes.
// TEMPORARY, opt-in: ALLOW_ANY_ORIGIN_MUSIC=true drops the Origin check for /api/music only, so a
// client that can't present the app's custom-scheme origin can still reach the music API. Auth is
// untouched — a valid token is still required, and the non-owner account backstop in
// originScopeMiddleware still applies. Delete this and the env var once the tailnet is the perimeter.
const origin = ctx.get('origin') as string | undefined;
const host = ctx.req.header('host');
if (!isMusicOriginExempt(ctx.req.path) && !isOriginAllowed(origin, host)) {
throw errors.FORBIDDEN('Invalid origin');
}
// There was an Origin check here until 2026-08-13. It is gone with the rest of origin validation —
// it had defaulted to off, so it ran on no real install. A valid token is required below, and the
// capability gate in hono.ts confines a non-owner to what their role grants.
try {
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
+4 -2
View File
@@ -12,8 +12,10 @@ import { parseTailLine } from './progress';
export const activityRouter = createRouter();
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? '';
import { DATA_PATH } from '../../data-path';
import { homedir } from 'node:os';
const HOME_DIR = homedir();
const ANNOUNCED_PATH = join(DATA_PATH, 'activity', 'announced.json');
const ALLOWED_ROOTS = ['/tmp', DATA_PATH, HOME_DIR].filter(Boolean);
const ACTIVE_WINDOW_MS = 120_000; // a task file touched within this is considered "active"
+3 -3
View File
@@ -2,7 +2,6 @@ import { createRouter } from '../../create-router';
import {
authAudit,
originMiddleware,
originValidationMiddleware,
userMiddleware,
bodyParser,
signinRateLimiter,
@@ -25,10 +24,11 @@ export const authRouter = createRouter();
authRouter.use(bodyParser());
// After the body parser (it reads the claimed identity out of the body) and before everything else, so
// that a probe rejected by origin validation or the rate limiter is recorded too. Observes only.
// that a probe rejected by the rate limiter is recorded too. Observes only.
authRouter.use(authAudit);
// Extracts the Origin (or derives it from Referer) for the handlers that log it. Not a check — origin
// validation was removed on 2026-08-13.
authRouter.use(originMiddleware);
authRouter.use(originValidationMiddleware);
authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
+6 -5
View File
@@ -10,11 +10,12 @@ const TEST_USERS: number[] = [];
export const signinHandler: Handler = async function (ctx) {
const { email, password } = ctx.get('body');
// A caller that sends neither Origin nor Referer — a server-to-server client, curl — leaves this
// undefined. It used to be unreachable: originValidationMiddleware rejected those requests before this
// handler ran, so the value was always a string by the time anything touched it. That is no longer
// guaranteed (ALLOW_ANY_ORIGIN lets them through), and `undefined` reached both a SQL parameter and
// `.startsWith`. Normalising to '' keeps every downstream use honest: no passkey is registered against
// the empty origin, so an origin-less caller falls through to password auth, which is what it wants.
// undefined. It was once unreachable: origin validation rejected those requests before this handler
// ran, so the value was always a string by the time anything touched it. Then the flag that disabled
// that check defaulted to on, and `undefined` reached both a SQL parameter and `.startsWith`. Origin
// validation is gone entirely as of 2026-08-13, so undefined is now the ordinary case rather than the
// edge one. Normalising to '' keeps every downstream use honest: no passkey is registered against the
// empty origin, so an origin-less caller falls through to password auth, which is what it wants.
const origin = (ctx.get('origin') as string | undefined) ?? '';
// Panic lockdown active → refuse all logins (looks like a normal failed login).
+10 -66
View File
@@ -3,7 +3,15 @@ import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { DATA_PATH } from '@@/data-path';
const DISCORD_WEBHOOK_URL = process.env.DISCORD_BUG_REPORT_WEBHOOK;
// Bug reports land on disk and nowhere else.
//
// There was a Discord webhook here until 2026-08-13, behind DISCORD_BUG_REPORT_WEBHOOK. It was a
// personal notification channel living in deployment config, on a self-hosted platform whose owner is
// the only person filing reports — and it was never in .env.example, so the setup script wrote a
// variable nothing documented.
//
// Nothing is lost from the report itself: the disk write below always happened first, and the webhook
// was only a ping about it.
export const bugReportRouter = createRouter();
@@ -36,73 +44,9 @@ bugReportRouter.post('/', async (ctx) => {
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
let screenshotBuffer: Buffer | null = null;
if (screenshot instanceof File) {
screenshotBuffer = Buffer.from(await screenshot.arrayBuffer());
await Bun.write(join(reportDir, 'screenshot.png'), screenshotBuffer);
}
if (DISCORD_WEBHOOK_URL) {
await sendToDiscord(report, screenshotBuffer);
await Bun.write(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
}
return ctx.json({ ok: true, id: dirName });
});
type BugReport = {
description: string;
context: {
url?: string;
userAgent?: string;
viewport?: { width: number; height: number };
apiError?: { status: number; message: string } | null;
} | null;
reporter: { id: number; email: string; name: string | null };
createdAt: string;
};
async function sendToDiscord(report: BugReport, screenshot: Buffer | null) {
const embed = {
title: 'Bug Report',
description: report.description,
color: 0xed4245,
fields: [
{ name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true },
{ name: 'URL', value: report.context?.url ?? 'N/A', inline: false },
{
name: 'Viewport',
value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A',
inline: true,
},
{ name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true },
],
timestamp: report.createdAt,
};
if (report.context?.apiError) {
embed.fields.push({
name: 'Last API Error',
value: `${report.context.apiError.status}: ${report.context.apiError.message}`,
inline: false,
});
}
const form = new FormData();
form.append('payload_json', JSON.stringify({ embeds: [embed] }));
if (screenshot) {
form.append('files[0]', new Blob([new Uint8Array(screenshot)], { type: 'image/png' }), 'screenshot.png');
}
const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form });
if (!res.ok) {
console.error('[bug-report] Discord webhook failed:', res.status, await res.text());
}
}
function shortenUA(ua?: string): string {
if (!ua) return 'N/A';
const browser = ua.match(/(Chrome|Firefox|Safari|Edge|Brave|OPR)\/[\d.]+/)?.[0] ?? '';
const os = ua.match(/\(([^)]+)\)/)?.[1]?.split(';')[0] ?? '';
return [browser, os].filter(Boolean).join(' — ') || ua.slice(0, 80);
}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from 'officerdb';
import { introduceAgentPanel } from '../agent-handoff/deliver';
import { logger } from './logger';
import { OFFICER_API_URL } from '../../officer-url.mjs';
/**
* The browser's half of the address book: name a panel, look up what a panel is, rename, remove.
@@ -23,7 +24,7 @@ import { logger } from './logger';
/** A name has to survive being typed into a prompt and into a shell, so keep it boring. */
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$/;
const API_ORIGIN = `http://127.0.0.1:${process.env.PORT ?? '5000'}`;
const API_ORIGIN = OFFICER_API_URL;
export function registerAgentPanelRoutes(router: Hono<any>): void {
// GET /chat/agent-panels?dashboardId=… — the address book for one dashboard.
+4 -3
View File
@@ -31,9 +31,10 @@ import { readSttConfig } from '../server-settings/stt';
/**
* Whose transcripts a request may read.
*
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument whenever
* HOME_DIR is set, which is how every read in this router used to resolve to the owner's `~/.claude` no matter
* who asked. Throws rather than falling back, for the same reason `resolveTurnIdentity` refuses: there is no
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument and
* always answers the owner, which is how every read in this router used to resolve to the owner's
* `~/.claude` no matter who asked. Throws rather than falling back, for the same reason
* `resolveTurnIdentity` refuses: there is no
* safe home to substitute, and the owner's is the one wrong answer.
*
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
+2 -2
View File
@@ -23,8 +23,8 @@ import { DATA_PATH } from '../../data-path';
//
// ── Why this takes a home instead of an email ──
//
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever
// HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discarded its argument
// whenever HOME_DIR was set — which was always, on a real install. Every read therefore resolved to the OWNER'S
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
// that were a property rather than an assumption. A member reaching these functions would have been handed the
// owner's conversation list.
+1 -1
View File
@@ -86,7 +86,7 @@ const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
/**
* Where a turn runs, relative to the caller's own home.
*
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set — so every
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument — so every
* `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is
* the only account" as though that were a property rather than an assumption.
*
+2 -2
View File
@@ -10,6 +10,7 @@ import { resolveBaseCwd } from '../chat/websocket';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import type { TurnMessage, MessageCost } from '../chat/types';
import * as jobManager from './pipeline-job-manager';
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
const DEFAULT_MODEL = 'claude-code';
@@ -96,9 +97,8 @@ type RunStepParams = {
};
async function refreshProxyToken(): Promise<void> {
const port = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
try {
await fetch(`http://127.0.0.1:${port}/refresh`, { method: 'POST' });
await fetch(`${ANTHROPIC_PROXY_URL}/refresh`, { method: 'POST' });
} catch {
// Best effort — proxy may not be running (e.g. using API key directly)
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { sign } from '@@/jwt';
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
const { PORT = '5000', PUBLIC_URL } = process.env;
const { PUBLIC_URL } = process.env;
const PUBLIC_HOST = (() => {
try {
@@ -24,7 +25,7 @@ type TaskApiUser = { id: number; email: string; username?: string | null };
export async function buildTaskApiEnv(user: TaskApiUser): Promise<Record<string, string>> {
const token = await sign({ id: user.id, email: user.email, username: user.username ?? '' }, '12h');
return {
OFFICER_API_URL: `http://127.0.0.1:${PORT}`,
OFFICER_API_URL,
OFFICER_API_HOST: PUBLIC_HOST ?? `127.0.0.1:${PORT}`,
OFFICER_AUTH_TOKEN: token,
};
+1 -1
View File
@@ -13,7 +13,7 @@ import { capabilityAvailability } from '../../app-store/availability';
// `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the
// app registry and the route guards all read it, so it is the frontend's whole view of the permission
// model — and it must never be the frontend's ENFORCEMENT of it. Hiding a dock icon is a courtesy; the
// 403 in origin-validation is the lock.
// 403 from the capability gate is the lock.
//
// Everything else here is owner-only and edits the policy itself.
+1 -4
View File
@@ -3,7 +3,6 @@ import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'offic
import type { UserRole } from 'officerdb';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { OS_USERS_ENABLED } from '@@/os-user';
import { validatePublicKey } from '@@/os-user-ssh';
import { provisionOsAccount } from './provision-os';
import { validatePassword } from '../auth/validate-password';
@@ -97,9 +96,7 @@ export const createUserHandler: Handler = async function (ctx) {
//
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
// account was made does not cost anybody their password and dashboards.
const os = OS_USERS_ENABLED
? await provisionOsAccount({ userId: user.id, email, username, inboundKey })
: { osUser: null, sshPublicKey: null, error: null };
const os = await provisionOsAccount({ userId: user.id, email, username, inboundKey });
if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`);
+53 -4
View File
@@ -2,7 +2,8 @@ import type { Handler } from 'hono';
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
import type { UserRole } from 'officerdb';
import * as errors from '@@/custom-errors';
import { OS_USERS_ENABLED } from '@@/os-user';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
import { DATA_PATH } from '@@/data-path';
// Owner-only management of the other accounts. Everything here is gated by ownerGate in
// users-router.ts; these handlers assume the caller is the Super Admin.
@@ -54,9 +55,6 @@ export const listUsersHandler: Handler = async function (ctx) {
roles: USER_ROLES,
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
ownerId: OWNER_USER_ID,
// So the UI offers the Linux-account controls only where they can work. On a host without the feature
// they would be a button that always reports the same refusal.
osUsersEnabled: OS_USERS_ENABLED,
});
};
@@ -104,6 +102,57 @@ export const deleteUserHandler: Handler = async function (ctx) {
const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found');
// ── The Linux side goes FIRST, and its failure stops the delete ──
//
// This used to be the whole handler: remove the row, cascade the database, done. Measured on the
// production host on 2026-08-12, immediately after deleting a member through this route: their Linux
// account was still alive with a working login shell, their rootless Docker daemon was still running a
// healthy postgres container, and 454 MB of their data was intact — while the platform had forgotten
// they existed. `useradd` hands out the lowest free uid, so that number was queued up to be reissued to
// the next member along with everything still owned by it.
//
// Ordered this way round because the row is what remembers there is anything to clean up. Delete it
// first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry
// against. Keeping the account on failure is also the safer half of the trade — an account that still
// exists is inert, whereas a freed uid whose files still carry it is the hazard itself.
if (existing.osUser) {
const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser });
if (!deprovisioned.ok) {
// Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage
// and the freed range — which after a failed release is the only surviving record of it.
console.error(
`[users] DEPROVISION FAILED for ${existing.email} at stage '${deprovisioned.stage}': ${deprovisioned.error}` +
(deprovisioned.freed
? ` — uid ${deprovisioned.freed.uid}, subuid ${deprovisioned.freed.subUid?.start ?? 'none'}` +
` ${deprovisioned.freed.subUid?.count ?? ''}`
: ''),
);
throw errors.INTERNAL_SERVER_ERROR(
`Could not remove ${existing.email}'s Linux account: ${deprovisioned.error} ` +
`The platform account was NOT deleted, so this can be retried.`,
);
}
for (const warning of deprovisioned.warnings) console.warn(`[users] ${existing.email}: ${warning}`);
if (deprovisioned.freed) {
// The audit line. `scripts/assert-uid-free.sh --check` takes exactly these arguments, and after
// `userdel` this log is the only place the freed subuid range still exists.
//
// DATA_PATH is spelled out rather than left to the operator, because this line exists to be COPIED
// and sudo's env_reset drops it — the version without it fell back to a hardcoded default and made
// the checker report CLEAN without reading a single member tree.
const { osUser, uid, subUid } = deprovisioned.freed;
const range = subUid ? `${subUid.start} ${subUid.count}` : null;
console.info(
range
? `[users] deprovisioned ${osUser} — verify with: sudo DATA_PATH=${DATA_PATH} ` +
`./scripts/assert-uid-free.sh --check ${osUser} ${uid} ${range}`
: `[users] deprovisioned ${osUser} (uid ${uid}) — it had no /etc/subuid range, so only the uid ` +
`half is verifiable: sudo DATA_PATH=${DATA_PATH} ./scripts/assert-uid-free.sh --check ` +
`${osUser} ${uid} <start> <count> will refuse without real numbers`,
);
}
}
// Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed
// to them. There is no undo, which is why the UI asks first.
await deleteUser(id);
+2 -6
View File
@@ -1,5 +1,5 @@
import { updateUser } from 'officerdb';
import { OS_USERS_ENABLED, ensureOsUser, osUserHome } from '@@/os-user';
import { ensureOsUser, osUserHome } from '@@/os-user';
import { provisionSshAccess } from '@@/os-user-ssh';
import { seedShellConfig } from '@@/os-user-shell';
import { provisionClaudeCli } from '@@/os-user-claude';
@@ -12,7 +12,7 @@ import { provisionUserDirs } from '@@/data-path';
// it has to happen at — the same reasoning as app-store/members.ts. The list of reasons a retry is needed is
// not exotic:
//
// - the host was not set up for it when the account was made (`OFFICER_OS_USERS` off, no sudoers entry)
// - the host was not set up for it when the account was made (no sudoers entry for the service user)
// - an ancestor directory was not traversable, which is the one everybody hits once
// - the owner wants to replace the inbound SSH key
//
@@ -42,10 +42,6 @@ export async function provisionOsAccount(params: {
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
inboundKey?: string | null;
}): Promise<OsProvisionOutcome> {
if (!OS_USERS_ENABLED) {
return { osUser: null, sshPublicKey: null, error: 'per-user Linux accounts are not enabled on this server' };
}
// First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at.
try {
provisionUserDirs(params.email);
+2 -2
View File
@@ -15,8 +15,8 @@ usersRouter.use(originMiddleware);
// Self-update. Any signed-in account may change its own name, username and avatar.
usersRouter.put('/', updateUserHandler);
// Everything below manages OTHER accounts and is the owner's alone. The global capability backstop in
// originScopeMiddleware already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
// Everything below manages OTHER accounts and is the owner's alone. The global capability gate in
// hono.ts already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
// not grantable — but that router-level rule cannot see the one exception beside it: `PUT /` is
// declared `selfService` so every account can edit its own profile. This gate is what keeps that
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
-1
View File
@@ -15,7 +15,6 @@ import { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey }
// • serves the native broker/unlock-key endpoints,
// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored
// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten.
// The origin scoping (OFFICER_VAULT_ORIGIN → /api/vault) is enforced globally by originScopeMiddleware.
export const vaultRouter = createRouter();
+6 -8
View File
@@ -2,7 +2,6 @@ import type { ServerWebSocket } from 'bun';
import { resolveAuthToken } from '../../auth-token';
import { isTokenBlacklisted } from 'officerdb';
import { isSuperAdmin } from '../../super-admin';
import { isOriginAllowed } from '../../_middlewares';
import { getVaultServerWsUrl } from './sidecar-server';
import { getValidAccessToken } from './token-store';
@@ -140,14 +139,13 @@ export const vaultWebsocket = {
const PREFIX = '/api/vault';
// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated; the platform JWT rides the
// query (?access_token= for SignalR, or ?token=). The session is validated in `open` (deferred). The
// device never sends a Vaultwarden token — we inject the stored one upstream.
// Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query
// (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device
// never sends a Vaultwarden token — we inject the stored one upstream.
//
// There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation.
// It had defaulted to allow-everything, so it refused nothing on a real install.
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
const origin = req.headers.get('origin') ?? undefined;
const host = req.headers.get('host') ?? undefined;
if (!isOriginAllowed(origin, host)) return new Response('Forbidden', { status: 403 });
const url = new URL(req.url);
const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || '';
if (!platformToken) return new Response('Unauthorized', { status: 401 });
+7 -10
View File
@@ -1,5 +1,5 @@
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../data-path';
import { join } from 'node:path';
import { OFFICER_ROOT } from '../data-path';
// Where the app store puts the containers it provisions.
//
@@ -17,11 +17,6 @@ import { DATA_PATH } from '../data-path';
// than configured separately, because a second environment variable that must agree with the first is a
// second thing to get wrong — and on a correct install `data/` is always a direct child of the root.
//
// (This development machine predates the convention and has it inverted: the whole project sits inside
// `~/dockers/officer.dev/`, so the root derives to `officer.dev` and the app store's directory would be
// `~/dockers/officer.dev/dockers`. Which is ugly, and correct — it is still isolated, still under one
// root, and still not mixed in with anything else. New installs get the clean shape.)
//
// ── Why this is not `~/dockers` ──
//
// That is where a seasoned user already keeps their own estate — 47 services on this machine alone. Two
@@ -37,11 +32,13 @@ import { DATA_PATH } from '../data-path';
// So this directory is exclusively ours to write, and everything in it was put there by an install.
/**
* The install root — the parent of `data/`. On a conventional install, `~/officerdev`.
* The install root — on a conventional install, `~/officerdev`.
*
* Derived, not configured: see above.
* Derived, not configured: see above. It moved to `../data-path` on 2026-08-12, when the direction
* inverted — it used to be `dirname(DATA_PATH)`, back when DATA_PATH was the environment variable that
* anchored everything. Re-exported here because this file is where callers expect to find it.
*/
export const OFFICER_ROOT = dirname(DATA_PATH);
export { OFFICER_ROOT };
/** Where provisioned services live, one directory each. Created on first install, not at boot. */
export const DOCKERS_DIR = join(OFFICER_ROOT, 'dockers');
+4 -3
View File
@@ -9,9 +9,10 @@ import type { CatalogueEntry } from './catalogue';
//
// ── What this deliberately does NOT do ──
//
// It does not install anything. Today nothing in `scripts/` installs Docker either — `setup.sh` runs
// `setup-dockers.sh`, which invokes `docker compose` without ever checking it exists, so a fresh host
// without Docker fails partway through setup with a bare "command not found". That is a real gap, and
// It does not install anything. Today nothing in `scripts/` installs Docker either — the host installer
// `scripts/setup/setup.sh` runs `scripts/setup/setup-dockers.sh`, which invokes `docker compose` without
// ever checking it exists, so a fresh host without Docker fails partway through setup with a bare
// "command not found". That is a real gap, and
// the intended fix is a per-sidecar `setup.sh` that ensures its own dependencies — which is also the
// shape a sidecar needs once it lives in its own repository and ships independently.
//
+1 -1
View File
@@ -9,7 +9,7 @@ import { verify } from './jwt';
// ── One resolver, two doors ──
//
// Identity is decided in TWO independent middlewares: `userMiddleware`, which every protected router
// mounts, and `originScopeMiddleware`, which runs globally in hono.ts and re-verifies the token itself
// mounts, and `capabilityGateMiddleware`, which runs globally in hono.ts and re-verifies the token itself
// because it must also cover routes that never mount `userMiddleware`. They have to agree about who a
// caller is, and the way they stop agreeing is somebody teaching one of them a credential format the
// other has never heard of — the second door would then see an unrecognisable token, resolve nobody, and
+1 -1
View File
@@ -89,7 +89,7 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom
// A confined capability touches the filesystem or runs a process, and is safe only because the
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
// `getOwnerHomeDir` ignores the email it is passed whenever HOME_DIR is set.
// `getOwnerHomeDir` ignores the email it is passed, always.
//
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
+92 -9
View File
@@ -1,12 +1,70 @@
import { join, resolve } from 'node:path';
import { chmodSync, mkdirSync } from 'node:fs';
import { chmodSync, mkdirSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// ── The install root, and why it is derived rather than configured ──
//
// An install is one directory with everything under it:
//
// $OFFICER_ROOT/
// platform/ the repo — the working directory every process runs in
// data/ DATA_PATH
// capabilities/ OFFICER_ITEMS_DIR
// dockers/ what the app store provisions
//
// These were three environment variables until 2026-08-12, which meant three answers that had to agree
// with each other and with the layout on disk. They are one fact now: the root is the parent of the
// working directory, and everything else is a fixed name under it. Nothing to set and nothing to
// disagree.
//
// This depends on the working directory being the repo, which is why pm2 pins `cwd` in
// ecosystem.profile.cjs — read the comment there before changing either. `assertInstallLayout` below
// is the check that says so out loud instead of silently writing to the wrong place.
export const OFFICER_ROOT = resolve(process.cwd(), '..');
export const DATA_PATH = join(OFFICER_ROOT, 'data');
// Unified, file-based store for all agent items, living outside the repo. Every skill/tool/task/
// process/extension is a directory under one of these type subfolders — no scope tiers, no DB.
export const OFFICER_ITEMS_DIR = process.env.OFFICER_ITEMS_DIR ?? join(process.cwd(), 'officer-items');
export const OFFICER_ITEMS_DIR = join(OFFICER_ROOT, 'capabilities');
/**
* Refuse to boot when the working directory is not the platform repo.
*
* Same posture as `assertCapabilityTotality` and `assertSecretsClosed`: a prerequisite that silently
* not holding is worse than one that fails. Every path in this file hangs off `resolve(process.cwd(), '..')`,
* so a process started from the wrong directory does not error — it computes a plausible root somewhere
* else and writes managed homes, capabilities and agent runs into it. The install looks empty and the
* data looks lost, with nothing naming the cause.
*
* `ecosystem.profile.cjs` already pins `cwd` for exactly this reason. This is the check that the pin
* still works, which is the part that was missing when the ecosystem files moved directory.
*/
export function assertInstallLayout(): void {
const manifest = join(process.cwd(), 'package.json');
let name: string | undefined;
try {
name = JSON.parse(readFileSync(manifest, 'utf8')).name;
} catch {
// Absent or unreadable is the same answer as wrong: this is not the repo.
}
if (name === 'officer') return;
throw new Error(
[
`Officer must run from the platform repo, but the working directory is ${process.cwd()}`,
'',
` expected a directory containing the platform's package.json ("officer")`,
` found ${name ? `package.json for "${name}"` : 'no readable package.json'}`,
'',
'Every path is derived from this — the install root is its parent, and data/, capabilities/ and',
`dockers/ hang off that. Continuing would write to ${OFFICER_ROOT} instead of the real install.`,
'',
'Under pm2 this means the `cwd` pin in ecosystem.profile.cjs no longer points at the repo.',
].join('\n'),
);
}
export type ItemType = 'skills' | 'tools' | 'tasks' | 'processes' | 'extensions' | 'agents';
@@ -33,14 +91,39 @@ export const SEED_PATH = resolve(import.meta.dir, '../../seed');
// The managed home under DATA_PATH. A remnant of the first architecture, where every user ran inside
// their own Docker container and this was that container's home — seeded by provisioning, described to
// the agent by a generated CLAUDE.md. Both of those are gone, and nothing executes here any more:
// terminals, chats and task runs all use getOwnerHomeDir below. It survives only as that function's
// fallback for when HOME_DIR is unset, and in pipeline-executor.
// the agent by a generated CLAUDE.md. Both of those are gone, and the OWNER's sessions never come here
// any more — terminals, chats and task runs all use getOwnerHomeDir below.
//
// It is not dead, though: user-home.ts returns it for a NON-owner, where it is deliberately the same
// path as osUserHome, and pipeline-executor still calls it.
//
// It was also getOwnerHomeDir's fallback until 2026-08-12, which is the only reason an unset HOME_DIR
// used to run the owner's terminals in a directory nobody meant.
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
// Where the owner's sessions actually run: their real login home when HOME_DIR is set, so platform
// terminals/chats/tasks share config and credentials with the shell they use outside Officer.
export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email);
// Where the owner's sessions actually run: their real login home, so platform terminals/chats/tasks
// share config and credentials with the shell they use outside Officer.
//
// `homedir()` is right here for one reason, and it is worth stating because everything below rests on
// it: the server process runs AS the owner. It is not a general "whose home is this" helper — a member
// never reaches this function, because their sessions go through os-user.ts and setpriv. It ignores the
// email it is passed, which it also did before; the callers that must not are already commented as such.
//
// ── Why this is captured once, and not read per call ──
//
// Measured on bun 1.3.10: BOTH `os.homedir()` and `os.userInfo().homedir` return $HOME when it is set,
// rather than reading the password file. And `sidecar/claude/user-instance.ts` assigns `process.env.HOME`
// on its way to spawning an agent. So a lazy read here would hand back whichever home was most recently
// spawned into — the owner's on the first call and something else afterwards.
//
// This module imports nothing but node builtins, so it is evaluated before any of that can run. The
// value is the owner's home, taken while $HOME still means what it says.
const OWNER_HOME = homedir();
// This was HOME_DIR in .env until 2026-08-12, whose absence fell back to getHomeDir — the managed home
// under DATA_PATH, not a login home at all. So forgetting to set it did not fail; it quietly ran every
// terminal somewhere else.
export const getOwnerHomeDir = (_email: string): string => OWNER_HOME;
// The directory skeleton a new account gets under DATA_PATH.
//
+10 -14
View File
@@ -57,8 +57,7 @@ import { agentStatusRouter } from './api/agent-status/router';
import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
import { isMusicOriginExempt } from './_middlewares/origin-validation';
import { userMiddleware, bodyParser, capabilityGateMiddleware } from './_middlewares';
export { Hono };
export { createRouter };
@@ -66,14 +65,12 @@ export type { HonoVariables };
export const honoServer = new Hono<{ Variables: HonoVariables }>();
// Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is
// not a loosening: the check it replaced defaulted to off, so this is what every real install already
// did. The perimeter is the tailnet and the lock is a valid token on every protected route, plus the
// capability gate below.
const corsMiddleware = cors({
origin: (origin, c) => {
const host = c.req.header('host');
// TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a
// browser client is not blocked by CORS after userMiddleware has already let it through.
if (isMusicOriginExempt(c.req.path)) return origin ?? '*';
return isOriginAllowed(origin, host) ? origin : '';
},
origin: (origin) => origin ?? '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
});
@@ -92,17 +89,16 @@ const isDavPath = (path: string) =>
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset.
honoServer.use(originScopeMiddleware);
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
honoServer.use(capabilityGateMiddleware);
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
honoServer.route('/api/auth', authRouter);
honoServer.route('/api/landing-page-data', landingPageDataRouter);
honoServer.route('/api/waitlist', waitlistRouter);
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. Origin
// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
// notifications WebSocket is upgraded at the serve level (server.tsx).
honoServer.route('/api/vault', vaultRouter);
+75
View File
@@ -0,0 +1,75 @@
// Where the app is, and the only place that knows.
//
// ── Why this file is .mjs ──
//
// Every process is bun except `officer-pty`, which pm2 launches with node (see
// ecosystem.config.cjs). Node cannot import TypeScript, so a .ts module here would have left the pty
// sidecar with its own copy — which is exactly the thing this file exists to end. Plain JS is
// importable by both, and `allowJs` in tsconfig.json means the TS callers still get types.
//
// ── Why there is no default ──
//
// There were twenty-two, and they disagreed: 5000 in the app and nineteen sidecars, 9010 in
// user-instance.ts, 9000 in .env.example. Each was defensible where it was written and none was
// visible from the others.
//
// A default is a guess at a value that .env always supplies. The one case it covers — PORT genuinely
// unset — is not a machine anyone wants running: it means .env was not loaded, so POSTGRES_URL is
// missing too and nothing works anyway. What a default buys there is a process that starts, binds
// somewhere unexpected and fails later for a reason that does not name the cause.
//
// So: no default anywhere, and this throws. Same posture as jwt.ts with JWT_SECRET.
const raw = process.env.PORT;
if (!raw) {
throw new Error(
[
'PORT is not set.',
'',
'Officer reads it from .env, which bun auto-loads from the working directory. If this is a pm2',
'process, the `cwd` pin in ecosystem.profile.cjs is what puts it there — a PORT this empty',
'usually means .env was never loaded at all, and POSTGRES_URL is missing too.',
'',
'Set PORT in .env (officer-setup writes 9000), or start from the repo root.',
].join('\n'),
);
}
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error(`PORT must be an integer between 1 and 65535, but it is ${JSON.stringify(raw)}`);
}
/** The port the app binds. Everything else addresses it; nothing else binds. */
export const PORT = parsed;
// The app serves HTTP and WebSocket on ONE listener, so these are the same port in two protocols.
// Sidecars append their own path — `/api/sidecar/register` for the registration socket.
//
// The env overrides are kept because they are explicit escape hatches rather than defaults: nothing
// sets either today, and a value that is present is a deliberate act rather than a guess.
export const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${PORT}`;
export const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${PORT}`;
// ── The Anthropic proxy: PORT + 1, derived, not configured ──
//
// This is the ONE sidecar that binds a fixed port. Every other one binds `port: 0`, lets the kernel
// choose, and reports what it got back over the registration socket — which works because their
// consumer is the platform.
//
// The proxy cannot do that. Its consumer is `claude`, spawned by a DIFFERENT pm2 process
// (officer-agent), which needs ANTHROPIC_BASE_URL at spawn time and has no channel to ask the proxy
// what port it landed on. Two independent processes with nothing between them have to agree in
// advance, so the number has to be predictable rather than discovered.
//
// It was ANTHROPIC_PROXY_PORT, defaulting to 5051 in four separate files: the one that binds it and
// three that guessed the same constant to find it. 5051 was chosen against nothing and could collide
// with anything the owner installs later, with the symptom being chat failing while the rest of the
// platform looked healthy.
//
// PORT + 1 keeps the predictability and removes both problems. There is no variable to set, no second
// number to keep in agreement with the first, and the pair moves together when the install moves.
export const ANTHROPIC_PROXY_PORT = PORT + 1;
export const ANTHROPIC_PROXY_URL = `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`;
+2 -2
View File
@@ -8,7 +8,7 @@ import { osUserHome, runAs } from './os-user';
// A single `/usr/local/bin/claude` would be less disk and one version to reason about, and the argument for
// it is real: the private part of Claude is the credential in `~/.claude`, not the executable. It is still
// the wrong shape here. `claude` updates itself — that is why the owner's own install goes through
// Anthropic's installer rather than npm (`scripts/setup.sh:853`) — and a root-owned binary is one a member
// Anthropic's installer rather than npm (`scripts/setup/setup.sh:861`) — and a root-owned binary is one a member
// cannot update, which turns "my agent is a version behind" into a request to the owner. Per-member also
// means the account's agent keeps working exactly as the tool ships, with no platform-shaped exception to
// explain. Same command the owner ran, run as them, in their home.
@@ -29,7 +29,7 @@ import { osUserHome, runAs } from './os-user';
// report whether the credential has appeared, so the UI can render the one-line instruction instead of an
// agent that fails for reasons nobody can see.
/** Anthropic's own installer — the same one `scripts/setup.sh` uses for the owner, chosen for auto-update. */
/** Anthropic's own installer — the same one `scripts/setup/setup.sh` uses for the owner, chosen for auto-update. */
const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';
/**
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, test } from 'bun:test';
import { guardDeletable, guardMemberTree, parseSubUidEntry } from './os-user-deprovision';
// The guards that stand between a database column and `sudo userdel` / `sudo chown -R`.
//
// Everything else in os-user-deprovision.ts needs a machine with accounts to delete on. These three are pure
// precisely so the dangerous decisions can be tested without one — they are the part where being wrong is not
// recoverable, and the manual teardown that produced the spec is not a thing anyone should repeat to check a
// refactor.
describe('guardDeletable', () => {
const home = '/data/member@example.com/home';
test('accepts an account the platform made', () => {
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: home, expectedHome: home })).toEqual({ ok: true });
});
test('refuses root, and every other system account', () => {
// The scenario: a `users` row whose osUser column says 'root'. Nothing else in the sequence would stop it
// — `loginctl`, `pkill` and `userdel` would all simply do as they were told.
const result = guardDeletable({ osUser: 'root', uid: 0, passwdHome: '/root', expectedHome: home });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('system account');
expect(guardDeletable({ osUser: 'daemon', uid: 1, passwdHome: home, expectedHome: home }).ok).toBe(false);
expect(guardDeletable({ osUser: 'nobody', uid: 999, passwdHome: home, expectedHome: home }).ok).toBe(false);
});
test('refuses a same-named account that is not ours', () => {
// A human account that happens to share a member's username. Its home is its own, so it fails the only
// test that proves ownership. This is `ensureOsUser`'s adoption rule read backwards.
const result = guardDeletable({ osUser: 'green', uid: 1002, passwdHome: '/home/green', expectedHome: home });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('not an account the platform created');
});
test('refuses an account with no passwd home at all', () => {
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: null, expectedHome: home }).ok).toBe(false);
});
test('the uid floor is not sufficient on its own', () => {
// Both halves are required: a high uid with a foreign home is still somebody else's account.
expect(guardDeletable({ osUser: 'green', uid: 4000, passwdHome: '/srv/green', expectedHome: home }).ok).toBe(false);
});
});
describe('guardMemberTree', () => {
test('resolves a normal account directory', () => {
const result = guardMemberTree('/data', 'member@example.com');
expect(result).toEqual({ ok: true, tree: '/data/member@example.com' });
});
test('refuses traversal out of DATA_PATH', () => {
// The path this produces is the argument to `chown -R` and, under destroy, to a recursive delete. The
// email reaches join() from a database row.
expect(guardMemberTree('/data', '../../etc').ok).toBe(false);
expect(guardMemberTree('/data', '..').ok).toBe(false);
expect(guardMemberTree('/data', 'a/../../b').ok).toBe(false);
});
test('refuses a nested path even without traversal', () => {
// A member tree is one level down. Anything deeper is not an account directory, whatever it is.
expect(guardMemberTree('/data', 'a/b').ok).toBe(false);
});
test('refuses DATA_PATH itself', () => {
expect(guardMemberTree('/data', '').ok).toBe(false);
expect(guardMemberTree('/data', '.').ok).toBe(false);
});
test('refuses an empty DATA_PATH', () => {
// Unset DATA_PATH would otherwise make every member tree a path under the process's cwd.
expect(guardMemberTree('', 'member@example.com').ok).toBe(false);
});
});
describe('parseSubUidEntry', () => {
const file = 'pastilhas:100000:65536\ngreen:231072:65536\nofficer_jg:165536:65536\n';
test('reads the range for the named account only', () => {
expect(parseSubUidEntry(file, 'green')).toEqual({ start: 231072, count: 65536 });
expect(parseSubUidEntry(file, 'officer_jg')).toEqual({ start: 165536, count: 65536 });
});
test('a missing entry is null, not a guess', () => {
// Normal: rootless Docker is tolerated when it fails, and accounts predating it have no entry. A guessed
// range would make the audit check a different range than the one that was actually freed.
expect(parseSubUidEntry(file, 'nobody')).toBeNull();
expect(parseSubUidEntry('', 'green')).toBeNull();
});
test('does not match on a prefix', () => {
// 'green' must not match the 'green2' line — the ranges are different and the wrong one verifies nothing.
expect(parseSubUidEntry('green2:300000:65536\n', 'green')).toBeNull();
});
test('rejects a malformed line rather than producing NaN', () => {
// NaN would flow into `assert-uid-free.sh --check` as an argument and quietly scan nothing.
expect(parseSubUidEntry('green:notanumber:65536\n', 'green')).toBeNull();
expect(parseSubUidEntry('green:231072:0\n', 'green')).toBeNull();
});
});
+374
View File
@@ -0,0 +1,374 @@
import { rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { userInfo } from 'node:os';
import { join, resolve } from 'node:path';
import { DATA_PATH } from './data-path';
import { lookupOsUser, osUserHome } from './os-user';
// Taking a member's Linux account away again.
//
// The specification, and every measured claim in it, is `docs/deprovision-os-account.md` — written from a
// manual teardown on the production host on 2026-08-11. Read it before changing the ORDER of anything here.
//
// ── Why this is not the mirror image of provisioning ──
//
// `provisionOsAccount` may fail freely: an account that did not get its Linux side is merely unusable, and
// the owner presses retry. This one cannot. `useradd` allocates the lowest free uid, so the moment `userdel`
// returns, that number is available to the next member — while the previous member's home, keys and
// container storage may still be owned by it. A half-finished deprovision does not leave a broken account;
// it leaves a **trap**, and the next person to create an account walks into it.
//
// Hence the one rule the sequence exists to enforce:
//
// Sever the data from the uid BEFORE releasing the uid. If severing fails, DO NOT release.
//
// The manual teardown got exactly this backwards — it ran `userdel` first and cleaned up afterwards — and
// the window it opened is the reason this file's steps are ordered rather than grouped.
//
// ── The subuid half ──
//
// A member's rootless Docker files are not owned by their uid. Container processes map through
// `/etc/subuid`, so on the production host `green`'s postgres data directory was owned by 231141
// (their range start + 70, postgres's uid inside the Alpine image). `userdel` frees the whole range along
// with the uid. So "nothing is owned by uid 1002" can be true while hundreds of megabytes are still owned by
// ids a future member's containers will map onto.
//
// `chown -R` fixes both at once — it rewrites every file it walks regardless of who owned it — which is why
// severing is a single operation rather than one pass per id space. But the range still has to be CAPTURED
// before `userdel`, because that is the last moment anyone can ask what it was. It is returned to the caller
// for exactly that reason; `scripts/assert-uid-free.sh --check` takes it as an argument.
/** What to do with the member's files. `preserve` is the default and the only one with a call site. */
export type DeprovisionPolicy = 'preserve' | 'destroy';
/**
* The identity of the account as it was before deletion.
*
* Returned even on failure when it was captured, because it is unrecoverable afterwards: `userdel` removes
* the `/etc/subuid` entry, and nothing on the machine then remembers which range the account held.
*/
export type FreedIdentity = {
osUser: string;
uid: number;
/** Null when the account had no `/etc/subuid` entry — rootless Docker was never provisioned for it. */
subUid: { start: number; count: number } | null;
};
export type DeprovisionResult =
| {
ok: true;
/** False when there was nothing to do — no Linux account by that name. Idempotent re-runs land here. */
removed: boolean;
freed: FreedIdentity | null;
/** Non-fatal residue. The uid is safe to reissue; something cosmetic outlived the teardown. */
warnings: string[];
}
| {
ok: false;
error: string;
/** Which step refused. `sever` in particular means the account is intact and MUST stay that way. */
stage: 'guard' | 'reap' | 'sever' | 'release';
freed: FreedIdentity | null;
};
async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> {
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
}
async function plain(args: string[]): Promise<{ ok: boolean; out: string }> {
const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
}
/** The account this process runs as — who severed data is reassigned to. */
const serviceUser = (): string => userInfo().username;
/**
* The account's `/etc/subuid` range, read before anything is removed.
*
* Absent is normal, not an error: rootless Docker is provisioned per member but tolerated when it fails, and
* an account made before that existed has no entry at all.
*/
export function parseSubUidEntry(fileContent: string, osUser: string): { start: number; count: number } | null {
for (const line of fileContent.split('\n')) {
const [name, start, count] = line.split(':');
if (name !== osUser) continue;
const parsedStart = Number(start);
const parsedCount = Number(count);
if (!Number.isInteger(parsedStart) || !Number.isInteger(parsedCount) || parsedCount <= 0) return null;
return { start: parsedStart, count: parsedCount };
}
return null;
}
/**
* How many processes the uid still owns.
*
* `pgrep` exits 1 on no match, which is the expected answer here — so the exit code says nothing and the
* line count is the measurement.
*/
async function processCount(uid: number): Promise<number> {
const found = await plain(['pgrep', '-u', String(uid)]);
if (!found.out) return 0;
return found.out.split('\n').filter((line) => line.trim()).length;
}
/**
* Refuse to touch an account that is not ours to touch.
*
* This is `ensureOsUser`'s adoption rule pointed the other way, and it is the most important function in the
* file. Adoption asks "may I take this account over"; if it is wrong, a member gets somebody else's home.
* This asks "may I DELETE this account" — and if it is wrong, `userdel root` is a single sudo away, driven by
* a string in a database row.
*
* The passwd home is the proof of ownership. `ensureOsUser` refuses to adopt an account whose home is not the
* one it was going to confine, so any account the platform created or adopted has `home == osUserHome(email)`.
* Anything else on this machine, by any name, does not — including every system account.
*
* Exported so it can be tested against the cases that matter without a machine to delete users on.
*/
export function guardDeletable(params: {
osUser: string;
uid: number;
passwdHome: string | null;
expectedHome: string;
}): { ok: true } | { ok: false; error: string } {
if (params.uid < 1000) {
return { ok: false, error: `refusing to deprovision '${params.osUser}': uid ${params.uid} is a system account` };
}
if (params.passwdHome !== params.expectedHome) {
return {
ok: false,
error:
`refusing to deprovision '${params.osUser}': its home is ${params.passwdHome ?? 'unknown'}, not ` +
`${params.expectedHome}. This is not an account the platform created.`,
};
}
return { ok: true };
}
/**
* The member's tree must be a direct child of `DATA_PATH`, spelled with no traversal.
*
* The email arrives from a database row and reaches `join()`, so `../..` in it would walk out of `DATA_PATH`
* — and this path is the argument to a recursive `chown` and, under `destroy`, to a recursive delete. Cheap
* to assert, and the failure it prevents has no upper bound.
*/
export function guardMemberTree(
dataPath: string,
email: string,
): { ok: true; tree: string } | { ok: false; error: string } {
if (!dataPath) return { ok: false, error: 'DATA_PATH is empty; refusing to resolve a member tree' };
const tree = resolve(join(dataPath, email));
const parent = resolve(dataPath);
if (tree === parent || !tree.startsWith(`${parent}/`) || tree.slice(parent.length + 1).includes('/')) {
return { ok: false, error: `refusing to operate on ${tree}: not a direct child of ${parent}` };
}
return { ok: true, tree };
}
/**
* Remove a member's Linux account, severing their data from the uid first.
*
* Idempotent. An account that is already gone returns `{ ok: true, removed: false }`, and every step
* tolerates having been done before — so a partial failure can be retried by calling this again rather than
* by finishing it by hand.
*
* Never throws; returns a result. But unlike the provisioning functions, `ok: false` here is not
* cosmetic — see the note at the top of the file, and `stage` on the failure result.
*/
export async function deprovisionOsAccount(params: {
email: string;
osUser: string;
policy?: DeprovisionPolicy;
}): Promise<DeprovisionResult> {
const policy: DeprovisionPolicy = params.policy ?? 'preserve';
const warnings: string[] = [];
// Absent is success. This is what a re-run after a completed deprovision looks like, and what an account
// that never had a Linux side looks like — neither is a problem to report.
const ids = await lookupOsUser(params.osUser);
if (!ids) return { ok: true, removed: false, freed: null, warnings };
const passwd = await plain(['getent', 'passwd', params.osUser]);
const passwdHome = passwd.ok ? (passwd.out.split(':')[5] ?? null) : null;
const expectedHome = osUserHome(params.email);
const allowed = guardDeletable({ osUser: params.osUser, uid: ids.uid, passwdHome, expectedHome });
if (!allowed.ok) return { ok: false, stage: 'guard', freed: null, error: allowed.error };
const treeGuard = guardMemberTree(DATA_PATH, params.email);
if (!treeGuard.ok) return { ok: false, stage: 'guard', freed: null, error: treeGuard.error };
// ── Capture, before anything can destroy the evidence ──
//
// Read now because `userdel` removes the /etc/subuid entry with the account, and after that there is no way
// to ask what range it held. Carried on every return path below, including the failures, so an operator
// auditing a partial teardown still has the numbers `assert-uid-free.sh --check` needs.
const subuidFile = await sudo(['cat', '/etc/subuid']);
const freed: FreedIdentity = {
osUser: params.osUser,
uid: ids.uid,
subUid: subuidFile.ok ? parseSubUidEntry(subuidFile.out, params.osUser) : null,
};
if (!subuidFile.ok) warnings.push('could not read /etc/subuid; the freed range is unknown and unverifiable');
// ── 1. Linger off, before terminating anything ──
//
// Lingering keeps a systemd user manager alive with no login session. Terminate first and linger can bring
// it straight back; disable first and nothing can respawn in the gap. A non-lingering account makes this a
// no-op, which is why its failure is a warning rather than a stop.
const linger = await sudo(['loginctl', 'disable-linger', params.osUser]);
if (!linger.ok) warnings.push(`disable-linger reported: ${linger.out}`);
// ── 2. Terminate, then PROVE it ──
//
// Measured on the production host: `terminate-user` is not a barrier. A three-hour-old `/bin/zsh -i` owned
// by the member survived it, and survived /run/user/<uid> being removed. `userdel` refuses while any
// process owned by the account lives, so trusting this call works on a quiet account and fails on a member
// who left a shell open — which is the normal case, not the edge one.
await sudo(['loginctl', 'terminate-user', params.osUser]);
const reaped = await reapProcesses(ids.uid);
if (!reaped.ok) return { ok: false, stage: 'reap', freed, error: reaped.error };
// ── 3. Sever the data from the uid, BEFORE releasing it ──
const severed = await severMemberTree({ tree: treeGuard.tree, policy });
if (!severed.ok) {
// Deliberately returns here rather than continuing. An account left intact is inert; a freed uid whose
// files are still owned by it is the hazard this whole file exists to prevent.
return { ok: false, stage: 'sever', freed, error: severed.error };
}
// ── 4. Release ──
//
// Never `-r`. It would delete the home, which contradicts `preserve` and would make `destroy` a consequence
// of a flag rather than of an explicit decision. Plain `userdel` was measured to remove the passwd, shadow
// and group entries and both the /etc/subuid and /etc/subgid ranges.
const del = await sudo(['userdel', params.osUser]);
if (!del.ok) {
return {
ok: false,
stage: 'release',
freed,
error:
`userdel failed for ${params.osUser}: ${del.out}. Their data has already been reassigned to ` +
`${serviceUser()}, so the account is inert — but it still exists. Retry.`,
};
}
// Residue: reported, not fatal. /run/user/<uid> is a tmpfs systemd normally reaps with the session; if it
// outlives one, it is empty, cleared at reboot, and no reason to call a correct teardown a failure.
if (existsSync(`/run/user/${ids.uid}`)) {
warnings.push(`/run/user/${ids.uid} still exists; it is tmpfs and clears on reboot`);
}
if (existsSync(`/var/lib/systemd/linger/${params.osUser}`)) {
warnings.push(`linger marker for ${params.osUser} survived disable-linger`);
}
return { ok: true, removed: true, freed, warnings };
}
/**
* Kill everything the uid owns, escalating, and refuse to return success while any of it lives.
*
* The bounded waits are the point. `pkill` returns as soon as the signal is delivered, not when the process
* has gone, so a check that follows it immediately reads the state before the kill took effect.
*/
async function reapProcesses(uid: number): Promise<{ ok: true } | { ok: false; error: string }> {
// Re-asserted here rather than trusted from the caller. This is the one function that signals by uid alone
// — the name is not in the argv — so if the number were ever wrong, it would be wrong about somebody else's
// processes with nothing else to catch it.
if (uid < 1000) return { ok: false, error: `refusing to signal uid ${uid}: not a member account` };
if ((await processCount(uid)) === 0) return { ok: true };
await sudo(['pkill', '-u', String(uid)]);
for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) {
await Bun.sleep(200);
}
if ((await processCount(uid)) > 0) {
await sudo(['pkill', '-9', '-u', String(uid)]);
for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) {
await Bun.sleep(200);
}
}
const remaining = await processCount(uid);
if (remaining > 0) {
return {
ok: false,
error: `${remaining} process(es) owned by uid ${uid} survived SIGKILL; not releasing the account`,
};
}
return { ok: true };
}
/**
* Break the link between the member's files and their uid.
*
* `preserve` reassigns; `destroy` reassigns and then deletes. Destroying via reassignment rather than via
* `sudo rm -rf` is deliberate: after the chown the service user owns every byte and can delete the tree
* itself, so a recursive delete as root — with a path built from a database column — never has to exist in
* this codebase.
*
* `-h` because a symlink must be re-owned rather than followed. Measured on this host: `chown -R` already
* behaves this way (a symlink pointing outside the tree was left untouched, and the link's own ownership was
* rewritten), but the flag states it in the argv instead of resting on traversal semantics nobody documented
* — and if it ever did follow, the target would be whatever a member chose to point at.
*
* Re-owning the symlinks is also what makes the audit meaningful: `find -uid` uses `lstat`, so a link left
* owned by the freed uid is a failing check.
*/
async function severMemberTree(params: {
tree: string;
policy: DeprovisionPolicy;
}): Promise<{ ok: true } | { ok: false; error: string }> {
// Nothing to sever is success — an account whose directories were already removed by hand still needs its
// passwd entry released, and refusing here would leave that undone forever.
if (!existsSync(params.tree)) return { ok: true };
const who = serviceUser();
const chowned = await sudo(['chown', '-h', '-R', `${who}:${who}`, params.tree]);
if (!chowned.ok) return { ok: false, error: `could not reassign ${params.tree} to ${who}: ${chowned.out}` };
// ── Ownership is not the only link to the uid ──
//
// `confineUserTree` grants the member a NAMED ACL entry on their whole tree — `u:<uid>:rwx` plus a
// `default:` copy inherited by everything either party creates afterwards. `chown` does not remove those:
// they are xattrs rather than ownership, and they record the uid NUMERICALLY.
//
// Measured on this host: after `chown -h -R` to the service user, `user:<uid>:rwx` was still present on the
// directory, on its children, and in their defaults. So the tree reads as the platform's while still
// granting the freed uid read and write on every byte of it — and the next account allocated that number
// inherits the previous member's home, keys, credential and container storage. Severing ownership without
// severing this just moves the hazard somewhere ownership checks cannot see it, including `find -uid`.
//
// `-b` removes every ACL rather than the member's entries alone. The service user owns all of it now, so a
// named entry granting themselves what ownership already grants is redundant, and "no ACLs" is a much
// cheaper thing to verify than "no ACL naming one particular id".
//
// `-P` is the physical walk. It is already the default for `setfacl -R` — verified here, a symlink out of
// the tree was not followed — but it is stated for the same reason `chown` above carries `-h`: a member
// chooses what their symlinks point at, and this argv should not depend on a traversal default holding.
const stripped = await sudo(['setfacl', '-R', '-P', '-b', params.tree]);
if (!stripped.ok) {
return { ok: false, error: `reassigned ${params.tree} but could not clear its ACLs: ${stripped.out}` };
}
if (params.policy === 'preserve') return { ok: true };
try {
await rm(params.tree, { recursive: true, force: true });
} catch (ex) {
return {
ok: false,
error: `reassigned ${params.tree} but could not delete it: ${ex instanceof Error ? ex.message : String(ex)}`,
};
}
return { ok: true };
}
+2 -2
View File
@@ -14,7 +14,7 @@ import { osUserHome } from './os-user';
//
// ── What it is ──
//
// `shell-skel/zshrc` → `~/.zshrc`, and the platform's own `scripts/starship.toml` → `~/.config/starship.toml`
// `shell-skel/zshrc` → `~/.zshrc`, and the platform's own `scripts/setup/starship.toml` → `~/.config/starship.toml`
// so a member's prompt is the same one the owner's install deploys. That file is the single source for both:
// setup.sh copies it for the owner and this copies it for everybody else, so the two cannot drift.
//
@@ -31,7 +31,7 @@ import { osUserHome } from './os-user';
/** Where the templates live, relative to this file. */
const SKEL_DIR = join(import.meta.dir, 'shell-skel');
/** The prompt config the owner's own install uses — one file, both audiences. */
const STARSHIP_SRC = join(import.meta.dir, '../../scripts/starship.toml');
const STARSHIP_SRC = join(import.meta.dir, '../../scripts/setup/starship.toml');
type SudoResult = { ok: boolean; out: string };
+4 -7
View File
@@ -18,9 +18,6 @@ import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever
// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong.
/** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */
export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1';
const MAX_USERNAME = 32;
/**
@@ -484,23 +481,23 @@ export async function findReadableSecrets(projectDir: string): Promise<string[]>
}
/**
* Refuse to boot with OS users enabled while a secret in the project tree is readable by them.
* Refuse to boot while a secret in the project tree is readable by other accounts on this machine.
*
* Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
* silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a
* member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
* bypass every capability check in the codebase.
*
* A no-op when the feature is off, so an existing install is unaffected until the owner opts in.
* Unconditional. It was a no-op unless `OFFICER_OS_USERS` was set, which made the guarantee opt-in — and
* a security prerequisite that only holds when someone remembers a flag is not a prerequisite.
*/
export async function assertSecretsClosed(projectDir: string): Promise<void> {
if (!OS_USERS_ENABLED) return;
const readable = await findReadableSecrets(projectDir);
if (!readable.length) return;
throw new Error(
[
'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:',
'These files are readable by other accounts on this machine:',
'',
...readable.map((p) => `${p}`),
'',
+2 -2
View File
@@ -2,6 +2,8 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { startRadicale, davPaths } from './radicale';
import { listCollections, listEvents, listContacts } from './collections';
import { DATA_PATH } from '../../data-path';
import { API_URL } from '../../officer-url.mjs';
// The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the
// collection storage under DATA_PATH/dav, and exposes two very different doors.
@@ -22,8 +24,6 @@ import { listCollections, listEvents, listContacts } from './collections';
// machine-facing interface. Same split officer-email already uses.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
const DATA_PATH = process.env.DATA_PATH ?? `${process.cwd()}/data`;
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
+1 -1
View File
@@ -35,7 +35,7 @@ console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`);
// Capture original HOME before user-instance overrides it
const HOST_HOME = process.env.HOME!;
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
import { DATA_PATH } from '../../data-path';
// Tear a persistent session down after this long with no new turn (see PersistentSession below).
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
+1 -1
View File
@@ -2,8 +2,8 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
import { createSidecarConnector } from '../connect';
import { API_URL } from '../../officer-url.mjs';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
// ── Startup ──
+1 -1
View File
@@ -1,8 +1,8 @@
import { join } from 'node:path';
import { homedir, userInfo } from 'node:os';
import { getState, updateState } from './state';
import { ANTHROPIC_PROXY_PORT as PROXY_PORT } from '../../officer-url.mjs';
const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051');
const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
+1 -2
View File
@@ -1,7 +1,6 @@
import { join } from 'node:path';
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
import { DATA_PATH } from '../../data-path';
/**
* A resumable session, and whose it is.
+15 -9
View File
@@ -17,6 +17,8 @@ import * as claudeManager from './claude-manager';
import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt';
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
import { DATA_PATH } from '../../data-path';
import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
// being told who to run as by the main server — one less thing that has to come from `officer` before
@@ -60,12 +62,15 @@ async function resolveOwner() {
const dbUser = await resolveOwner();
const email = dbUser.email;
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
// Same officer instance for both, so the fallback port has to agree. It used to default to 5000 for the
// WebSocket and 9010 for the REST base, which would have split them apart if PORT were ever unset.
const OFFICER_PORT = process.env.PORT ?? '9010';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${OFFICER_PORT}`;
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${OFFICER_PORT}`;
// Both URLs address the SAME officer instance — this file binds nothing, and the app serves HTTP and
// WebSocket on one listener. So the fallback port has to agree, and twice it did not: 5000 for the
// WebSocket against 9010 for the REST base here, and then 9010 here against 5000 in the app and every
// other sidecar. The second one was the worse half, because chat is the only thing that would have
// broken and nothing else would have looked wrong.
//
// 9000 everywhere now, matching server.tsx and .env.example. 9010 was the old installer's default
// (scripts/setup-old/setup.sh), which is why it was the only one of the three that ever matched a real
// machine.
const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
// Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them
@@ -75,7 +80,9 @@ const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.u
// perfect parity with terminal sessions (same config, credentials and transcript store,
// interchangeable via `claude --resume`). That absence of isolation is precisely why `chat` is an
// `execution` capability and can never be granted: this is a shell, not a feature flag.
const homeDir = process.env.HOME_DIR ?? homedir();
// Evaluated here, ABOVE the `process.env.HOME = homeDir` below: homedir() reads $HOME, so a
// read placed after that assignment would return whatever was last spawned into.
const homeDir = homedir();
const globalToolsDir = join(DATA_PATH, 'tools');
// The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled).
@@ -186,10 +193,9 @@ function generateMcpConfig(): string {
// Resolved lazily rather than once at boot: PM2 starts the proxy and the agent together, and
// `ensureProxySecret` persists on a 30s debounce, so on a first-ever boot the secret can be briefly
// absent. Re-checked before every spawn until it lands.
const ANTHROPIC_PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
function ensureAnthropicEnv(): void {
process.env.ANTHROPIC_BASE_URL ??= `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`;
process.env.ANTHROPIC_BASE_URL ??= ANTHROPIC_PROXY_URL;
// Without this, every platform chat session is capped at 200K context while the same `claude` in a
// terminal gets Opus 5's full 1M — for no reason other than the proxy hop above.
//
+1 -1
View File
@@ -5,7 +5,7 @@ type AnyCommand = SidecarCommand;
type AnyEvent = SidecarEvent;
type SidecarConnectorConfig = {
apiUrl: string; // ws://127.0.0.1:5000/api/sidecar/register
apiUrl: string; // ws://127.0.0.1:9000/api/sidecar/register
name: string;
capabilities: string[];
onCommand: (cmd: AnyCommand, reply: (msg: AnyEvent) => void) => void;
+1 -1
View File
@@ -4,8 +4,8 @@ import { initEmailIdle, stopEmailIdle } from './email-idle';
import { broadcastEmailNew } from './routes';
import { startEmailServer } from './http';
import { createSidecarConnector } from '../connect';
import { API_URL } from '../../officer-url.mjs';
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
// The sidecar used to reach BACK into the platform's queue over this socket to get a sync run —
// enqueueViaWs / listJobsViaWs and a pending-response map. Syncs run in this process now

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