master
1263
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3cb39662b5 |
desktop is a plugin too
Its sidecar, officer-vnc, was already excluded from the light profile, so calling it core was only the capability kind saying `execution` — which is about who may reach it, not whether a light install runs it. Unmounted the same way as the other twelve: `/desktop`, its capability's api and ws claims, the `desktop` websocket handler and its upgrade route. Implementation untouched. Also reverts a mistake from the previous commit. I had commented entries out of WSData's `provider` union and left `upgradeWs`'s parameter type listing them, which would have been a type error the moment either was used — and one I cannot see here, since node_modules is empty and tsgo does not run. Those unions describe possible values rather than what is served, and neither is a registration. Only registrations are commented now, which is what was asked for in the first place. Totality simulated again: 33 live mounts, 5 websockets, zero problems. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ffca309a77 |
switch off every plugin router, pending extraction
Twelve capabilities unmounted: gitea, music, photos, jellyfin, memos, calendar,
email, notify, transmission, soulseek, invoices, wallet. Mounts commented in
place, implementation untouched on disk, same as vault and the browser relay.
Each needed its capability's `api` claim commented in the same change.
assertCapabilityTotality check 2 refuses to boot on a capability claiming a
prefix nothing mounts, so unmounting alone would have stopped the server
starting — the opposite of what happened with vault, which is exempt.
music also claims two websockets. Its `ws: ['cliamp', 'cliamp-audio']` claim, the
two handlers in server.tsx and the provider union entries all had to move
together: check 3 fails on a served socket nothing claims, check 4 on a claimed
socket nothing serves.
calendar was the awkward one and the mount list was not where it lived. Four of
its six doors are not in the routes table:
honoServer.route('/dav', davSyncRouter) the sync door, top-level, not /api
honoServer.all('/.well-known/caldav') RFC 6764 autodiscovery
honoServer.all('/.well-known/carddav') the same for contacts
four routes in server.tsx /dav, /dav/*, and both well-knowns
and the two that ARE in the table carry trailing comments, which is why the first
pass silently missed them and left /api/caldav and /api/dav mounted with their
claim gone — exactly the boot failure this commit is about.
/vpn deliberately stays. It reads as a plugin (kind 'app') but it is OffTail
enrollment forwarding to officer-headscale, which is core because the tailnet is
the perimeter.
Verified by simulating assertCapabilityTotality against the post-change files
rather than trusting the edits: parse hono's live mounts, the registry's live
claims, server.tsx's live ws providers and totality's two exempt lists, then run
all four checks. It reported the two stale caldav mounts, which is how they were
found. Zero problems now, 34 live mounts, all core.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1eecc400a7 |
switch off Vaultwarden's routers, pending extraction into a plugin
Same treatment as the browser relay: mounts commented, code left on disk. Its
tables were already commented out of the schema earlier tonight, which is what
made this necessary — /api/vault was mounted against tables db:push no longer
creates, so a fresh core install shipped an endpoint that could only fail with a
Postgres "relation does not exist".
Vaultwarden is not one mount. Eight places had to go, and grepping for `vault`
found them only because several are not named after a router:
hono.ts /api/vault the authenticated reverse-proxy
/vaultwarden the unauthenticated one for the browser extension
VAULT_ONLY_PREFIXES loop /identity, /notifications, /icons, /events
the isBitwardenClient diverter an /api/* middleware that hands Bitwarden
clients to the vault router before anything else sees them
./api/vault/sidecar-server a SIDE-EFFECT import capturing the sidecar's port
UNPROTECTED_API_PREFIXES the '/vault' entry
server.tsx the 'vault' ws provider, its handler, and the notifications upgrade route
The side-effect import is the one worth naming: it registers a sidecar listener
and appears in no route table, so nothing about unmounting the routers would have
stopped it running.
No capability registry change, unlike browser and task-logs. Vaultwarden is
exempt from totality on both halves — EXEMPT_API_PREFIXES has '/vault'
("Bitwarden protocol clients authenticate to Vaultwarden, not to Officer") and
EXEMPT_WS_PROVIDERS has 'vault'. So nothing claims it and nothing breaks by
unmounting it. I said the opposite before checking; the check is what settled it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
595dd082a7 |
delete Task Logs
A complete read path over a table nothing could write to. task-logger.ts exported createTaskLog, appendToLog and finalizeLog, and none of the three was called anywhere in the tree — so `task_logs` could never gain a row, and the screen was permanently empty for everyone. The read half was fully wired: mounted router, capability claim, dock icon, two routes and a page-title rule. That is why it looked alive. Gone, in the order it was reached: Screens/Dashboard/TaskLogs/ the screen App.tsx /task-logs and /task-logs/:id Dashboard/index.tsx the export Layout/Dock.tsx the 'Logs' icon, and ScrollText with it state/usePageTitle.ts the title rule api/task-logs/task-logs.ts the router, and its mount in hono.ts api/task-logger.ts 101 lines of orphaned writer officer_db/src/operations/ the directory officer_db/src/schema.ts the export line officer_db/src/types.ts TaskLogSelect / TaskLogInsert capabilities/registry.ts loses '/task-logs' from the `tasks` capability's `api` AND `routes`. The api half is not optional: assertCapabilityTotality check 2 refuses to boot on a capability claiming a prefix nothing mounts, so unmounting the router while leaving the claim would have stopped the server starting. db:push now creates 21 tables, down from 43 at the start of the evening. officer_db/src/operations was one of the two lopsided directories the schema/queries merge exposed. integrations/ is the remaining one, and it is legitimate — it spans server and user-data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f9cd0a798a |
drop two dead tables: queue_jobs and terminal_containers
Neither had a reader or a writer anywhere in the tree. db:push created them on every fresh install and nothing ever touched them. queue_jobs is from when the background job engine kept its state in Postgres; it works on files now (src/servers/queue/storage.ts). terminal_containers held a docker id and port per user, from the architecture where every account ran in its own container — gone, as data-path.ts already records. Their types went with them: QueueJobSelect/Insert and TerminalContainerSelect/ Insert were unreferenced too. db:push now creates 22 tables. operations/schema.ts keeps task_logs and a note about what left and why, along with the thing still wrong there: it has no queries.ts, because task_logs is reached as `schema.taskLogs` from src/servers/api/task-logger.ts, past this package's boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
288b4bf683 |
each feature declares its own exports; the barrel is one line per feature
src/index.ts is 47 lines and reads as a list: `export * from './agent-panels';` and 22 more, under `db` and `schema`. It was 297 lines of hand-written named exports. What a feature exports now lives in <feature>/index.ts, beside the schema and queries it describes. Adding a query function is one file in one directory rather than that file plus a list three levels up that nothing enforces — a function missing from that list was invisible to all 107 consumers while existing and compiling perfectly. The old file had drifted in the ways a hand-maintained list does: twelve features were listed twice because values and types were separate statements repeating the path, soulseek three times, two features used an inline `type` specifier instead, and `db` and `schema` — the package's most fundamental exports — sat at line 270 with notify, types and app-store appended after them. The surface is byte-for-byte the same set. Checked rather than asserted: 247 exported names before, 247 after, no missing and no extra. The per-feature index files carry the same named lists the barrel did, so `export *` widens nothing. `operations` is deliberately not in the list, and the root file says why: it has a schema and no queries, its task_logs is reached as `schema.taskLogs` from src/servers past this package's boundary, and its other two tables are read by nothing at all. Verified: every file in the package parses, every relative import resolves to a real file, and all 107 consumers still parse. Not typechecked — empty node_modules, frozen installs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
68f2c55ecf |
one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4cebae1c85 |
schema barrel: core tables only, plugin tables commented
db:push now creates 24 tables instead of 43. The 19 belonging to sidecars a light install does not run are commented out in schema/index.ts, kept as the record of what each table is called and which file defines it. Core is ecosystem.light plus officer-headscale, plus the app store and service_connections — app-store/effects.ts reads the latter, so it is core however few plugins exist. Commented: email, music, notify, dav, photos, jellyfin, invoiceshelf, soulseek, vault, wallet. The reason this is only a barrel edit is worth writing down. drizzle.config.ts points `schema` at schema/index.ts, so that file is drizzle-kit's view of the schema — but it is NOT the runtime's. Every query imports its table object from a schema file and calls db.select().from(table), and nothing anywhere uses drizzle's relational API (db.query.X), which is the only thing the `schema` passed to drizzle() in db.ts is for. So a commented line removes a table from the database without removing a line of code. That only held after moving nine query modules off the barrel and onto their own schema file — email-accounts, music, notify, photos, jellyfin, invoiceshelf, soulseek, vault and wallet all imported their tables from '../schema', so commenting the barrel would have broken the query module, then its export, then its consumers. dav already did it the right way. With that done the cascade is gone: everything still compiles, and the tables simply are not created. Two corrections to what was asked, both checked rather than assumed. The query barrel (src/index.ts) has no bearing on db:push — drizzle never reads it — so commenting it would not have kept a single table out of the database. And vault is not plugin-only from the platform's side: /api/vault is mounted top-level in hono.ts for the Bitwarden client, and api/vault/router.ts imports getVaultTokens. Its TABLES are out, but that route still exists and will fail against them. Not typechecked — empty node_modules, frozen installs — and db:push was not run, since there is no database here. Every file in the package parses, as does everything in the tree importing officerdb, and nothing outside the package reached a plugin table through the exported schema namespace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9d6c196848 |
PUBLIC_URL comes back, and gen:index takes it as an argument
Removing PUBLIC_URL earlier today was wrong, and the Build section is where it would have surfaced: gen-index.ts exits 1 without it, so `bun gen:index` fails, index.gen.html is never written, and `bun start` has no page to serve. It came out because origin validation was being discontinued — but that was one of four consumers and the only one that is gone. gen-index needs it for OpenGraph tags, which crawlers fetch standalone and cannot resolve relative; task-api-env builds OFFICER_API_HOST from it; and dav/router hard-requires it, https only, to build an iOS profile. It is also the one value this machine genuinely cannot derive, which is what separates it from DATA_PATH and the rest that left today. gen:index now takes a URL as its first argument, ahead of the environment and .env: `bun gen:index https://officer.example.com`. Changing the public address is one command rather than an edit plus a regenerate, and a second address can be generated for without touching the install's .env. It also validates now. A relative or scheme-less value substituted silently and produced OpenGraph tags nothing can resolve — invisible until someone shares a link and the preview comes back blank. .env is PORT, PUBLIC_URL, POSTGRES_URL. Verified by running the section; all three paths through gen:index exercised (absent, valid argument, invalid). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8207824a81 |
build the secret store: one key per purpose, none in .env
.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in $OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the service user, created on first use. The design doc planned to move ONE at-rest key into the store. What shipped splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all seven, so one leak opened all of them — and it was named after whichever plugin needed it first, which is why it read as safe to change if you did not run a vault. A core install bootstraps two, jwt and headscale; the rest appear when their plugin first asks. The file IS the secret. No second key unlocks it, because a key beside the store it opens buys nothing. The gain was never secrecy, it is blast radius: bun auto-loads .env into all twenty pm2 processes, so a key there is readable from /proc/<pid>/environ of twenty processes — officer-music held the key that decrypts wallet seed envelopes. Two defects found by testing the store rather than reading it, both of which would have shipped: The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than inheriting the database's mode, and a freshly written key lives in the WAL before checkpoint — so the 0600 on the database was decorative. The 0700 directory covered it, but only until someone loosened the directory. PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set AFTER it. With twelve concurrent openers, six died on that line with SQLITE_BUSY. Every sidecar opens this store at boot, so they open it simultaneously by definition: most of them would have failed to start on a cold boot and none on a warm one. Fixed by ordering the pragmas; re-tested with twelve racing processes, one key, one row. crypto.ts takes a purpose as its first argument now, which the design doc had explicitly promised would not happen — 32 call sites across seven query modules. That promise is corrected in the doc rather than quietly dropped. Also live, not just comments: wallet/upstream.ts gated wallet storage on process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It asks the store now, and the question it answers changed — not "did somebody set a variable" but "can this process open the store", since the key is created on demand. assertSecretsClosed covers the store, its directory and its WAL. The jwt key mints owner tokens, so a member's shell reading it is strictly worse than the .env leak that check was written for. Not typechecked: node_modules is empty and installs are frozen, so the officerdb/secret-store subpath could not be resolved at runtime here — verified that officerdb/types fails identically, so it is the empty tree and not the new export. The store module itself was tested directly: creation, idempotence across processes, hasKey not creating, permissions, and the twelve-way race. Every changed file parses; the setup section runs and degrades correctly when the import is unavailable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1ccb21e67f |
CLAUDE.md: five capability kinds, not four
The file described a stricter model than the code enforces. It said terminal, chat, files, tasks, desktop and browser are all `kind: 'execution'` and therefore never shareable — but terminal, chat and files moved to `confined` on 2026-08-11 with per-user Linux accounts, and are grantable. The distinction matters and is now written down: a confined grant means nothing without a Linux user. authorize.ts:97 drops it for an account whose `osUser` is null, so "granted but unconfined" resolves to no access rather than to the owner's home — which is what it would otherwise resolve to, since getOwnerHomeDir ignores the email it is passed. Verified in the code, not inferred from the comment. Also brings the Data section in line with today: DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are no longer environment variables, the install root is derived from cwd, and assertInstallLayout is why a wrong cwd fails instead of relocating the install. And `bun setup` runs officer-setup.sh, which is sections 1-6 of 10 — worth saying, since the entry read as though it were finished. Registry needs real work for where this is going. This is only the docs catching up to what is there now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9864fb1a49 |
switch off the browser relay, pending extraction into a plugin
BROWSER_RELAY_PORT is gone and the second listener no longer starts. The Chrome
extension, api/browser/ and the /browser screen all stay on disk — this is going
to be extracted, and deleting it means writing it again.
Three things had to move together, and the middle one would have failed the boot
on its own:
server.tsx the listener, commented out with the variable name recorded
hono.ts the /api/browser mount, closed
registry.ts the 'browser' capability's claim on /browser, dropped
assertCapabilityTotality checks both directions: check 2 refuses to start on a
capability claiming a prefix nothing serves. Unmounting the router alone would
have left the registry describing it, and the server would not have come up.
The capability itself survives because it also claims /scrape, which shares
nothing with the relay — it launches its own headless chromium through playwright
and never speaks to the extension.
The comment in server.tsx carries the two facts that are not recoverable by
reading the remaining code. First, the port is an INPUT TO A CREDENTIAL:
relay-auth.ts derives each extension's token as HMAC(JWT_SECRET,
'officer-browser-relay-v1:${port}:${userId}:${salt}'), so bringing the relay back
on a different number silently invalidates every paired browser — reported by the
extension as "Relay not reachable", which SETUP.md blames on a wrong address,
port or token. Second, it cannot come back as a kernel-assigned port:0 like the
other sidecars: the extension is configured by hand and stores the value, so a
port that moves each restart breaks the pairing each restart.
Left alone deliberately: the /browser route in App.tsx, its Dock entry, and the
Settings → Browser Relay panel. They will not work against a closed endpoint.
Removing them is frontend work for the extraction, not part of switching the
listener off.
.env is down to PORT and POSTGRES_URL.
Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes two variables.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |