Compare commits
268
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15cc74bb1e | ||
|
|
9700959c9e | ||
|
|
7359af2f55 | ||
|
|
0dc88b5119 | ||
|
|
7e31564c15 | ||
|
|
98ba604aaf | ||
|
|
559560de48 | ||
|
|
4874c21dfd | ||
|
|
a84eedd126 | ||
|
|
5ef83ef0d0 | ||
|
|
8e77b9198d | ||
|
|
31d8d72527 | ||
|
|
affe967ef4 | ||
|
|
c945f50e60 | ||
|
|
d7b602ff34 | ||
|
|
84d6d7e7ce | ||
|
|
e2b0d5c06b | ||
|
|
cc889a864c | ||
|
|
640529ccba | ||
|
|
dd26e4a688 | ||
|
|
cca85a7524 | ||
|
|
2258812d4a | ||
|
|
29170dd922 | ||
|
|
dadfdc26af | ||
|
|
256f8185f5 | ||
|
|
abb8fe4320 | ||
|
|
9c2d6a97f7 | ||
|
|
027b10bd6e | ||
|
|
f9fd002ff4 | ||
|
|
5afa2d832e | ||
|
|
5ec354cfcb | ||
|
|
2e8ec845c8 | ||
|
|
b0fcd8b81b | ||
|
|
9b1c0a75b2 | ||
|
|
f78abbe05a | ||
|
|
e11e0b6475 | ||
|
|
0a55964db5 | ||
|
|
f1bd75853d | ||
|
|
9af52fd754 | ||
|
|
1e79b4effd | ||
|
|
a9bf51407e | ||
|
|
8bfcd40bd2 | ||
|
|
e930586878 | ||
|
|
05eb947bd1 | ||
|
|
de3340398c | ||
|
|
18c4ebd0b4 | ||
|
|
7d65732f77 | ||
|
|
b5db3c47e1 | ||
|
|
8545b427dd | ||
|
|
965ced52a6 | ||
|
|
4a9f23c759 | ||
|
|
b4dab16d2a | ||
|
|
2c89281bfc | ||
|
|
585c046a64 | ||
|
|
8cc51cfb40 | ||
|
|
8b6cb34ae0 | ||
|
|
8587ae20b7 | ||
|
|
e13128846b | ||
|
|
0e24aa3d52 | ||
|
|
543e88a9a6 | ||
|
|
2e3c935da6 | ||
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 | ||
|
|
fe0012635a | ||
|
|
547662842b | ||
|
|
eb1fd8c31a | ||
|
|
d85f089817 | ||
|
|
11710f283a | ||
|
|
b6feca8350 | ||
|
|
1d95ad3d1b | ||
|
|
e881015df5 | ||
|
|
3862558b92 | ||
|
|
977e30e782 | ||
|
|
edbe446b34 | ||
|
|
e36c6bb431 | ||
|
|
4d14e11f6c | ||
|
|
4c33ef7206 | ||
|
|
6c13e0d8f6 | ||
|
|
cbfe376a42 | ||
|
|
64f3de59fb | ||
|
|
081920c61f | ||
|
|
fb0286e1a9 | ||
|
|
d6f01862fe | ||
|
|
bf7e919593 | ||
|
|
2ac58e2007 | ||
|
|
9f15de3448 | ||
|
|
3ca7f5f331 | ||
|
|
2273941e71 | ||
|
|
41cdd1e63a | ||
|
|
7b32f5bc2f | ||
|
|
cc1eab7794 | ||
|
|
8826c2847e | ||
|
|
236a3a5481 | ||
|
|
d7d64cd6d6 | ||
|
|
cd209483e3 | ||
|
|
77f1284925 | ||
|
|
74b4c7908a | ||
|
|
f1cfc0042f | ||
|
|
88c9e96895 | ||
|
|
e5fe966308 | ||
|
|
60ab531294 | ||
|
|
f33e7474b0 | ||
|
|
bedc420d4d | ||
|
|
9b56e04b5e | ||
|
|
ea87ffed04 | ||
|
|
3cca07187e | ||
|
|
b5aa2e0387 | ||
|
|
0da15d78a1 | ||
|
|
e2faad40a3 | ||
|
|
61a3ae720f | ||
|
|
085afe7604 | ||
|
|
62cbf510cb | ||
|
|
b075f1f882 | ||
|
|
7280d13b93 | ||
|
|
fbb6d6c78c | ||
|
|
3cb39662b5 | ||
|
|
ffca309a77 | ||
|
|
1eecc400a7 | ||
|
|
595dd082a7 | ||
|
|
f9cd0a798a | ||
|
|
288b4bf683 | ||
|
|
68f2c55ecf | ||
|
|
4cebae1c85 | ||
|
|
9d6c196848 | ||
|
|
8207824a81 | ||
|
|
1ccb21e67f | ||
|
|
9864fb1a49 | ||
|
|
571d0a62ff | ||
|
|
f063fc0c08 | ||
|
|
c5adb4aa08 | ||
|
|
3c7f52ab77 | ||
|
|
e72cae4830 | ||
|
|
3bc06bba3c | ||
|
|
3f071c0b24 | ||
|
|
86079adb9a | ||
|
|
040ea41dbc | ||
|
|
32af97e260 | ||
|
|
cb67b22f80 | ||
|
|
9eabc3ee4c | ||
|
|
0bceace6f1 | ||
|
|
a64d5610e6 | ||
|
|
911b79b6a2 | ||
|
|
a0b083db8f | ||
|
|
8a0946ea39 | ||
|
|
ee21fa16f0 | ||
|
|
a5a8cd4e9c | ||
|
|
b724e3ffbe | ||
|
|
e120dfa36e | ||
|
|
30052e3295 | ||
|
|
d6a78d7b5a | ||
|
|
607a962115 | ||
|
|
cb3e7062b9 | ||
|
|
00e58931ff | ||
|
|
999362948f | ||
|
|
068a310bd3 | ||
|
|
0286cc6db6 | ||
|
|
5a33a517e7 | ||
|
|
2e70a6dd21 | ||
|
|
2b9e16c11e | ||
|
|
03cc01a135 | ||
|
|
4fcc34de18 | ||
|
|
a989f8fbfa | ||
|
|
c629a849d9 | ||
|
|
06492c3297 | ||
|
|
22a661131d | ||
|
|
85d8fa62f1 | ||
|
|
766c8ee655 | ||
|
|
2d544db07e | ||
|
|
46ce58dd12 | ||
|
|
2803bc34b7 | ||
|
|
538a2d3b6d | ||
|
|
ff7035a47a | ||
|
|
4d478cc0f1 | ||
|
|
6a29c39b74 | ||
|
|
059f0f6df2 | ||
|
|
a63a327065 | ||
|
|
09303299cc | ||
|
|
9d53ff506e | ||
|
|
f9c6b7925a | ||
|
|
4339ab829d | ||
|
|
28673869c3 | ||
|
|
08e7de1b6d | ||
|
|
0da184d082 | ||
|
|
f176b92378 | ||
|
|
bdf13331ae | ||
|
|
7a5cf89819 | ||
|
|
192293cdca | ||
|
|
9591f917f5 | ||
|
|
b1cc916258 | ||
|
|
d9d4085033 | ||
|
|
c0eb3e3a85 | ||
|
|
fbb90c917d | ||
|
|
3fb0e5c887 | ||
|
|
458510a0a8 | ||
|
|
c87127ff93 | ||
|
|
6ffd3534bd | ||
|
|
d19dc5a92a | ||
|
|
d4b9b7b334 | ||
|
|
6947284f1b | ||
|
|
9b0e05bfd8 | ||
|
|
716a6e2750 | ||
|
|
b5948f9673 | ||
|
|
6480788979 | ||
|
|
163f8d5899 | ||
|
|
1beb357f2e | ||
|
|
454faf5406 | ||
|
|
f896d4882f | ||
|
|
a5ef9f7662 | ||
|
|
dd655577e3 | ||
|
|
5cb243eed9 | ||
|
|
7622239949 | ||
|
|
bba6d854bc | ||
|
|
dbdef23d29 | ||
|
|
41ff8030e9 | ||
|
|
34bb8fc22a | ||
|
|
44141faf0a | ||
|
|
dda214ffb0 | ||
|
|
483bb15d8a | ||
|
|
315073cf3b | ||
|
|
79da78008a | ||
|
|
015e280e5c | ||
|
|
e9d0261e87 | ||
|
|
2eedbcda54 | ||
|
|
4e404f17c8 | ||
|
|
cec8fbe57e | ||
|
|
76cd7c20bf | ||
|
|
a2f63dc534 | ||
|
|
46799dada8 | ||
|
|
f34d7fef70 | ||
|
|
35715546e2 | ||
|
|
f5f509a99d | ||
|
|
9c353f5f0d | ||
|
|
37adc65a12 | ||
|
|
a6acfea9a6 | ||
|
|
a730fc0fe0 | ||
|
|
dcaee2fc95 | ||
|
|
7040536f1f |
+36
-17
@@ -1,18 +1,37 @@
|
||||
# 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>"
|
||||
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
||||
MAIL_TRANSPORT="smtp://localhost:1025"
|
||||
|
||||
# Where Officer is reached from a browser — the one value the machine cannot derive. Read by
|
||||
# `bun gen:index` (OpenGraph tags, which need an absolute URL), the task API host, and the CalDAV iOS
|
||||
# profile builder, which additionally requires https.
|
||||
#
|
||||
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
|
||||
PUBLIC_URL=http://localhost:9000
|
||||
|
||||
# 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
|
||||
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
|
||||
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
|
||||
# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
|
||||
# per purpose, created on first use. See docs/secret-store.md.
|
||||
#
|
||||
# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2
|
||||
# processes, so a key here is readable from /proc/<pid>/environ of twenty processes that mostly have
|
||||
# no business with it — officer-music held the key that decrypts wallet seed envelopes.
|
||||
#
|
||||
# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres
|
||||
# unreadable, and for the wallet seed that is unrecoverable.
|
||||
|
||||
DATA_PATH=/path/to/data
|
||||
OFFICER_ITEMS_DIR=/path/to/officer-items
|
||||
HOME_DIR=/home/user
|
||||
BROWSER_RELAY_PORT=18792
|
||||
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
||||
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
||||
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
|
||||
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
|
||||
# only loads this file, so `bun dev` against a production .env runs fully hardened.
|
||||
# PUBLIC_BUILD_ENV=dev
|
||||
|
||||
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR were here until 2026-08-12 and are no longer read.
|
||||
# The install root is derived as the parent of the working directory (src/servers/data-path.ts), so
|
||||
# data/, capabilities/ and dockers/ follow from it; the owner's home comes from the OS. Three values
|
||||
# that had to agree with each other and with the disk became one that cannot disagree.
|
||||
|
||||
# ── Sidecars ────────────────────────────────────────────────────────────────────────────────────
|
||||
# Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy
|
||||
@@ -31,14 +50,14 @@ BROWSER_RELAY_PORT=18792
|
||||
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
|
||||
# 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.
|
||||
|
||||
+53
@@ -51,3 +51,56 @@ 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
|
||||
|
||||
# Generated by officer-setup, describing THIS install's processes. Never committed:
|
||||
# the repository has no ecosystem file at all any more, and the next machine
|
||||
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
||||
ecosystem.config.cjs
|
||||
|
||||
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
|
||||
# rewritten on every install. See servers/plugins/generate.ts.
|
||||
build/
|
||||
build.next/
|
||||
src/apps/officer-web/Plugins.gen.tsx
|
||||
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||
|
||||
# drizzle-kit's generated migrations. Nothing applies them — there is no __drizzle_migrations table and
|
||||
# `drizzle-kit migrate` has never been run here; `bun db:push` diffs the schema code against the live
|
||||
# database and alters it directly. The schema code is the source of truth (src/databases/CLAUDE.md).
|
||||
#
|
||||
# Ignored rather than merely unused, because `bun db:gen` reads src/schema.ts — whose last line imports
|
||||
# plugin-schemas.gen.ts, itself generated from the plugin DIRECTORIES on this machine. So a generated
|
||||
# migration describes whichever plugins happen to be checked out here, and committing one would launder
|
||||
# per-machine state into the repository: run it with music installed and history gains music_*; run it on
|
||||
# a fresh clone and the next commit deletes them again.
|
||||
#
|
||||
# The old 0000_new_princess_powerful.sql is deleted from the WORKING TREE but left in history. It had 36
|
||||
# tables and described a schema from before capabilities, api_keys, the app store and the plugin system
|
||||
# existed, including three (task_logs, terminal_containers, queue_jobs) that no longer exist at all.
|
||||
#
|
||||
# Purging it from history was tried on 2026-08-15 and deliberately undone. This codebase cites 76 commit
|
||||
# SHAs in comments and docs as evidence — `totality.ts` points at 2873948 for the websocket incident,
|
||||
# `registry.ts` at 044aacf4, CLAUDE.md at f35c145 — and a filter-repo run rewrites every one of them.
|
||||
# Three stale files nobody reads in old commits are not worth 76 dangling citations in a codebase whose
|
||||
# documentation works by pointing at the commit that proves the claim.
|
||||
#
|
||||
# Generate one locally whenever a diff is useful to read. It stays local.
|
||||
src/databases/officer_db/migrations/
|
||||
|
||||
# ── plugins/ holds documentation, not plugins ──
|
||||
#
|
||||
# Every plugin is its own repository (gitea.officer.dev/plugins/*) and arrives here by `git clone` when
|
||||
# somebody installs it. A cloned plugin carries its own .git, so leaving it tracked means `git add -A`
|
||||
# commits a gitlink — a pointer to a commit this repo does not contain. That happened on 2026-08-15.
|
||||
#
|
||||
# The trailing slash matters: this ignores DIRECTORIES only, so files at the top of plugins/ —
|
||||
# EXTRACTING-A-PLUGIN.md and anything beside it — stay tracked. The documentation about plugins belongs
|
||||
# to the platform; the plugins do not.
|
||||
/plugins/*/
|
||||
|
||||
@@ -12,22 +12,22 @@ look for `CLAUDE.md`, without the two drifting apart.
|
||||
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
|
||||
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
|
||||
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
|
||||
per-role capability grants, resolved on every request.
|
||||
per-role permission grants, resolved on every request.
|
||||
|
||||
If a design question turns on "which user", the answer depends on the surface: real for the **app**
|
||||
capabilities (gitea, music, photos, email, calendar…), and still always **the owner** for anything
|
||||
permissions (gitea, music, photos, email, calendar…), and still always **the owner** for anything
|
||||
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
|
||||
`kind: 'execution'` and can never be granted. `src/servers/capabilities/registry.ts` is the authority.
|
||||
`kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority.
|
||||
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Capabilities"
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions"
|
||||
section of `CLAUDE.md` before adding one.
|
||||
|
||||
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
|
||||
management". That was written to correct an *older* drift in the opposite direction — a fictional
|
||||
management". That was written to correct an _older_ drift in the opposite direction — a fictional
|
||||
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
|
||||
paragraph above; treat the capability registry as the source of truth over either.
|
||||
paragraph above; treat the permission registry as the source of truth over either.
|
||||
|
||||
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as
|
||||
This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as
|
||||
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
||||
and no restart.
|
||||
|
||||
|
||||
@@ -14,18 +14,28 @@ written: `users` holds six rows. The accurate statement is narrower and more use
|
||||
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
||||
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
||||
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
|
||||
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Terminal, chat, tasks, files, desktop and browser
|
||||
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's
|
||||
home, so there is no level of "read" that makes them safe. They have no level at all and the grants
|
||||
API refuses to store one.
|
||||
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
|
||||
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
|
||||
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
|
||||
home, and the kernel refusing everything above it.
|
||||
|
||||
So "which user is this" now has a real answer for the **app** surface (gitea, music, photos, email,
|
||||
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
|
||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
|
||||
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
|
||||
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
|
||||
HTTP routes, the websocket doors and the dock together.
|
||||
|
||||
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
|
||||
So "which user is this" has a real answer for the **app** surface (gitea, music, photos, email,
|
||||
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||
anything under `execution`.
|
||||
|
||||
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||
@@ -42,18 +52,20 @@ One Bun process (`src/server.tsx`) serves everything:
|
||||
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
||||
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
||||
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
||||
- a browser relay on its own port (`BROWSER_RELAY_PORT`, default 18792)
|
||||
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
|
||||
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
|
||||
|
||||
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
||||
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
||||
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
|
||||
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
||||
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
||||
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
||||
source of truth.
|
||||
|
||||
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
|
||||
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
|
||||
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
|
||||
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
||||
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
||||
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
||||
@@ -72,7 +84,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 permission 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 +104,16 @@ 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
|
||||
@@ -104,16 +125,27 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
||||
Two stores, and the split matters:
|
||||
|
||||
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
||||
email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in
|
||||
`src/queries/`, types inferred from the schema in `src/types.ts`.
|
||||
email accounts, queue and pipeline jobs. One directory per feature holding `schema.ts` and
|
||||
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
|
||||
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
|
||||
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory
|
||||
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no
|
||||
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite
|
||||
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in
|
||||
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under
|
||||
`DATA_PATH`) versus `getOwnerHomeDir` (the owner's real login home when `HOME_DIR` is set, which is
|
||||
where terminals, chats and task runs actually execute).
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
|
||||
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
|
||||
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the
|
||||
per-account email SQLite stores — those are the **email sidecar's**, and nothing in the platform opens
|
||||
them.
|
||||
|
||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
|
||||
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
|
||||
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
|
||||
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
|
||||
failing.
|
||||
|
||||
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
|
||||
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
|
||||
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
|
||||
passed).
|
||||
|
||||
### Schema changes use `push`, not migrations
|
||||
|
||||
@@ -134,35 +166,37 @@ 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 permission 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.
|
||||
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||
authenticated request until the server restarts.
|
||||
|
||||
### Capabilities — read this before mounting a router
|
||||
### Permissions — 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/permission-gate.ts` → `permissions/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`.
|
||||
|
||||
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
|
||||
(owner only, and `execution` is never grantable at any level).
|
||||
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them.
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). 27 entries as of 2026-08-13.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
|
||||
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
|
||||
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
|
||||
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
||||
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
||||
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
||||
@@ -173,7 +207,7 @@ So **adding a router means adding one line to `CAPABILITIES`**. If the surface g
|
||||
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
||||
is how the hole happened the first time.
|
||||
|
||||
The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||
into a refusal.
|
||||
|
||||
@@ -186,9 +220,17 @@ bunx tsgo # typecheck (not tsc)
|
||||
bun test # tests
|
||||
bun format # prettier over every dirty file — see the note below before running it
|
||||
bun db:push # apply the schema to Postgres
|
||||
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
```
|
||||
|
||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
|
||||
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
|
||||
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
|
||||
needs to, and on macOS never does, because Homebrew refuses to run as root.
|
||||
|
||||
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
||||
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
||||
|
||||
@@ -379,6 +421,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
|
||||
|
||||
-320
@@ -1,320 +0,0 @@
|
||||
# Music API (`/api/music/*`)
|
||||
|
||||
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
||||
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
||||
|
||||
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns
|
||||
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
||||
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
||||
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
||||
- **`<rel>`:** an album folder path **relative to the `Music` root** (e.g. `Albums/AC-DC/[1980] Back in Black`).
|
||||
|
||||
## Auth
|
||||
|
||||
Every endpoint is behind the standard user auth. Two ways to pass the JWT:
|
||||
|
||||
- **Header:** `Authorization: Bearer <jwt>` (normal fetches).
|
||||
- **Query:** `?token=<jwt>` — for media elements / native players that can't set headers (audio, images).
|
||||
|
||||
`401` = no token · `403` = invalid/expired token.
|
||||
|
||||
---
|
||||
|
||||
## 1. Playback — stream a track
|
||||
|
||||
```
|
||||
GET /api/music/stream?path=<home-relative>&token=<jwt>
|
||||
```
|
||||
|
||||
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||
|
||||
| Case | Status | Headers |
|
||||
|---|---|---|
|
||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||
|
||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
|
||||
VBR MP3s. No need to scan the file.
|
||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||
|
||||
```
|
||||
curl -H "Authorization: Bearer $JWT" -H "Range: bytes=0-1023" \
|
||||
"$BASE/api/music/stream?path=Music/Albums/AC-DC/[1980]%20Back%20in%20Black/01%20Hells%20Bells.mp3" -D -
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Library index — the synced cache
|
||||
|
||||
The server maintains a cache tree that **mirrors the library**, one entry per album folder. The app syncs
|
||||
this instead of walking + ID3-parsing the library itself.
|
||||
|
||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
|
||||
|
||||
### 2.1 Manifest — one call, whole library
|
||||
|
||||
```
|
||||
GET /api/music/manifest
|
||||
```
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||
"albums": {
|
||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
||||
// …
|
||||
}
|
||||
}
|
||||
```
|
||||
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||
directly in that artist/album folder — their per-file metadata is in that folder's `meta.json` (§2.2). A folder
|
||||
may have any mix of `tracks`, `videos`, and `disco`.
|
||||
|
||||
### 2.2 Album metadata
|
||||
|
||||
```
|
||||
GET /api/music/meta?path=<rel>
|
||||
```
|
||||
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||
```jsonc
|
||||
{
|
||||
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||
"cover": "cover.jpg", // present only if a cover exists
|
||||
"tracks": [
|
||||
{
|
||||
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||
"title": "Hells Bells",
|
||||
"artist": "AC/DC",
|
||||
"albumArtist": "AC/DC",
|
||||
"album": "Back in Black",
|
||||
"track": "1",
|
||||
"year": "1980",
|
||||
"durationSec": 312,
|
||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||
}
|
||||
// …
|
||||
],
|
||||
"videos": [ // present only for folders that contain video files
|
||||
{
|
||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||
"durationSec": 8130,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
||||
}
|
||||
// …
|
||||
]
|
||||
}
|
||||
```
|
||||
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||
omitted entirely when the folder has none.
|
||||
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||
|
||||
### 2.3 Cover
|
||||
|
||||
```
|
||||
GET /api/music/cover?path=<rel>
|
||||
```
|
||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
Only meaningful when the manifest entry has `"cover": true`.
|
||||
|
||||
#### 2.3.1 Video poster
|
||||
|
||||
```
|
||||
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||
```
|
||||
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||
|
||||
#### 2.3.2 Lyrics
|
||||
|
||||
```
|
||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||
```
|
||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||
|
||||
Sources, in precedence order (indexed at build time): an external **`<track basename>.lrc`** > external
|
||||
**`<track basename>.txt`** > **embedded** lyrics in the audio tags (`lyrics` / `lyrics-<lang>` /
|
||||
`unsyncedlyrics`). A `.txt` (or embedded) whose text actually contains `[mm:ss]` lines is served as `lrc`.
|
||||
|
||||
### 2.4 Discography (artist album grouping)
|
||||
|
||||
For artist folders (manifest entry with `"disco": true`), this returns a map of **album folder → release
|
||||
type**, so the player can split an artist's album list into sections (Studio, Live, Compilation, Single, EP…).
|
||||
|
||||
```
|
||||
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||
```
|
||||
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
```jsonc
|
||||
{
|
||||
"artist": "Anthrax",
|
||||
"albums": {
|
||||
"[1984] Fistful Of Metal": "Studio",
|
||||
"[1985] Armed And Dangerous": "EP",
|
||||
"[1994] The Island Years": "Live",
|
||||
"[1991] Attack Of The Killer B's": "Compilation"
|
||||
// …
|
||||
}
|
||||
}
|
||||
```
|
||||
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||
`DJ-Mix`, `Demo`, `Mixtape`, `Bootleg`, `Other` (unknown values pass through as-is). The player defines
|
||||
section order.
|
||||
- An album folder **not present** here has no classification → put it in an "Other"/uncategorized section.
|
||||
- Source of truth is each artist's `_discography.md` (author-maintained); this JSON is derived from it and
|
||||
re-generated whenever that file changes (its `v` bumps independently of the albums' `meta`/`cover`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Building / refreshing the index
|
||||
|
||||
The index is built **on demand** (nothing is pre-built or scheduled). A build is **incremental** — albums
|
||||
whose `v` is unchanged are skipped — and it **prunes** albums removed from the library.
|
||||
|
||||
### 3.1 Trigger
|
||||
|
||||
```
|
||||
POST /api/music/reindex → returns IndexStatus (running: true)
|
||||
GET /api/music/reindex/status → IndexStatus snapshot
|
||||
```
|
||||
|
||||
`IndexStatus`:
|
||||
```jsonc
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": 1785034701973, "finishedAt": null,
|
||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Live progress — SSE
|
||||
|
||||
```
|
||||
GET /api/music/reindex/stream
|
||||
```
|
||||
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||
`IndexReport`) and **closes** the stream.
|
||||
|
||||
```
|
||||
event: progress
|
||||
data: {"running":true,"foldersScanned":45,"tracksIndexed":320,"albumsBuilt":12,"albumsSkipped":3,"coversSaved":12,"currentPath":"Albums/AC-DC/[1980] Back in Black", …}
|
||||
|
||||
event: done
|
||||
data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":320,"coversSaved":12,"elapsedSec":37.2,"error":null}
|
||||
```
|
||||
|
||||
`IndexReport` (the `done` payload):
|
||||
```jsonc
|
||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
||||
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
||||
```
|
||||
|
||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended resync algorithm (app side)
|
||||
|
||||
Keep the last `manifest.albums` you synced. On resync:
|
||||
|
||||
1. `GET /api/music/manifest`.
|
||||
2. For each `<rel>` in the new manifest:
|
||||
- **new**, or **`v` differs** from your stored copy → fetch `GET /meta?path=<rel>` (+ `GET /cover?path=<rel>`
|
||||
if `cover:true`, + `GET /discography?path=<rel>` if `disco:true`); store them under your local `<rel>/`.
|
||||
- **`v` unchanged** → **skip** (no download).
|
||||
3. For each `<rel>` you have locally that's **absent** from the new manifest → delete it.
|
||||
4. Save the new manifest as your baseline.
|
||||
|
||||
Optionally trigger a fresh server build first via `GET /reindex/stream` (and show progress from its
|
||||
`progress`/`done` events) so the manifest reflects the latest library before you diff.
|
||||
|
||||
Result: a resync after adding one album = 1 manifest fetch + that one album's `meta` + `cover`. Nothing else moves.
|
||||
|
||||
---
|
||||
|
||||
## Per-user state — Favorites & Currently-playing
|
||||
|
||||
Unlike everything above (library data served by the sidecar), these are **per-user** and served by the
|
||||
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
||||
supplies; the server never interprets them:
|
||||
|
||||
| kind | key |
|
||||
|---|---|
|
||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||
| `artist` | music-rel — `Albums/AC-DC` |
|
||||
|
||||
### Favorites
|
||||
|
||||
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||
```json
|
||||
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
|
||||
```
|
||||
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||
(a repeat add is a no-op).
|
||||
- **`DELETE /api/music/favorites?kind=<kind>&key=<key>`** → `{ ok: true }` (no-op if not set). Key passed as a
|
||||
query param (URL-encode it).
|
||||
- `400 { error: "kind and key required" }` on a bad/missing kind or empty key.
|
||||
|
||||
### Currently-playing (resume)
|
||||
|
||||
One snapshot per user — persist while playing (throttled) and on pause / track-change / close; read it on
|
||||
launch to offer "resume".
|
||||
|
||||
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||
```json
|
||||
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
|
||||
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
|
||||
```
|
||||
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||
→ `{ ok: true }` (upsert). Omitted fields default to `""`/`0`.
|
||||
- **`DELETE /api/music/now-playing`** → `{ ok: true }` (clear, e.g. on stop).
|
||||
- `400 { error: "homePath required" }` if `homePath` is missing/empty.
|
||||
|
||||
---
|
||||
|
||||
### Playlists
|
||||
|
||||
Server-side playlists, scoped to the calling user. Items are track **keys** — the same
|
||||
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
||||
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||
|
||||
| method | path | body | returns |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||
|
||||
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
|
||||
audio files* is a separate, later feature.)
|
||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||
+17
-10
@@ -6,13 +6,13 @@ Everything the `/system-monitor` web screen renders, for building the same in th
|
||||
|
||||
- Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string
|
||||
(required for the SSE endpoints — `EventSource` can't set headers).
|
||||
- **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and
|
||||
- **Owner-only.** These routes belong to the `server-admin` permission, which is `kind: 'admin'` and
|
||||
therefore never grantable — a non-owner account gets `403` here whatever its role. The full
|
||||
**officer-mobile** client (which authenticates as the owner) has access; the music app does not.
|
||||
- Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were
|
||||
confined to a hardcoded `/api/auth` + `/api/music`. That list is gone, replaced by per-role
|
||||
capability grants. The *outcome* for these routes is unchanged — still owner-only — but the reason is
|
||||
now the capability's kind, not a two-element array.
|
||||
permission grants. The _outcome_ for these routes is unchanged — still owner-only — but the reason is
|
||||
now the permission's kind, not a two-element array.
|
||||
- All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`.
|
||||
|
||||
---
|
||||
@@ -20,7 +20,7 @@ Everything the `/system-monitor` web screen renders, for building the same in th
|
||||
## `GET /api/system-monitor/stats`
|
||||
|
||||
One full snapshot. Poll it on a steady interval (the web client uses **2 s**) — a few fields are rates
|
||||
computed from the delta since your *previous* call (see notes), so a steady cadence matters.
|
||||
computed from the delta since your _previous_ call (see notes), so a steady cadence matters.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -65,6 +65,7 @@ computed from the delta since your *previous* call (see notes), so a steady cade
|
||||
```
|
||||
|
||||
**Notes**
|
||||
|
||||
- `net.*BytesPerSec` and `power.cpuWatts` are **deltas since the previous `/stats` call**. The **first**
|
||||
call returns `0`/`null` for these; steady-interval polling gives stable numbers.
|
||||
- `cpuWatts` is usually `null` — RAPL `energy_uj` is root-only unless a udev rule opens it. `gpuWatts` works.
|
||||
@@ -77,16 +78,18 @@ computed from the delta since your *previous* call (see notes), so a steady cade
|
||||
```jsonc
|
||||
{
|
||||
"processes": [
|
||||
{ "id": 0, // pm2 id (pm_id) — use this for the logs endpoint
|
||||
{
|
||||
"id": 0, // pm2 id (pm_id) — use this for the logs endpoint
|
||||
"name": "officer",
|
||||
"status": "online", // online | stopped | errored | …
|
||||
"pid": 3339851, // OS pid, or null
|
||||
"cpuPct": 0,
|
||||
"memBytes": 10354688,
|
||||
"restarts": 44,
|
||||
"uptimeMs": 420000 } // 0 unless status === "online"
|
||||
"uptimeMs": 420000,
|
||||
}, // 0 unless status === "online"
|
||||
],
|
||||
"error": "…" // present only if pm2 couldn't be read
|
||||
"error": "…", // present only if pm2 couldn't be read
|
||||
}
|
||||
```
|
||||
|
||||
@@ -95,14 +98,16 @@ computed from the delta since your *previous* call (see notes), so a steady cade
|
||||
```jsonc
|
||||
{
|
||||
"containers": [
|
||||
{ "id": "abc123def456", // short id (12 chars) — use for the logs endpoint
|
||||
{
|
||||
"id": "abc123def456", // short id (12 chars) — use for the logs endpoint
|
||||
"name": "jellyfin",
|
||||
"image": "jellyfin/jellyfin",
|
||||
"state": "running", // running | exited | …
|
||||
"status": "Up 3 hours",
|
||||
"ports": "0.0.0.0:9301->8096/tcp" }
|
||||
"ports": "0.0.0.0:9301->8096/tcp",
|
||||
},
|
||||
],
|
||||
"error": "…"
|
||||
"error": "…",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -114,12 +119,14 @@ Both stream one **`data: <log line>`** frame per line, plus `: hb` heartbeat com
|
||||
server kills the underlying tail when the connection closes. Open with `EventSource` using `?token=`.
|
||||
|
||||
### `GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n>`
|
||||
|
||||
- `id` — **numeric** pm2 id from `/pm2` (required).
|
||||
- `lines` — initial backlog, default `100`, max `1000`.
|
||||
- Source: `pm2 logs <id> --raw` (combined stdout+stderr, follows live). The first frames include a short
|
||||
pm2 `[TAILING] …` header.
|
||||
|
||||
### `GET /api/system-monitor/docker/logs?id=<container>&lines=<n>`
|
||||
|
||||
- `id` — container id or name from `/docker` (charset-validated).
|
||||
- `lines` — initial backlog (`--tail`), default `100`, max `1000`.
|
||||
- Source: `docker logs -f --tail <n> <id>` (combined stdout+stderr).
|
||||
|
||||
@@ -4,34 +4,48 @@ Deferred work.
|
||||
|
||||
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
|
||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||
indirection as accidental complexity. **That direction was reversed.** The capability permission model
|
||||
indirection as accidental complexity. **That direction was reversed.** The permission permission model
|
||||
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
|
||||
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
|
||||
below that proposed deleting it have been removed rather than left to mislead the next reader.
|
||||
|
||||
What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as
|
||||
What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||
|
||||
## 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
|
||||
@@ -61,24 +75,34 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
|
||||
`docs/workspace-panel-todo.md` §3.
|
||||
|
||||
- [ ] **`capabilities/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
|
||||
- [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
|
||||
registry functions and the totality check; the resolver that does the owner bypass, the grant
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_.
|
||||
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||
Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto
|
||||
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||
broken panel or an endless spinner rather than a clean refusal.
|
||||
|
||||
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
|
||||
every caller resolves to the owner's real login home. Safe only because all seven callers sit
|
||||
behind `execution` capabilities. If per-user home confinement is ever attempted, this is the
|
||||
behind `execution` permissions. If per-user home confinement is ever attempted, this is the
|
||||
function to start from.
|
||||
|
||||
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
|
||||
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
|
||||
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
|
||||
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
|
||||
owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the
|
||||
owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the
|
||||
thing to fix first if any of them is ever granted.
|
||||
|
||||
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
|
||||
@@ -96,7 +120,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
|
||||
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
|
||||
`notify` capability could deregister another's device; and `POST /_officer/notify` let a request
|
||||
`notify` permission could deregister another's device; and `POST /_officer/notify` let a request
|
||||
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
|
||||
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
|
||||
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
|
||||
@@ -230,6 +254,74 @@ write**, orphaning a pty per reload; Running Shells has **404'd since 2026-07-31
|
||||
error boundaries** anywhere in the repo; and `dashboards.id` is a **global** primary key fed by
|
||||
`slugify(name)`, so two members naming a dashboard the same thing collide.
|
||||
|
||||
## Code editor
|
||||
|
||||
- [ ] **The owner wants to go deeper here — ideas pending (noted 2026-08-15).** Raised right after the file
|
||||
browser gained its first links into `/code-editor`. Do this after the file-browser test pass.
|
||||
|
||||
What is worth knowing before starting, all established on 2026-08-15:
|
||||
|
||||
**It has been effectively invisible for six months.** The app landed 2026-02-21 (`9e9acd96`), the
|
||||
screen 2026-02-17, and `1fa3a659` on 2026-07-25 is titled "**restore** the code editor screen" — so
|
||||
it was dropped and brought back at least once. It IS in `CORE_DOCK_ITEMS` as "Editor", but
|
||||
`DEFAULT_DOCK_PATHS` is `['/', '/files', '/terminal', '/dashboards', '/chat']` and the owner's own
|
||||
`dock_configs` row does not list it either. Until today the only way in was typing the URL.
|
||||
|
||||
**So it has no users, and that is the risk.** A surface nobody reached for six months is where things
|
||||
rot quietly. Nothing about the editor itself has been verified — whether saving works, how the file
|
||||
tree behaves, what closing the last tab does, what an unsaved-changes navigation does. Only the
|
||||
plumbing was checked: `?file=` is read (`EDITOR_FILE_PARAM`), `/code-editor` renders with `urlState`,
|
||||
and `CodeEditor.tsx:60-76` fetches a path that arrives in the URL rather than only matching already
|
||||
open tabs.
|
||||
|
||||
**One bug of exactly this kind was already found and fixed** in the FileViewer's editor on the same
|
||||
day: the Edit toggle was gated on `content` rather than `content !== null`, so an empty file — the
|
||||
one "New file" produces — could not be edited at all. Assume siblings.
|
||||
|
||||
**Open question, deliberately not decided:** `Open in editor` in the file browser's row menu still
|
||||
navigates to `/code-editor`, which is the last action that leaves the browser. Editing now happens
|
||||
in place via double-click → the viewer's pane. Either `/code-editor` earns its keep as a genuinely
|
||||
different tool (tree, tabs, multi-file) or that menu item should go.
|
||||
|
||||
## Files app — routing
|
||||
|
||||
**The URL model is done (2026-08-15).** The folder is the pathname —
|
||||
`/files/archive/Tests/platform/docs` — and `?view=` is a NAME within it, not a second copy of the path.
|
||||
Deep links and refresh work, which they never had. Landed across `559560de`, `98ba604a`, `7e31564c`,
|
||||
`0dc88b51`, `7359af2f`, with `files-route.ts` + 23 tests as the one owner of encoding and of which params
|
||||
belong to the overlay.
|
||||
|
||||
Four bugs came out of that work and are fixed: closing a pane sent you home; `?view=` was stripped on
|
||||
every page load by a mount effect that had made deep links impossible since 2026-02-23; navigating kept a
|
||||
pane belonging to the folder you left; and the breadcrumb opted out of that last rule because it is a
|
||||
`<Link>` and never called the function enforcing it.
|
||||
|
||||
- [ ] **Rows are still not links.** This is the half that did not get done, and it is the original
|
||||
finding. Both list and grid render `<div onDoubleClick>` (`FileItem.tsx:701,748`), and search
|
||||
results render `<div onClick>` (`FileViewContainer.tsx:187`) — the "opaque click" that
|
||||
`docs/navigation-audit.md` names. No cmd-click into a new tab, no middle-click, not
|
||||
link-focusable, and the target lives in a closure rather than the DOM.
|
||||
|
||||
It is now much easier than it was: `folderHref(path)` gives a folder's URL and `hrefForPath` gives
|
||||
a full `To` with the overlay already stripped, so a row becomes
|
||||
`<Link to={hrefForPath(childPath)}>` and a file row becomes a link that sets `?view=<name>`. The
|
||||
breadcrumb has done exactly this since before today and is the model.
|
||||
|
||||
Watch for: a row is also a click target for SELECTION (single click selects, double opens,
|
||||
shift/cmd extend). An anchor changes what those gestures mean by default, so the selection
|
||||
handlers have to keep working and `preventDefault` where they win.
|
||||
|
||||
- [ ] **The acts that clear the overlay have no owner, only the list does.** `VIEWER_PARAMS` and
|
||||
`withoutViewerParams` live in `files-route.ts` and are shared. But every site still decides FOR
|
||||
ITSELF whether to call them, which is precisely how the breadcrumb shipped wrong an hour after the
|
||||
rule was written. Three bugs in a row came from this. A single `navigateToFolder()` that every
|
||||
caller must go through — rather than a helper they may remember — is the fix if a fourth appears.
|
||||
|
||||
- [ ] **A panel's folder is still not addressable.** `urlPath` is true only on the `/files` screen;
|
||||
panels keep the path in local state, so they always open at their base and cannot be linked or
|
||||
restored. Deliberate as written — a dashboard can hold two browsers and one URL cannot serve both —
|
||||
but worth re-deciding rather than inheriting.
|
||||
|
||||
## Known bugs
|
||||
|
||||
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
|
||||
|
||||
+10
-1
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
|
||||
coverage = true
|
||||
coverageDir = "coverage"
|
||||
preload = ["./test-setup.ts"]
|
||||
root = "./src"
|
||||
# The repo, not just `src` — a plugin's tests are the platform's tests.
|
||||
#
|
||||
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
|
||||
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
|
||||
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
|
||||
# extraction would have taken its tests out of the suite the same way.
|
||||
#
|
||||
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
|
||||
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
|
||||
root = "."
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# The documentation, triaged
|
||||
|
||||
**2026-08-13.** A map of what is in here, what it is for, and what should happen to it. Made because
|
||||
there are 42 documents and 13,000 lines, and no way to tell from the filenames which describe the
|
||||
system as it is and which are a record of an afternoon in July.
|
||||
|
||||
**How much I verified:** the classifications below are from filenames, status lines, and greps for
|
||||
things that changed on 2026-08-13. Where I actually read the document or checked the code, it says
|
||||
so. The rest is a starting point for a conversation, not a verdict.
|
||||
|
||||
---
|
||||
|
||||
## Living — these describe the system and must stay true
|
||||
|
||||
| doc | state |
|
||||
| --- | --- |
|
||||
| `working-on-officer.md` | **updated 2026-08-13.** Operational guide. |
|
||||
| `secret-store.md` | **updated 2026-08-13.** Built; rotation still open. |
|
||||
| `install-variants.md` | new. The branch tree, for discussion. |
|
||||
| `http-secure-context-audit.md` | new. What breaks over plain http. |
|
||||
| `install-container-testing.md` | new. First container pass and its findings. |
|
||||
| `per-user-linux-accounts.md` | partly updated. `OFFICER_OS_USERS` is gone; check the rest. |
|
||||
| `navigation-audit.md` | authoritative on routing. Unverified against tonight's route removals. |
|
||||
| `workspace-panels.md` + `workspace-panel-todo.md` | the panel framework. 1,300 lines combined — likely the biggest cleanup here. |
|
||||
| `agent-coordination.md` | the north star for panel work. |
|
||||
| `deprovision-os-account.md` | implemented; the `'disabled'` stage it may mention was deleted tonight. |
|
||||
|
||||
## Stale — describe things that changed on 2026-08-13
|
||||
|
||||
Each of these references something that no longer exists. **Not yet corrected.**
|
||||
|
||||
- `sidecar-topology.md` — "ecosystem.config.cjs is the source of truth". It is generated now, and
|
||||
holds six processes.
|
||||
- `sidecar-app-store.md` — derives the catalogue from `full − light`. Those files are gone, and
|
||||
`catalogue.test.ts` was rewritten.
|
||||
- `sidecar-bootstrapping.md` — "20 PM2 entries, 18 sidecar dirs". Six entries now.
|
||||
- `mobile-api-keys.md` — partly corrected; recheck the origin-checking claims.
|
||||
- `wallet-key-custody.md` — `VAULT_STORE_KEY` is now the per-purpose `wallet` key.
|
||||
- `push-notifications.md` — "agreed design, 2026-07-31". Notify is a plugin and unmounted.
|
||||
- `chat-session-lifetime.md`, `chat-ui-walkthrough.md` — reference `officer-agent`, renamed.
|
||||
|
||||
## Historical — a record of a moment, and should stay one
|
||||
|
||||
Do **not** rewrite these to match today's code. They document how a decision was reached, and
|
||||
editing them destroys the reasoning. If they mislead, add a dated header pointing forward.
|
||||
|
||||
- `sidecar-audit-2026-07.md` (1,377 lines)
|
||||
- `claude-sidecar-isolation.md` — records the `officer-claude` → `officer-agent` rename that
|
||||
preceded tonight's `officer-agent` → `officer-claude-code`
|
||||
- `open-threads-after-per-user-claude.md`
|
||||
- `two-agent-field-report-2026-08-12.md`
|
||||
- `api-method-changes-2026-08-06.md`
|
||||
|
||||
## The opencode cluster — nine documents for one migration
|
||||
|
||||
`opencode-fork-decision` · `-parity` · `-api-2-assessment` · `-phase0-review` · `-phase1-report` ·
|
||||
`-phase1-review` · `-serve-migration-plan` · `-serve-path` · `-testing-checklist`
|
||||
|
||||
**The migration landed** — `opencode serve` is in the sidecar, verified. So
|
||||
`opencode-serve-migration-plan.md` saying "Nothing here is implemented" is false.
|
||||
|
||||
This is the clearest consolidation candidate in the whole directory: one document recording what was
|
||||
decided and what shipped, replacing nine that describe stages of getting there. I did not do it
|
||||
because it needs reading all nine, and deleting documents unread is not a thing to do at 4am.
|
||||
|
||||
## The mobile-dav thread — three documents, one conversation
|
||||
|
||||
`mobile-dav-provisioning` · `-feedback` · `-reply`. A correspondence. Almost certainly one document.
|
||||
|
||||
## Unclassified — I have not looked
|
||||
|
||||
`design-language-interface` · `file-sync` · `jobs-unification` · `mobile-photo-sync-api` ·
|
||||
`nextcloud-replacement` · `agent-git-identity`
|
||||
|
||||
---
|
||||
|
||||
## The plugin split, which affects most of the above
|
||||
|
||||
A core install is six processes. **Everything else is a plugin**, switched off tonight but present on
|
||||
disk. Most documents here were written when the estate was twenty processes and every one of them was
|
||||
simply "there", so they describe availability that no longer holds.
|
||||
|
||||
The useful rewrite is usually one line, not a rewrite: say whether the thing described is **core** or
|
||||
**a plugin**, and if a plugin, that it is not mounted on a fresh install.
|
||||
+109
-109
@@ -6,7 +6,7 @@ a human authoring the workflow at the top. Written live during the conversation
|
||||
owner's own words; where a section records a decision, that decision is his, not a proposal.
|
||||
|
||||
**Read this before ranking, deferring or starting any workspace/panel item.** It is the thing every
|
||||
other workspace/panel document is ranked *against*:
|
||||
other workspace/panel document is ranked _against_:
|
||||
|
||||
- `docs/workspace-panels.md` — how the framework works today (descriptive, no opinions)
|
||||
- `docs/workspace-panel-todo.md` — the work queue, currently ordered by defect severity
|
||||
@@ -26,7 +26,7 @@ Everything that follows is about **`/chat`** and **`/dashboards`**. Verified aga
|
||||
`src/apps/officer-web/App.tsx`:
|
||||
|
||||
| route | element | line |
|
||||
|---|---|---|
|
||||
| ---------------------------------------------------------------------- | ---------------------------- | ----- |
|
||||
| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 42–46 |
|
||||
| `/dashboards` | `Dashboard.DashboardsScreen` | 82 |
|
||||
| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 |
|
||||
@@ -58,15 +58,15 @@ keeping it would distort the design, favour Claude and note the assumption here.
|
||||
|
||||
### 1.3 The dashboards scenario — the live example
|
||||
|
||||
The owner's chosen illustration is **what he is doing at this moment**: running *two Claude agents in
|
||||
parallel, in two different chat windows, both working on the platform.*
|
||||
The owner's chosen illustration is **what he is doing at this moment**: running _two Claude agents in
|
||||
parallel, in two different chat windows, both working on the platform._
|
||||
|
||||
**Stated as fact by the owner** (not inferred):
|
||||
|
||||
- Two agents, two chat windows, same platform, at the same time.
|
||||
- This is precisely why the standing "never restart the server yourself" rule exists: a
|
||||
`pm2 restart officer` is a **shared, destructive-ish event** across every agent working on the
|
||||
platform, so it must be *timed* by the owner rather than triggered by whichever agent happens to
|
||||
platform, so it must be _timed_ by the owner rather than triggered by whichever agent happens to
|
||||
finish first.
|
||||
|
||||
**Observed by me during this same session**, as corroborating detail — the frictions this arrangement
|
||||
@@ -74,11 +74,11 @@ actually produces:
|
||||
|
||||
1. **The owner is the scheduler.** Each agent independently reaches a point where it needs a restart and
|
||||
asks. Nothing in the system knows another agent exists, so the owner is the only thing that can
|
||||
serialise it. (He also had to tell me, separately, to stop *repeating* the request once made.)
|
||||
serialise it. (He also had to tell me, separately, to stop _repeating_ the request once made.)
|
||||
2. **The owner is the message bus.** Neither agent can see the other's work, so anything one needs to
|
||||
know about the other has to be relayed by hand.
|
||||
3. **Shared tree, shared `master`.** Two agents, one working copy. This produced the session's sharpest
|
||||
instruction — *"The problem is committing each other's work. Like, that can't happen, man."* — and
|
||||
instruction — _"The problem is committing each other's work. Like, that can't happen, man."_ — and
|
||||
the mitigation is purely behavioural: each agent must be told, separately, to stage explicit paths
|
||||
and never `git add -A`. Nothing enforces it.
|
||||
4. **Uncertain ownership of a failure.** I hit a real typecheck error (`CodeBlock.tsx:138`) and could not
|
||||
@@ -86,7 +86,7 @@ actually produces:
|
||||
|
||||
**Unconfirmed inference — to be confirmed or corrected by the owner before it is treated as the
|
||||
objective:** that the dashboards half of the holy grail is a surface where these parallel agent sessions
|
||||
are *visible together and manageable together* — one screen, multiple live agents as panels, with the
|
||||
are _visible together and manageable together_ — one screen, multiple live agents as panels, with the
|
||||
state they contend over (restarts, the git tree, who is touching what) legible — so the human stops
|
||||
being both the scheduler and the message bus between them.
|
||||
|
||||
@@ -96,7 +96,7 @@ being both the scheduler and the message bus between them.
|
||||
> path I want, to continue or start a new session from a specific path. Each chat panel gets attributed
|
||||
> some kind of persistent ID related to that dashboard.
|
||||
|
||||
And the behaviour that PoC is *for*:
|
||||
And the behaviour that PoC is _for_:
|
||||
|
||||
> I can let you both work, and at the end of your turn you ask the other agent "can I restart?", wait for
|
||||
> his output, restart yourself. And the same from the other side — the other agent, when he finishes his
|
||||
@@ -105,10 +105,10 @@ And the behaviour that PoC is *for*:
|
||||
|
||||
> If we get this to work, the sky is the limit.
|
||||
|
||||
Decomposed into the five capabilities it actually requires:
|
||||
Decomposed into the five permissions it actually requires:
|
||||
|
||||
| # | capability | exists today? |
|
||||
|---|---|---|
|
||||
| # | permission | exists today? |
|
||||
| --- | --------------------------------------------------------------------------------- | ------------- |
|
||||
| P1 | Two chat panels in one dashboard, each an **independent** session | **No** |
|
||||
| P2 | Each panel pointed at **its own path** (cwd) | **No** |
|
||||
| P3 | A **persistent id** per chat panel, scoped to the dashboard, that survives reload | Partly |
|
||||
@@ -121,7 +121,7 @@ Read from source on 2026-08-07, not assumed:
|
||||
|
||||
**P1 — the blocker.** `ChatPanelWrapper` (`apps/Chat/ChatPanelWrapper.tsx:45`) is declared
|
||||
`() => {…}` — **it takes no props at all, not even `panelId`.** Everything it uses comes from
|
||||
`useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are *per screen*. Two
|
||||
`useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are _per screen_. Two
|
||||
chat panels dropped into one dashboard today are therefore **byte-for-byte identical**: same cwd, same
|
||||
context, same session-resolution path. There is no per-panel anything. It also calls
|
||||
`useChat(undefined, undefined, …)`, so no session id is passed in — a panel cannot be told which session
|
||||
@@ -132,12 +132,12 @@ through `WorkspaceContext`. `scoped = cwd !== '~'`. Every panel on a screen nece
|
||||
|
||||
**P3 — the good news, with one sharp edge.** Panel ids (`layout-utils.ts:4`,
|
||||
`` uid = () => `p-${Date.now()}-${++counter}` ``) are generated once and **persisted inside the layout
|
||||
`jsonb`**, so a panel id *is* already stable across reloads. That makes panel id a viable durable key —
|
||||
`jsonb`**, so a panel id _is_ already stable across reloads. That makes panel id a viable durable key —
|
||||
which is the single most load-bearing fact for this PoC. The edge: `movePanel` mints a **new** id
|
||||
(`layout-utils.ts:192`, `:207`) rather than carrying the old one, so dragging a panel would silently
|
||||
sever its session binding. That is gap **G2** in the analysis, and it is now on the critical path.
|
||||
|
||||
Also already half-built, and worth knowing: for a *user* dashboard the wrapper already derives
|
||||
Also already half-built, and worth knowing: for a _user_ dashboard the wrapper already derives
|
||||
`{ context: 'dashboard', contextId: dashboardId }` (`ChatPanelWrapper.tsx:49-55`) — a notion of
|
||||
dashboard-scoped chat context exists. It is keyed to the **dashboard**, not the panel, which is exactly
|
||||
one level too coarse for this.
|
||||
@@ -160,7 +160,7 @@ PoC has the shape it has.**
|
||||
A lot of work landed today and over the last few days: **a session now survives a server restart with no
|
||||
refresh and no user action.** One case remains broken, and the owner has **decided not to solve it**:
|
||||
|
||||
> *unless the agent is currently outputting — the restart of the server interrupts that output.*
|
||||
> _unless the agent is currently outputting — the restart of the server interrupts that output._
|
||||
|
||||
This reframes the PoC entirely. **The by-turn handshake is not merely coordination; it is a deliberate
|
||||
route around the one failure mode that is not going to be fixed.** Restarts are made safe by
|
||||
@@ -190,7 +190,7 @@ signal the direction is right, since it falls out of the PoC at no extra cost.
|
||||
### 1.7 The actual objective — the software factory
|
||||
|
||||
**The restart problem is not the goal, and is barely even a problem.** It exists only because the owner
|
||||
is currently using the platform to fix the live platform, for velocity. It is a *dogfooding artifact*.
|
||||
is currently using the platform to fix the live platform, for velocity. It is a _dogfooding artifact_.
|
||||
It has been chosen as the proof of concept because it is small, real, and falsifiable — not because it
|
||||
is the target.
|
||||
|
||||
@@ -204,7 +204,7 @@ The target:
|
||||
So the north star is: **several specialised agents, working concurrently on one codebase, coordinating
|
||||
with each other rather than through the human, with quality gates between them and the mainline.**
|
||||
|
||||
The dashboards surface is how a human *watches and steers* that factory. The chat panels are the
|
||||
The dashboards surface is how a human _watches and steers_ that factory. The chat panels are the
|
||||
workers. The restart handshake is the first, smallest instance of the general primitive: agents
|
||||
negotiating a shared resource without a human in the middle.
|
||||
|
||||
@@ -226,7 +226,7 @@ plainly and early — the cost of a late correction here is much higher than the
|
||||
risk profile, and it is recorded here because it is the strongest single argument in the whole
|
||||
conversation.
|
||||
|
||||
**Constraint, binding:** *there will always be a human orchestrator* — the owner, or whoever later runs
|
||||
**Constraint, binding:** _there will always be a human orchestrator_ — the owner, or whoever later runs
|
||||
the platform. **The goal is explicitly not agents ping-ponging inputs and outputs with no structure.**
|
||||
Any design that removes the human from the top of the loop is wrong, not ambitious.
|
||||
|
||||
@@ -240,14 +240,14 @@ computers. The owner's worked example, verbatim in substance:
|
||||
well-documented, and the documentation keeps being updated with new learnings.
|
||||
2. The owner **shifts focus entirely** to other work — mobile monorepo, platform architecture — for one
|
||||
to two hours, without having to hold Soulseek in his head.
|
||||
3. The platform agent reports: *"Soulseek is up, give it a try, here is how to test it."*
|
||||
3. The platform agent reports: _"Soulseek is up, give it a try, here is how to test it."_
|
||||
4. The owner restarts, enters credentials, confirms it works, and the agent pushes.
|
||||
5. The owner pulls on the MacBook and tells the **mobile agent** — which already knows the mobile
|
||||
infrastructure — "create me a Soulseek app based on everything the platform has today." It works.
|
||||
|
||||
**So the pattern is proven by human execution.** What is being automated is not "can agents collaborate"
|
||||
— it is the *bridging role*, which the owner currently performs and describes as: *stressful, a lot to
|
||||
keep in my head*, though enjoyable and exciting.
|
||||
— it is the _bridging role_, which the owner currently performs and describes as: _stressful, a lot to
|
||||
keep in my head_, though enjoyable and exciting.
|
||||
|
||||
### 1.10 The midnight scenario — the shape of the target
|
||||
|
||||
@@ -257,8 +257,8 @@ keep in my head*, though enjoyable and exciting.
|
||||
> "this is not according to spec", to mobile "maybe change this" — and in the end be **responsible for
|
||||
> the joining of everything, which is currently the work that I'm doing.**
|
||||
|
||||
The owner's own framing: *a holy grail by its nature doesn't exist — but I really think we can get
|
||||
there.*
|
||||
The owner's own framing: _a holy grail by its nature doesn't exist — but I really think we can get
|
||||
there._
|
||||
|
||||
Structural requirements this adds, beyond the two-panel PoC:
|
||||
|
||||
@@ -267,36 +267,36 @@ Structural requirements this adds, beyond the two-panel PoC:
|
||||
- **Panels are aware of each other** — an agent must be able to enumerate its peers.
|
||||
- **Panels span repositories** — platform and `monorepo-mobile` are different repos with different
|
||||
remotes.
|
||||
- **The fourth role is different in kind from the first three.** Roles 1–3 are *do the work*, and are
|
||||
already proven by the manual flow. Role 4 is *hold the whole picture and judge* — the role the owner
|
||||
- **The fourth role is different in kind from the first three.** Roles 1–3 are _do the work_, and are
|
||||
already proven by the manual flow. Role 4 is _hold the whole picture and judge_ — the role the owner
|
||||
performs today with human judgement. See §5 for why this is flagged as the research risk rather than
|
||||
an engineering task.
|
||||
|
||||
### 1.11 Do not design for the examples — the owner's counterpoints
|
||||
|
||||
Recorded because every one of these is a correction of *my* over-constraining, and the same mistake will
|
||||
Recorded because every one of these is a correction of _my_ over-constraining, and the same mistake will
|
||||
be easy to repeat later.
|
||||
|
||||
- **The Soulseek flow is one example, not the specification.** Other workflows will exist; some need only
|
||||
two agents. *"This coordination is the point I want to ultimately reach."*
|
||||
two agents. _"This coordination is the point I want to ultimately reach."_
|
||||
- **Roles are malleable.** Not every run involves four agents, and not with those roles. Fixing
|
||||
"front end / backend / mobile / reviewer" into the design would be inventing a constraint the owner
|
||||
does not have.
|
||||
- **There is no paradigm.** *"It's whatever we want it to be."*
|
||||
- **There is no paradigm.** _"It's whatever we want it to be."_
|
||||
- **The owner's current needs are not the end state.** He has a day job unrelated to mobile that would
|
||||
benefit from the same coordination. Designing narrowly around platform+mobile development is a trap.
|
||||
- **Cross-machine is NOT the hard problem, and I was wrong to raise it as a fork.** The owner has already
|
||||
solved it at small scale: a second Claude on the MacBook with a 15-minute timer pulling the latest
|
||||
platform changes and replicating them for the mobile apps. Git hooks or cron do the same.
|
||||
*"That's the least painful point of all this."*
|
||||
_"That's the least painful point of all this."_
|
||||
|
||||
**The painful point, in the owner's words:** *panel communication inside a single web page, or a single
|
||||
workspace, on our platform Web UI.* That is the problem to solve. Everything else is downstream.
|
||||
**The painful point, in the owner's words:** _panel communication inside a single web page, or a single
|
||||
workspace, on our platform Web UI._ That is the problem to solve. Everything else is downstream.
|
||||
|
||||
This yields a natural two-tier split, which the design should respect rather than unify:
|
||||
|
||||
| tier | mechanism | status |
|
||||
|---|---|---|
|
||||
| --------------------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** |
|
||||
| Agents across **machines** | the git repo itself, polled on a timer / hook / cron | already solved, cheap, not our problem |
|
||||
|
||||
@@ -311,8 +311,8 @@ Compare it to the restart handshake:
|
||||
|
||||
> I finished my output, you can restart the server, and tell me when you're done so I can continue.
|
||||
|
||||
**These are the same protocol with a different payload.** Both are: *declare turn-end → hand off →
|
||||
await the peer's completion → resume.* The restart PoC is therefore not a toy standing in for the real
|
||||
**These are the same protocol with a different payload.** Both are: _declare turn-end → hand off →
|
||||
await the peer's completion → resume._ The restart PoC is therefore not a toy standing in for the real
|
||||
thing; it is the real protocol, exercised on the smallest possible payload.
|
||||
|
||||
The design consequence: **build the primitive general and keep the roles as configuration.** A named,
|
||||
@@ -348,30 +348,30 @@ What he expects instead:
|
||||
- The **workflow graph lives in the prompts**, authored by the human at dashboard setup.
|
||||
- The system's entire job is: give each agent a **stable, addressable identity**, and **deliver messages
|
||||
between them at turn boundaries**. That is it.
|
||||
- The failure mode this avoids is the one that kills most multi-agent systems: agents deciding *what* to
|
||||
do and *who* should do it. Here, the human decides both, up front, once.
|
||||
- The failure mode this avoids is the one that kills most multi-agent systems: agents deciding _what_ to
|
||||
do and _who_ should do it. Here, the human decides both, up front, once.
|
||||
|
||||
**One consequence worth stating** (observation, not a decision taken): if roles are prompts, then
|
||||
*addressing* must still resolve. "Pass that work to the front end developer" needs a destination. The
|
||||
_addressing_ must still resolve. "Pass that work to the front end developer" needs a destination. The
|
||||
consistent answer is that the **human names each panel at setup** and tells each agent the names of its
|
||||
peers — so addressing is a string the human chose, and the system merely routes it. A system-maintained
|
||||
roster of roles would re-import the paradigm through the back door.
|
||||
|
||||
**Also note:** *not long lived* lowers the persistence bar for a dashboard's workflow configuration —
|
||||
**Also note:** _not long lived_ lowers the persistence bar for a dashboard's workflow configuration —
|
||||
but **not** for panel identity, which must still survive a reload for the whole PoC to work (§1.5, P3).
|
||||
|
||||
### 1.14 The charter — and what is explicitly *not* mine
|
||||
### 1.14 The charter — and what is explicitly _not_ mine
|
||||
|
||||
**Owner's ruling on the shared-working-tree challenge (my push-back #2). Accepted, not to be
|
||||
re-litigated.**
|
||||
|
||||
- It has been working in practice: three agents at a time on the platform, and *the way the platform was
|
||||
modularised means they don't step on each other's toes ~90% of the time.* Nothing is or will be
|
||||
- It has been working in practice: three agents at a time on the platform, and _the way the platform was
|
||||
modularised means they don't step on each other's toes ~90% of the time._ Nothing is or will be
|
||||
perfect.
|
||||
- Worktrees, branches, everything-on-master: **not the focus.** The owner has ~20 years professional
|
||||
experience, has never used a git worktree, and is willing to adopt one when it becomes necessary.
|
||||
- Explicit division of labour, verbatim: *"that's my problem as a software engineer, as an architect, to
|
||||
solve."*
|
||||
- Explicit division of labour, verbatim: _"that's my problem as a software engineer, as an architect, to
|
||||
solve."_
|
||||
|
||||
So git isolation is **owner-owned, deliberately deferred, and not a work item here.** It is recorded so
|
||||
it is not lost, not so it gets picked up. Raising it once was welcomed; raising it again is noise.
|
||||
@@ -379,7 +379,7 @@ it is not lost, not so it gets picked up. Raising it once was welcomed; raising
|
||||
**Important clarification from the owner — this is not a narrowing of the push-back instruction (§1.8):**
|
||||
|
||||
> Don't take what I said as a restriction on you to push back on things that you think might come up that
|
||||
> are maybe not directly related with your particular mission. I just want you to understand that I *do*
|
||||
> are maybe not directly related with your particular mission. I just want you to understand that I _do_
|
||||
> know what I'm doing — I've been through all those things my whole career. Those stones in my shoe are
|
||||
> mine to bear, not yours.
|
||||
|
||||
@@ -395,12 +395,12 @@ settled by the ruling; the right to raise the next one is not affected.
|
||||
And the expectations around it:
|
||||
|
||||
- **Learn to walk first.** The owner does not expect that the night after this works he creates a
|
||||
dashboard with four windows and builds a project. The *practice* of using it will be perfected over
|
||||
dashboard with four windows and builds a project. The _practice_ of using it will be perfected over
|
||||
time, separately from the mechanism.
|
||||
- **This mission will take some time to reach an initial state.** It is not a quick change.
|
||||
|
||||
**The problems the owner explicitly wants thought about and documented** — these are the real design
|
||||
work, and they come *after* the basics are proven:
|
||||
work, and they come _after_ the basics are proven:
|
||||
|
||||
1. Does the system survive a **server restart**?
|
||||
2. Does it survive a **page refresh**?
|
||||
@@ -413,7 +413,7 @@ Survey of the chat/agent commits since 2026-08-01, read from diffs and source. *
|
||||
further along than assumed.** Load-bearing findings:
|
||||
|
||||
**The injection channel already exists.** `sidecar.spawnClaudeStreaming({sessionKey, prompt, …})` called
|
||||
on an *existing* `sessionKey` does **not** spawn anything — it pushes a user message onto the live input
|
||||
on an _existing_ `sessionKey` does **not** spawn anything — it pushes a user message onto the live input
|
||||
queue (`claude-manager.ts:380-391` → `pushTurn` → `input.push`). **No browser involved.** Existing
|
||||
callers: `websocket.ts:354`, `agent-runner.ts:187`, `pipeline-executor.ts:238`. This is how one agent
|
||||
delivers a message to another.
|
||||
@@ -421,7 +421,7 @@ delivers a message to another.
|
||||
**The turn-boundary signal already exists.** `result` is the explicit terminal event
|
||||
(`chat/types.ts:143-153`, emitted `stream-parser.ts:144`), and `onTurnComplete(hadToolCalls)` is already
|
||||
a public option on `useChat` (`useChat.ts:28`, fired at `:286-314`). Terminal set is
|
||||
**`result` | `error` | `stopped` | `cut-off`**. Note: the *session outlives the turn* — `task:started` /
|
||||
**`result` | `error` | `stopped` | `cut-off`**. Note: the _session outlives the turn_ — `task:started` /
|
||||
`task:notification` arrive **after** `result`, so "turn ended" ≠ "agent idle".
|
||||
|
||||
**Durable, cursor-addressed log:** `chat_session_events` — global monotonic `bigserial` cursor,
|
||||
@@ -429,20 +429,20 @@ per-session index, `prevSeq` continuity chain, at-least-once replay from a clien
|
||||
**Caveat: 7-day retention** (`api/chat/retention.ts`) — a replay buffer, not an archive.
|
||||
|
||||
**Peer-restart notification:** `onClaudeSidecarStarted` (`sidecar-registry.ts:69-91`) — keyed off the
|
||||
agent *registering*, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by
|
||||
agent _registering_, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by
|
||||
the process that owns the session, **failing toward alive**.
|
||||
|
||||
**Principles this codebase has already paid for — adopt, don't re-derive:**
|
||||
|
||||
1. *Never route the durability guarantee over the link expected to break.* The agent writes to
|
||||
1. _Never route the durability guarantee over the link expected to break._ The agent writes to
|
||||
`chat_session_events` itself, then notifies; officer relays. Write durable, then notify.
|
||||
2. *Infer liveness from the birth of the new process, not the death of the socket* — socket death fires
|
||||
2. _Infer liveness from the birth of the new process, not the death of the socket_ — socket death fires
|
||||
on the innocent case (`pm2 restart officer`).
|
||||
3. *An availability check must fail toward the less-alarming answer.*
|
||||
4. *Classify by recoverability, not severity* — `cut-off` (seam + Retry) is a different object from
|
||||
3. _An availability check must fail toward the less-alarming answer._
|
||||
4. _Classify by recoverability, not severity_ — `cut-off` (seam + Retry) is a different object from
|
||||
`error` (red bubble).
|
||||
5. *An id that never crosses the process boundary is not an address* (`47d03de`).
|
||||
6. *Disambiguate at the only site holding the extra bit*, and set the flag **before** the await that can
|
||||
5. _An id that never crosses the process boundary is not an address_ (`47d03de`).
|
||||
6. _Disambiguate at the only site holding the extra bit_, and set the flag **before** the await that can
|
||||
race it.
|
||||
|
||||
**⚠ Flagged for the chat owner — NOT mine to fix (§1.14 rule).** `sessionKey` (officer's uuid, the key
|
||||
@@ -454,7 +454,7 @@ path for agent-to-agent messaging.** To be written up in `COMMS/` and handed off
|
||||
|
||||
### 1.16 The real optimisation target: unattended continuity, not parallelism
|
||||
|
||||
**Correcting a wrong assumption of mine.** The owner does *not* want three or four agents running flat
|
||||
**Correcting a wrong assumption of mine.** The owner does _not_ want three or four agents running flat
|
||||
out at once:
|
||||
|
||||
> I don't expect to have three or four agents running at the same time like crazy. **What I want is to be
|
||||
@@ -470,12 +470,12 @@ are large:
|
||||
between then and morning.
|
||||
|
||||
**And the consequence that dominates the architecture — flagged for the owner to confirm (§5):** if the
|
||||
owner is *asleep*, **the dashboard page is closed.** A handoff must therefore work with **no browser
|
||||
owner is _asleep_, **the dashboard page is closed.** A handoff must therefore work with **no browser
|
||||
open**. That rules out every browser-resident mechanism — `usePanelChannel`, React state, anything in the
|
||||
document — not on elegance grounds but because the document will not exist when the message is sent.
|
||||
|
||||
This does not contradict the owner's framing of the problem as *"panel communication inside a single web
|
||||
page"*; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how
|
||||
This does not contradict the owner's framing of the problem as _"panel communication inside a single web
|
||||
page"_; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how
|
||||
a human watches and steers a conversation that continues without it — which is also precisely what
|
||||
§1.15 shows the chat system was rebuilt to support (durable event log, cursor replay, agent-as-writer,
|
||||
session outliving the socket).
|
||||
@@ -485,7 +485,7 @@ against the running system, deliberately looking for the break. Method and raw e
|
||||
`COMMS/handoff-durability-2026-08-07.md`.
|
||||
|
||||
| what was done to it mid-handoff | turn completed | narration durable |
|
||||
|---|---|---|
|
||||
| -------------------------------------- | -------------- | ------------------------------------------------- |
|
||||
| nothing (control) | ✅ | ✅ |
|
||||
| `pm2 restart officer` | ✅ | ✅ |
|
||||
| **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** |
|
||||
@@ -498,11 +498,11 @@ Three things follow, and they change how the outstanding work should be read:
|
||||
- **The "no browser open" requirement is satisfied, and so is the harder one.** Not one of these runs had
|
||||
a page open, and the middle row is the proof that officer is genuinely off the delivery path: the agent
|
||||
sidecar committed the model's own words to Postgres during a 25-second server outage.
|
||||
- **A restart costs a *turn*, not an *agent*.** After being killed mid-turn, the receiver resumed the
|
||||
- **A restart costs a _turn_, not an _agent_.** After being killed mid-turn, the receiver resumed the
|
||||
identical Claude session on the next handoff and volunteered which work had been lost. Continuity —
|
||||
`sessionKey` minted once, write-through map on disk — does the job it was built for. That makes the
|
||||
outstanding claude **stage 5** a smaller problem than its position on the list suggests.
|
||||
- **The remaining hole is on the *sending* side.** A handoff POSTed while officer is down is refused and
|
||||
- **The remaining hole is on the _sending_ side.** A handoff POSTed while officer is down is refused and
|
||||
dropped, and nothing in the introduction text tells the agent to retry — so the sender can believe it
|
||||
handed off when it did not. That, not the receiving side, is where store-and-forward would earn its
|
||||
keep.
|
||||
@@ -510,7 +510,7 @@ Three things follow, and they change how the outstanding work should be read:
|
||||
One topology fact found while setting this up, worth stating here because it is the practical limit on
|
||||
working unattended: **`officer-agent` is `sidecar/claude/user-instance.ts`, and every `claude` process on
|
||||
the machine is its direct child.** `pm2 restart officer-agent` therefore kills every agent on every
|
||||
dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting *officer* is safe — it is, and that is
|
||||
dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting _officer_ is safe — it is, and that is
|
||||
verified above — but is silent on this one.
|
||||
|
||||
### 1.17 The restart payload is temporary — the protocol is not
|
||||
@@ -518,17 +518,17 @@ verified above — but is silent on this one.
|
||||
> The restart thing is giving me pain right now. Pretty soon that won't be a problem, because I won't
|
||||
> have the necessity of editing the platform in real time from the platform as I'm doing today.
|
||||
|
||||
Further confirmation that the PoC is **scaffolding**: the *payload* is disposable, the *protocol* is the
|
||||
Further confirmation that the PoC is **scaffolding**: the _payload_ is disposable, the _protocol_ is the
|
||||
deliverable. Reinforces §1.12 — build message passing, not a restart-negotiation feature. If the restart
|
||||
case disappeared tomorrow, nothing built should need to be deleted.
|
||||
|
||||
### 1.18 Ruling on push-back #3 (agents reviewing agents)
|
||||
|
||||
Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — *that is
|
||||
assuming the owner is dumb, which is important sometimes, but not for this mission.*
|
||||
Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — _that is
|
||||
assuming the owner is dumb, which is important sometimes, but not for this mission._
|
||||
|
||||
Correct, and worth stating why so the boundary is understood rather than merely obeyed: **the quality of
|
||||
an agent's review is a *usage* concern, downstream of the mechanism.** Whether the reviewer is any good
|
||||
an agent's review is a _usage_ concern, downstream of the mechanism.** Whether the reviewer is any good
|
||||
is a property of the prompt the human wrote, not of the transport. The mechanism is the postal service;
|
||||
it is not accountable for what is in the envelopes.
|
||||
|
||||
@@ -542,15 +542,15 @@ Derived from §1. These are the constraints the design must satisfy.
|
||||
|
||||
1. **A stable, addressable identity per chat panel**, persisted, surviving reload. Panel id is the
|
||||
natural key (§1.5, P3) — subject to the `movePanel` hazard.
|
||||
2. **Message passing between named sessions at turn boundaries.** Messages carry *content* (a handoff of
|
||||
work), not just signals (§1.12, and the owner's escalation: work handoff is *the whole crux*).
|
||||
2. **Message passing between named sessions at turn boundaries.** Messages carry _content_ (a handoff of
|
||||
work), not just signals (§1.12, and the owner's escalation: work handoff is _the whole crux_).
|
||||
3. **Loud failure** when a message is dropped, a peer does not exist, or a handoff never lands (§1.8 as
|
||||
refined; already a value in this codebase — `9eb8fa1`).
|
||||
|
||||
**Do not build:**
|
||||
|
||||
- No role registry, orchestration engine, planner, or task allocator (§1.13).
|
||||
- No restart-negotiation feature — restart is a *payload* (§1.17).
|
||||
- No restart-negotiation feature — restart is a _payload_ (§1.17).
|
||||
- No git, repo, branch, build, or test awareness. **No knowledge of software at all** (§1.11 + the
|
||||
domain-agnosticism constraint): the mechanism must be as ignorant of the work as a postal service is
|
||||
of what is in the envelope.
|
||||
@@ -561,7 +561,7 @@ Derived from §1. These are the constraints the design must satisfy.
|
||||
|
||||
- **Turn-boundary only.** The safety property comes from negotiation, not robustness (§1.6).
|
||||
- **Must work with no browser open.** The owner's goal is to sleep; the page will be closed. The
|
||||
mechanism is server-side; panels are the view (§1.16). *Pending owner confirmation — see §5.*
|
||||
mechanism is server-side; panels are the view (§1.16). _Pending owner confirmation — see §5._
|
||||
- **N-way from day one.** Parallelism is not the current target but must not be foreclosed — no "the
|
||||
other agent" singular anywhere, no single-writer ordering assumptions (§1.16 correction).
|
||||
- **Durability over latency.** Seconds or minutes between handoffs is fine; a lost 03:00 handoff is not.
|
||||
@@ -578,35 +578,35 @@ primitive is the right one.
|
||||
- **Automatic handoff of cross-domain findings** — e.g. this document's own §1.15 chat defect, which the
|
||||
owner must currently carry by hand to the chat agent (§1.14). The rule and the mission are the same
|
||||
shape.
|
||||
- **The software factory** (§1.7) — several specialised agents with quality gates, as *usage* built on
|
||||
- **The software factory** (§1.7) — several specialised agents with quality gates, as _usage_ built on
|
||||
the primitive rather than as features of it.
|
||||
- **Non-software domains entirely**, and other users with unrelated goals.
|
||||
- **True parallelism**, later — *"the literal definition of heaven on earth."*
|
||||
- **True parallelism**, later — _"the literal definition of heaven on earth."_
|
||||
|
||||
## 4. Constraints and rules laid down
|
||||
|
||||
*(ground rules stated by the owner for this body of work, verbatim in substance)*
|
||||
_(ground rules stated by the owner for this body of work, verbatim in substance)_
|
||||
|
||||
- Nothing is started — including trivial fixes — until the picture is complete and played back to the
|
||||
owner, and the owner has confirmed it is correct.
|
||||
- This document is kept live *during* the conversation, not written up afterwards.
|
||||
- This document is kept live _during_ the conversation, not written up afterwards.
|
||||
- Re-ranking the existing todo waits until the conversation is finished, and is then reflected both here
|
||||
and in the documents that already exist.
|
||||
|
||||
### 4.1 Explicitly de-scoped — not wrong, just not now
|
||||
|
||||
Stated by the owner before the objective itself, and it is a *priority* judgement, not a correctness
|
||||
Stated by the owner before the objective itself, and it is a _priority_ judgement, not a correctness
|
||||
one. These are acknowledged as poor architecture and are nonetheless **not to be worked on**:
|
||||
|
||||
- **Everything downstream of the file browser at `/files`** — the ephemeral-panel machinery
|
||||
(`ephemeral` prop, `useFileViewerPanels`, the search-param-driven viewer/player/side-chat that opens
|
||||
beside the browser without entering your saved layout). The owner's words: *horrible architecture*,
|
||||
and *everything is working as much as I need it*.
|
||||
beside the browser without entering your saved layout). The owner's words: _horrible architecture_,
|
||||
and _everything is working as much as I need it_.
|
||||
- The query-string-driven sub-panel approach generally.
|
||||
|
||||
The rule that follows: **do not open these as work items, and do not let a fix wander into them.** If
|
||||
one of them is genuinely blocking the objective, that is a finding to raise with the owner — not a
|
||||
licence to start. Some of them will likely improve *inadvertently*, as a side effect of work done for
|
||||
licence to start. Some of them will likely improve _inadvertently_, as a side effect of work done for
|
||||
the objective, and that is the expected and acceptable way for them to get better.
|
||||
|
||||
This section is a live list. Anything else the owner de-scopes gets added here rather than being
|
||||
@@ -635,14 +635,14 @@ The chain, verified:
|
||||
calls `killClaudeSession` after 30 idle minutes, sparing only a session that is generating or has
|
||||
pending tasks.
|
||||
- **But `killClaudeSession` (`:411-427`) does not clear the resume pointer.** It aborts the query, closes
|
||||
the input queue and drops the in-memory entry — and deliberately does *not* call
|
||||
the input queue and drops the in-memory entry — and deliberately does _not_ call
|
||||
`clearClaudeSession`. That is a separate function (`clearSession`, `:430`) on the explicit-disconnect
|
||||
path.
|
||||
- The `sessionKey → claudeSessionId` map is **write-through to disk** (`state.ts:86-102`, at
|
||||
`DATA_PATH/<email>/sidecar/claude-state.json`), specifically so it survives a crash or SIGKILL —
|
||||
commit `f4be4fd`.
|
||||
- On a fresh spawn, `createSession` reads it back: `resumeId = getClaudeSession(sessionKey) ??
|
||||
params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`).
|
||||
params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`).
|
||||
|
||||
**Therefore `spawnClaudeStreaming` on a reaped `sessionKey` transparently re-creates the session with
|
||||
`resume: <claudeSessionId>`, context intact from the on-disk transcript.** This is the good answer, and
|
||||
@@ -658,17 +658,17 @@ it is better than a heartbeat on every axis:
|
||||
Design consequence: **a panel is a pointer to a transcript, not a held resource.** The smallest possible
|
||||
durable object. Delivery is "resume that transcript and push a turn."
|
||||
|
||||
⚠ **The one hazard to respect:** the explicit `disconnect` path *does* call `clearClaudeSession`, which
|
||||
⚠ **The one hazard to respect:** the explicit `disconnect` path _does_ call `clearClaudeSession`, which
|
||||
destroys the resume pointer and orphans the transcript. Coordination must never ride that path, and
|
||||
whatever closes a panel must not trigger it.
|
||||
|
||||
**Q3 — RESOLVED by the owner, 2026-08-07: *"Yes, we can do that. I name them all."*** The human assigns
|
||||
**Q3 — RESOLVED by the owner, 2026-08-07: _"Yes, we can do that. I name them all."_** The human assigns
|
||||
each panel a name at setup; the system routes a string the human chose and knows nothing about its
|
||||
meaning. **The name is the address; the panel id is merely where it currently lives** — which also
|
||||
disarms the `movePanel` hazard (§1.5, P3), since dragging changes position, not identity.
|
||||
|
||||
**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to *how a panel
|
||||
acquires its Claude session id*:
|
||||
**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to _how a panel
|
||||
acquires its Claude session id_:
|
||||
|
||||
> We can wait for the first conversation with a certain agent to start and get the first output, so we
|
||||
> get the session id from Claude and add it to our session key. Or basically we **fire up each session
|
||||
@@ -677,7 +677,7 @@ acquires its Claude session id*:
|
||||
> dashboard creation or session creation.
|
||||
|
||||
**Adopt the second.** It is strictly better, because it collapses two problems into one act: the role
|
||||
prompt the human must write anyway *is* the message that brings the session into existence. Consequences:
|
||||
prompt the human must write anyway _is_ the message that brings the session into existence. Consequences:
|
||||
|
||||
- The **address book is fully populated at dashboard-creation time** — no lazy state, no "panel exists
|
||||
but has no session yet" hole, no first-handoff race.
|
||||
@@ -698,7 +698,7 @@ the rule — but it **does not block this work.**
|
||||
> agent will be instructed to write at the end of its work, having in mind to whom that prompt is going
|
||||
> to be delivered. **For proof of concept it could just be a dot character.**
|
||||
|
||||
So: the sending agent *composes* the message; the system carries it and does not parse it. Same rule as
|
||||
So: the sending agent _composes_ the message; the system carries it and does not parse it. Same rule as
|
||||
roles — semantics in the prose, mechanism dumb. **PoC success criterion collapses to: did a turn land in
|
||||
the other panel.** A single `.` is a sufficient payload to prove the mechanism.
|
||||
|
||||
@@ -720,31 +720,31 @@ Owner, 2026-08-07:
|
||||
> sequence that worked from start to finish, what were the prompts passed from one to another.
|
||||
> **But this is something for version 2.**
|
||||
|
||||
Shape: one row per *dashboard run*, holding the roster of Claude session ids and an ordered list of
|
||||
Shape: one row per _dashboard run_, holding the roster of Claude session ids and an ordered list of
|
||||
handoffs (from, to, prompt, timestamp). Deliberately **not** an output log — Claude's own transcripts and
|
||||
`chat_session_events` already hold the content, and duplicating them is the mistake to avoid.
|
||||
|
||||
**Do not build this in v1.** But do not preclude it either: v1 must emit enough that the ledger is purely
|
||||
*additive* later.
|
||||
_additive_ later.
|
||||
|
||||
### 5.2 The distinction that keeps v1 small: address book vs ledger
|
||||
|
||||
These are two different things and conflating them would inflate v1 into v2:
|
||||
|
||||
| | what it is | when |
|
||||
|---|---|---|
|
||||
| **Address book** | the durable mapping *panel → session*, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. |
|
||||
| **Ledger** | the durable *history* of who handed what to whom | **v2 — deferred** (§5.1). |
|
||||
| ---------------- | --------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| **Address book** | the durable mapping _panel → session_, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. |
|
||||
| **Ledger** | the durable _history_ of who handed what to whom | **v2 — deferred** (§5.1). |
|
||||
|
||||
v1 needs the address book and nothing more. Provenance recorded in v1 should be the minimum that makes a
|
||||
handoff *visible and its failure loud* (§2), not a history feature.
|
||||
handoff _visible and its failure loud_ (§2), not a history feature.
|
||||
|
||||
## 6. How the found defects map onto the path
|
||||
|
||||
*(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against
|
||||
_(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against
|
||||
what the mechanism turned out to need, not against what it was predicted to need. `workspace-panel-todo.md`
|
||||
is ordered by defect severity; this section says which of those defects the **objective** actually cares
|
||||
about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)*
|
||||
about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)_
|
||||
|
||||
### 6.1 The headline: most of the panel defect list is not on this path
|
||||
|
||||
@@ -754,7 +754,7 @@ Postgres and on the sidecar's disk. So a panel can remount, re-render, lose its
|
||||
across the dashboard, or not be rendered at all — and the work continues. Whole sections of the todo that
|
||||
rank high on severity rank near-zero here.
|
||||
|
||||
The corollary, and it is the useful half: the defects that *do* matter are almost all the same defect
|
||||
The corollary, and it is the useful half: the defects that _do_ matter are almost all the same defect
|
||||
wearing four hats — **a write that silently does not persist.** Panel identity is the one piece of
|
||||
coordination state that lives in the layout jsonb rather than in a table of its own, so every silent
|
||||
persistence failure in this list is now a path by which a panel forgets which agent it is.
|
||||
@@ -762,15 +762,15 @@ persistence failure in this list is now a path by which a panel forgets which ag
|
||||
### 6.2 Tier A — on the critical path
|
||||
|
||||
**A1. Stop swallowing persist failures.** (§1, third item — `state/src/useDashboardState.ts:46`,
|
||||
`.catch(() => {})`.) *The single highest-value item in the whole list against this objective.* The
|
||||
`.catch(() => {})`.) _The single highest-value item in the whole list against this objective._ The
|
||||
panel's agent name is written through this path. A swallowed 500 leaves the optimistic cache correct, so
|
||||
the panel shows its name, answers to its name, and **forgets it on the next reload** — the failure is
|
||||
invisible for exactly as long as the human is not looking, which is the entire window this project
|
||||
exists to serve. §2 requires *loud failure*; this is the loudest silence in the codebase.
|
||||
exists to serve. §2 requires _loud failure_; this is the loudest silence in the codebase.
|
||||
|
||||
**A2. The PATCH dispatcher's missing `else`.** (§2, first item.) The server half of A1. `ws-layout-*` is
|
||||
matched today so panel `config` does persist — verified, the demo dashboard round-tripped with
|
||||
`config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the *next* key
|
||||
`config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the _next_ key
|
||||
family added for coordination is a silent no-op that returns 200. Add the 400.
|
||||
|
||||
**A3. Validate the layout on read, and fix the `'[]'` default.** (§4, items 2 and 3.) Panel `config` is
|
||||
@@ -799,7 +799,7 @@ functions over a serialisable tree; there is no excuse.
|
||||
|
||||
**A7. Two windows must not disagree about the roster.** (§5.5, "the cache is never invalidated" —
|
||||
`staleTime: Infinity`, no `invalidateQueries` anywhere, and every PATCH already returns a fresh state
|
||||
blob the client throws away.) Q1 makes the dashboard *a window onto server-side work*. Two windows onto
|
||||
blob the client throws away.) Q1 makes the dashboard _a window onto server-side work_. Two windows onto
|
||||
the same work that permanently diverge, and neither told, is a direct contradiction of that. Cheap:
|
||||
consume the response that is already being computed.
|
||||
|
||||
@@ -815,22 +815,22 @@ rather than tidy. Not Tier A only because it is stable today and the failure req
|
||||
the derivation.
|
||||
|
||||
**B2. Panel lifecycle — but the ranking inverts.** (§5.1.) Against the terminal-orphan objective this was
|
||||
"the highest-value change here." Against *this* objective the priority is the opposite one: **closing a
|
||||
"the highest-value change here." Against _this_ objective the priority is the opposite one: **closing a
|
||||
chat panel must never destroy the agent.** §5 Q2 records the hazard precisely — the explicit `disconnect`
|
||||
path calls `clearClaudeSession`, which destroys the `sessionKey → claudeSessionId` pointer and orphans the
|
||||
transcript, whereas idle reaping deliberately does not. So what is wanted from `onClose` here is a
|
||||
*guarantee that nothing rides that path*, not an eager cleanup hook. Build the hook for the terminal by
|
||||
_guarantee that nothing rides that path_, not an eager cleanup hook. Build the hook for the terminal by
|
||||
all means; do not let a chat panel be wired into it without deciding that question first. A panel is a
|
||||
pointer, and closing a window should not delete what it points at.
|
||||
|
||||
**B3. `normalizeLayout` as framework, not convention.** (§5.4.) Matters for one consequence: a panel
|
||||
whose appType is allow-listed but no longer in the registry renders, on a `locked` screen, as an
|
||||
unrecoverable empty box. A chat panel in that state is a *visible* agent the human cannot reach — though
|
||||
unrecoverable empty box. A chat panel in that state is a _visible_ agent the human cannot reach — though
|
||||
note its peers still can, because the mechanism does not go through the browser. Real, but a display
|
||||
failure over a live agent rather than a lost one.
|
||||
|
||||
**B4. The mobile collapse decision.** (§6 of the todo, first item.) Genuinely undecided against this
|
||||
objective, and worth putting to the owner rather than guessing: *"I want to be able to sleep at night"*
|
||||
objective, and worth putting to the owner rather than guessing: _"I want to be able to sleep at night"_
|
||||
raises the obvious question of whether the 03:00 check-in happens on a phone. If yes, a user-created
|
||||
dashboard rendering only its left column forever is a Tier A problem wearing a mobile hat. If the answer
|
||||
is "I check on the laptop, and mobile web is being retired for the native app" — which is what
|
||||
@@ -846,12 +846,12 @@ this project.
|
||||
problem. It is not this one.
|
||||
- **§3, multi-user correctness.** Ranks on its own timer (a second member creating a dashboard), which is
|
||||
unrelated to this path.
|
||||
- **§5.2, the remount table.** *The largest downgrade in this re-rank.* A remount used to threaten
|
||||
- **§5.2, the remount table.** _The largest downgrade in this re-rank._ A remount used to threaten
|
||||
whatever the panel was holding; a panel now holds nothing. A chat panel that remounts re-runs
|
||||
`resume-cursor` from its stored cursor and replays the durable log — it costs latency, and §2 declares
|
||||
latency free. Fix these for the interaction quality they are actually about; do not fix them for this.
|
||||
- **§5.3, drag-to-move.** *The second-largest downgrade, and it was on the critical path when the north
|
||||
star was written* (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things
|
||||
- **§5.3, drag-to-move.** _The second-largest downgrade, and it was on the critical path when the north
|
||||
star was written_ (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things
|
||||
disarmed it. Q3 made the **name** the address and the panel id merely where it currently lives; and
|
||||
`e588524` made `swapPanels`/`movePanel` carry `{appType, config}` as one unit, so the name travels with
|
||||
the panel. `useAgentPanel` resolves by name and re-anchors the row's `panelId` afterwards. The
|
||||
@@ -863,7 +863,7 @@ this project.
|
||||
writes rather than widen the diff. And §5.8 is not a prerequisite here; the mechanism never goes
|
||||
through a channel, because it never goes through the browser at all.
|
||||
|
||||
### 6.5 What the re-rank did *not* find, and that is the result
|
||||
### 6.5 What the re-rank did _not_ find, and that is the result
|
||||
|
||||
No defect in `workspace-panel-todo.md` blocked building the MVP. It was built, and it ran unattended, on
|
||||
the framework as it stands. The framework needed exactly one addition — per-panel config that survives a
|
||||
|
||||
@@ -9,7 +9,7 @@ A team of agents works on this project, sometimes several of them in the same re
|
||||
one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody
|
||||
having to remember to say so.
|
||||
|
||||
Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS
|
||||
Today it cannot. Every agent commits as the owner, because every agent _is_ the owner as far as the OS
|
||||
is concerned.
|
||||
|
||||
## How git identity can be overridden at all
|
||||
@@ -85,7 +85,7 @@ const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ..
|
||||
That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session
|
||||
guards, and nothing is added per turn.
|
||||
|
||||
**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there
|
||||
**This is the good news.** `env` is _already_ a per-`query()` option. It is built once today, but there
|
||||
is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point
|
||||
for everything below.
|
||||
|
||||
@@ -96,8 +96,8 @@ Almost none, and none of it at the OS level.
|
||||
- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport
|
||||
field on the pushed message.
|
||||
- **Agent name and persona are prompt-only.** `buildAgentPrompt`
|
||||
(`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user
|
||||
message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file.
|
||||
(`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the _first user
|
||||
message_. There is no `systemPrompt`, no `--agents`, no per-agent settings file.
|
||||
- The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)`
|
||||
(`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their
|
||||
transcripts.
|
||||
@@ -147,7 +147,7 @@ API field. Neither is a small change, and this document does not propose one.
|
||||
|
||||
There is **no filesystem isolation** between agents. They share one real `HOME`
|
||||
(`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this
|
||||
outright, and it is the stated reason `chat` is an `execution` capability that can never be granted.
|
||||
outright, and it is the stated reason `chat` is an `execution` permission that can never be granted.
|
||||
`grep -ril worktree src/` returns nothing — worktrees are used nowhere.
|
||||
|
||||
cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.
|
||||
|
||||
@@ -43,7 +43,7 @@ that is the sidecar running your agent. **It is not.**
|
||||
|
||||
```ts
|
||||
name: 'proxy',
|
||||
capabilities: ['proxy'],
|
||||
permissions: ['proxy'],
|
||||
```
|
||||
|
||||
and its entire job is four things (`index.ts:10-23`): take a PID lock, load state, ensure an Anthropic
|
||||
@@ -73,11 +73,11 @@ The credential path is in roughly the right place; the process topology is not.
|
||||
### Why the process dies — two independent mechanisms
|
||||
|
||||
1. **Process-tree kill.** PM2 signals the whole tree on restart, so the agent gets SIGINT even though
|
||||
nothing in Officer's code asks for it. *(Inferred from PM2's default `treekill: true`;
|
||||
nothing in Officer's code asks for it. _(Inferred from PM2's default `treekill: true`;
|
||||
`ecosystem.config.cjs` sets no `treekill` key, so the default applies. I did not test this in
|
||||
isolation.)*
|
||||
isolation.)_
|
||||
2. **Inherited stdio.** `sidecar-registry.ts:240-241` passes `stdout: 'inherit', stderr: 'inherit'`,
|
||||
so the agent writes into *officer's* PM2 log pipes. When officer restarts those pipes close, and
|
||||
so the agent writes into _officer's_ PM2 log pipes. When officer restarts those pipes close, and
|
||||
subsequent writes fail. Even if the signal were suppressed, the child's output path dies with the
|
||||
parent.
|
||||
|
||||
@@ -85,7 +85,7 @@ Both must be fixed. Fixing only the signal leaves a process writing to a closed
|
||||
|
||||
### Also relevant: the transport direction is inverted
|
||||
|
||||
`user-instance.ts:19` dials *out* to officer:
|
||||
`user-instance.ts:19` dials _out_ to officer:
|
||||
|
||||
```ts
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
@@ -95,8 +95,8 @@ The agent sidecar is a **client** of officer, registering over `/api/sidecar/reg
|
||||
listener, reports no port. That is the exact inverse of the compliant sidecars (slskd, music, vault),
|
||||
which listen on a loopback port, report it on connect, and let officer forward to them.
|
||||
|
||||
This matters for survivability, not just tidiness: when officer restarts, a sidecar that *listens*
|
||||
just sits there with its work intact and waits to be forwarded to again. A sidecar that *dials in* has
|
||||
This matters for survivability, not just tidiness: when officer restarts, a sidecar that _listens_
|
||||
just sits there with its work intact and waits to be forwarded to again. A sidecar that _dials in_ has
|
||||
to notice the drop, reconnect, and re-establish identity — and anything it wanted to emit in the
|
||||
meantime has nowhere to go.
|
||||
|
||||
@@ -108,7 +108,7 @@ process now survives. Does your session?
|
||||
Not yet. Five things have to hold, and only some are about process lifetime:
|
||||
|
||||
| # | Requirement | Status today |
|
||||
|---|---|---|
|
||||
| --- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| R1 | The agent process is outside officer's process tree | **broken** — child of officer |
|
||||
| R2 | The agent's stdio does not belong to officer | **broken** — `'inherit'` |
|
||||
| R3 | The sidecar survives its control socket dropping, and reconnects | **probably fine** — `connect.ts` has a reconnect backoff table; not tested across a real restart |
|
||||
@@ -130,7 +130,7 @@ The backend half exists too (`chat/websocket.ts:612-629`, `getChatEventsSince`).
|
||||
"Disconnected" indicator in the UI (`ChatHistory/ChatDetailPanel.tsx:38-52`).
|
||||
|
||||
So **the sequence-and-replay protocol I was about to propose building already exists end to end.** The
|
||||
only thing wrong with it is *who writes the events*. That collapses Stage 2 below from "design a
|
||||
only thing wrong with it is _who writes the events_. That collapses Stage 2 below from "design a
|
||||
durable outbox" to "move the writer" — the single biggest simplification in this plan.
|
||||
|
||||
One gap to close while moving it: nothing verifies sequence continuity. `resume-cursor` is only sent
|
||||
@@ -175,7 +175,7 @@ With the data flow inverted to match slskd:
|
||||
`server.tsx:164-228` (dev-server) and `server.tsx:323-326` → `api/vault/websocket.ts` (vault).
|
||||
- The sidecar **writes its own events to Postgres** with a monotonic per-session sequence number. It
|
||||
already imports `officerdb` (`user-instance.ts:10`), so this is established precedent, not a new
|
||||
capability. Officer stops touching `chat_session_events` entirely.
|
||||
permission. Officer stops touching `chat_session_events` entirely.
|
||||
- On reconnect the browser sends `since=<seq>` and the **sidecar** answers the replay. Officer relays
|
||||
the question and the answer, and interprets neither.
|
||||
|
||||
@@ -208,14 +208,14 @@ The minimum fix for R1 + R2. Two routes, and I'd want your view on which:
|
||||
`ensureClaudeSidecar` / `spawnAndWaitForRegistration` (`sidecar-registry.ts:198-274`, ~77 lines
|
||||
including the 50ms registration poll). Officer no longer spawns anything.
|
||||
|
||||
- *Pro:* correct, matches every other sidecar, PM2 restarts and logs it properly.
|
||||
- *Con:* the per-email spawn model has to go or change — see the open question below.
|
||||
- _Pro:_ correct, matches every other sidecar, PM2 restarts and logs it properly.
|
||||
- _Con:_ the per-email spawn model has to go or change — see the open question below.
|
||||
|
||||
**1b. Detach the spawn.** Keep on-demand spawning but `detached: true`, own stdio to its own log file,
|
||||
own process group.
|
||||
|
||||
- *Pro:* smallest diff, keeps lazy startup.
|
||||
- *Con:* leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state,
|
||||
- _Pro:_ smallest diff, keeps lazy startup.
|
||||
- _Con:_ leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state,
|
||||
but it might be a legitimate first step if you want the survivability today.
|
||||
|
||||
After this stage: the process survives, the socket reconnects, **but output produced during the
|
||||
@@ -265,7 +265,7 @@ reads its settings from. The whole chain — unauthenticated endpoint, sidecar `
|
||||
`panel-refresh` frame, `onPanelRefresh` prop — was deleted on 2026-08-04. The Chat panel already does the
|
||||
same job from `onTurnComplete`, in-process, with no hook and no HTTP round trip.
|
||||
|
||||
### Stage 5 — the harder question: surviving a *sidecar* restart
|
||||
### Stage 5 — the harder question: surviving a _sidecar_ restart
|
||||
|
||||
Stages 1-4 make the agent survive an **officer** restart. They do not make it survive a restart of the
|
||||
agent sidecar itself — the agent process is that sidecar's child by design.
|
||||
@@ -286,9 +286,9 @@ is a real design decision and I don't have a confident recommendation.
|
||||
|
||||
1. ~~**Is the per-email spawn model dead weight?**~~ — **answered 2026-08-07: yes, it is.** The
|
||||
question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
|
||||
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled
|
||||
it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is
|
||||
`kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**,
|
||||
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The permission model settled
|
||||
it in the _other_ direction from what "the platform is going multi-user" would suggest: `chat` is
|
||||
`kind: 'execution'` in `permissions/registry.ts`, which is **never grantable at any level**,
|
||||
because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional
|
||||
accounts exist now, and not one of them can ever open a chat.
|
||||
|
||||
@@ -299,7 +299,7 @@ is a real design decision and I don't have a confident recommendation.
|
||||
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
|
||||
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
|
||||
the browser a short-lived token and the browser connects to the sidecar directly (survives an
|
||||
officer restart *without even a reconnect*, but needs its own TLS/origin story and a second
|
||||
officer restart _without even a reconnect_, but needs its own TLS/origin story and a second
|
||||
exposed port). I lean relay — the reconnect is cheap once Stage 2 makes it lossless — but the
|
||||
direct path is the only one where you genuinely never notice.
|
||||
|
||||
@@ -329,7 +329,7 @@ is a real design decision and I don't have a confident recommendation.
|
||||
## Verified vs not
|
||||
|
||||
**Verified by reading the code or inspecting the running system:** my process ancestry; that
|
||||
`officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn capability;
|
||||
`officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn permission;
|
||||
that `user-instance.ts` has no PM2 entry and is spawned only at `sidecar-registry.ts:238` with
|
||||
inherited stdio; that it dials out rather than listening; that events leave via `connection.send`;
|
||||
that officer persists and replays them; that `--resume` is in my own argv; the two port defaults; the
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ worth serving both from one place.
|
||||
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
|
||||
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
|
||||
strategies) should be enabled and surfaced in the UI precisely so this is not confused.
|
||||
- **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so
|
||||
- **It is not sharing.** Files is an `execution` permission — the owner's disk, never grantable — so
|
||||
there is still nobody to share with, whatever the account list says since 2026-08-07.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# What breaks over plain http
|
||||
|
||||
**Audited 2026-08-13**, after `crypto.randomUUID` took the chat page down at the end of every turn.
|
||||
|
||||
Officer is reached at `http://officer-dev:9000` — a tailnet address, so **neither https nor
|
||||
localhost**, and therefore not a [secure context]. A set of browser APIs are unavailable there by
|
||||
specification, not by policy, and there is no flag that changes it.
|
||||
|
||||
The failure mode is what makes this worth a document. Two of the three shapes below are silent:
|
||||
|
||||
| shape | what a user sees |
|
||||
| --- | --- |
|
||||
| `crypto.randomUUID()` | `TypeError` — and if it is inside a `useState` initialiser, the whole tree unmounts |
|
||||
| `navigator.clipboard.writeText()` | `TypeError`, killing the click handler |
|
||||
| `navigator.clipboard?.writeText()` | **nothing at all** — the button reports success and copies nothing |
|
||||
|
||||
The optional-chained one is the worst: indistinguishable from working until somebody pastes.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### `crypto.randomUUID` — 18 call sites
|
||||
Secure-context only. `crypto.getRandomValues` is **not** — it lives on `Crypto` rather than
|
||||
`SubtleCrypto` — so `helpers/random-id.ts` builds the same v4 UUID from the same CSPRNG when
|
||||
`randomUUID` is absent. Same entropy, same version and variant bits.
|
||||
|
||||
### `navigator.clipboard.writeText` — 20 call sites across 18 files
|
||||
Secure-context only. `helpers/clipboard.ts` falls back to `document.execCommand('copy')` over an
|
||||
off-screen textarea, which predates the secure-context rule and works on any origin. Deprecated and
|
||||
working beats modern and absent.
|
||||
|
||||
One call site carried the comment *"Officer is always behind HTTPS"*. It was not.
|
||||
|
||||
---
|
||||
|
||||
## Cannot be fixed this way
|
||||
|
||||
### `navigator.clipboard.read()` — pasting a file in the file browser
|
||||
No fallback exists. `document.execCommand('paste')` was never permitted from script, so on an
|
||||
insecure origin there is no way to pull clipboard contents on demand — only a real paste event the
|
||||
user initiates, which is a different interaction. Now guarded by `canReadClipboard()` and refuses
|
||||
with an explanation instead of throwing.
|
||||
|
||||
### `getUserMedia` — audio recording, 4 files
|
||||
`apps/Chat/useAudioRecording.ts`, `apps/FileBrowser/.../DictateDialog.tsx`,
|
||||
`apps/QrTransfer/Receiver.tsx`, and a test. Requires a secure context and cannot be polyfilled — the
|
||||
browser will not hand out a microphone or camera over http.
|
||||
|
||||
**Being removed** rather than guarded: the owner uses an external dictation app. Note `QrTransfer`
|
||||
uses it for the CAMERA rather than a microphone, so removing "audio" does not cover it — that one
|
||||
needs its own decision.
|
||||
|
||||
### `navigator.credentials` — passkeys
|
||||
WebAuthn is secure-context only. `helpers/passkeys.ts` exists and cannot work over http, whatever is
|
||||
done to it. Not currently reachable, so nothing is broken today.
|
||||
|
||||
---
|
||||
|
||||
## Checked and clear
|
||||
|
||||
- **`crypto.subtle`** — not used anywhere in the frontend. This was the one worth confirming, since
|
||||
it would have had no cheap fallback.
|
||||
- **`Notification`** — the six matches are type names, not the browser API. Nothing calls
|
||||
`new Notification` or `requestPermission`.
|
||||
- **Service workers, WebUSB, WebSerial, WebBluetooth, Payment Request, Wake Lock, Storage Manager,
|
||||
`SharedArrayBuffer`** — not used.
|
||||
- **`navigator.geolocation`** (`widgets/Weather`) — secure-context only, but already guarded with
|
||||
`if (!navigator.geolocation) return;`, so it degrades rather than throws. The widget simply cannot
|
||||
locate you over http.
|
||||
- **`navigator.share`** (`Headscale/InvitesView`) — already guarded with a `typeof` check, and its
|
||||
comment notes it is absent on desktop browsers anyway.
|
||||
- **WebSockets, IndexedDB, localStorage, EventSource** — no secure-context restriction. Chat,
|
||||
terminal and the sidecar transports are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## The alternative
|
||||
|
||||
All of this disappears with a certificate, and `tailscale cert` issues a real one for the MagicDNS
|
||||
name in about one command — no public DNS, no port 80 challenge, no renewal to remember. Worth
|
||||
knowing that the choice here was "make it work over http", not "http is the only option".
|
||||
|
||||
[secure context]: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
|
||||
@@ -0,0 +1,74 @@
|
||||
# Testing the installer in containers
|
||||
|
||||
**2026-08-13.** First pass. Ubuntu 24.04, Debian 12, Arch, Fedora 41.
|
||||
|
||||
## What passed
|
||||
|
||||
**OS and package-manager detection is correct on all four.**
|
||||
|
||||
| image | `OS` | `PM` |
|
||||
| --- | --- | --- |
|
||||
| ubuntu:24.04 | `ubuntu` | `apt` |
|
||||
| debian:12 | `debian` | `apt` |
|
||||
| archlinux | `arch` | `pacman` |
|
||||
| fedora:41 | `fedora` | `dnf` |
|
||||
|
||||
**`--help` and argument handling work unprivileged** in a clean container, before any escalation.
|
||||
|
||||
**The install report is written**, end to end, in a container that had never seen this code. That is
|
||||
task 1's mechanism confirmed outside the machine it was written on.
|
||||
|
||||
**Refusing beats hanging.** With no answer available the run stopped with
|
||||
`FAIL: No answer. Set ASSUME_YES=1 to run without prompts.` rather than blocking forever on a prompt
|
||||
nobody could see. That is the behaviour an unattended run needs, and it already exists.
|
||||
|
||||
---
|
||||
|
||||
## What it found
|
||||
|
||||
### 1. `--only` does not isolate a step
|
||||
|
||||
Running `--only "Core utils"` still **created a user account**, because `ask_username` and the
|
||||
account creation happen in the preamble, above the step framework. Everything before the first
|
||||
`step` call runs on every invocation.
|
||||
|
||||
Defensible — every step needs to know who it is installing for — but it means `--only` is not the
|
||||
surgical tool it appears to be, and a first-time reader will assume it is. Either the preamble
|
||||
becomes lazy, or `--only` says plainly what it will still do.
|
||||
|
||||
### 2. `.setup-answers` travels with a copy of the tree
|
||||
|
||||
It lives at `scripts/setup/machine-setup/.setup-answers`, is correctly gitignored, and is `0600`
|
||||
root-owned. But it is **inside the repository directory**, so `cp -r` or a tarball of the tree
|
||||
carries it — which is exactly what happened here: a container that had never run setup came up
|
||||
already knowing the username `pastilhas` and created that account.
|
||||
|
||||
Not a leak (username and install path, nothing secret). It is a surprise, and surprises in an
|
||||
installer are the expensive kind. Worth moving outside the repo, next to the progress file.
|
||||
|
||||
### 3. `adduser` leaks its own prompts
|
||||
|
||||
```
|
||||
Use of uninitialized value $answer in pattern match (m//) at /usr/sbin/adduser line 848.
|
||||
Try again? [y/N]
|
||||
```
|
||||
|
||||
The account-creation path reaches an interactive `adduser` question the script does not answer.
|
||||
Harmless here because the run stopped anyway, but on a real unattended install this is a hang.
|
||||
|
||||
---
|
||||
|
||||
## Coverage this cannot reach
|
||||
|
||||
Containers have no init by default, so **`systemctl`, netplan, ufw and the sshd drop-ins were not
|
||||
exercised**. Those sections can only be verified as "wrote the right file", not "the service came
|
||||
up". Running privileged containers with systemd would close most of that gap and is the obvious next
|
||||
step.
|
||||
|
||||
**Docker-in-Docker** was not attempted, so the Docker section and Postgres provisioning are
|
||||
untested. Mounting the host socket would test the section's logic while telling us nothing about the
|
||||
install path.
|
||||
|
||||
**macOS is untestable here entirely.** The 17 skipped sections, the Homebrew paths, the Xcode
|
||||
command line tools step and the refusal-to-run-as-root are all reasoned from documentation and
|
||||
unverified by execution.
|
||||
@@ -0,0 +1,99 @@
|
||||
# The install page, and the scripts behind it
|
||||
|
||||
**Status: for discussion, 2026-08-13.** Nothing here is built. It exists so tomorrow's conversation
|
||||
is about real branches rather than sketched ones — every question below is one the scripts already
|
||||
ask today.
|
||||
|
||||
## The shape agreed
|
||||
|
||||
- One **source** — the interactive scripts as they are.
|
||||
- A **build script** that compiles them into single files, because `curl | bash` cannot fetch libs.
|
||||
- The build emits **one script per leaf** of the question tree, not one script with pre-seeded
|
||||
answers. A person auditing before running reads only their own path.
|
||||
- Verification is of the **generator**, once: anyone regenerates the leaves from source and diffs
|
||||
them against what is published. One thing to trust rather than N.
|
||||
|
||||
---
|
||||
|
||||
## The questions that actually exist
|
||||
|
||||
Forty-seven prompts across the two scripts. Almost none of them should become a branch — the
|
||||
distinction that matters is:
|
||||
|
||||
**A branch** changes which *code* runs. Removing it makes a script genuinely shorter.
|
||||
|
||||
**A value** changes a *string*. Removing it makes a script no shorter — it just moves the answer
|
||||
from a prompt to a constant.
|
||||
|
||||
**A consent** is a yes/no about doing a step at all. These are the interesting middle: pre-answering
|
||||
one lets the build delete the section entirely.
|
||||
|
||||
### Branches — these change what code exists
|
||||
|
||||
| question | answers | what it eliminates |
|
||||
| --- | --- | --- |
|
||||
| operating system | macOS · Debian/Ubuntu · Arch · Fedora | 17 of 26 machine-setup sections on macOS; the whole `case $PM` ladder collapses to one arm |
|
||||
| machine role | homelab · vps · dev | swap, ballast, earlyoom, sleep/suspend, boot-hang, static addressing — each is role-gated today |
|
||||
| tailnet | already connected · set one up · none | the entire Tailscale section, its four sub-options and the offscale explanation |
|
||||
| which half | machine + officer · officer only · machine only | one of the two scripts disappears |
|
||||
|
||||
### Consents — pre-answering deletes a section
|
||||
|
||||
Docker · fail2ban · unattended-upgrades · Neovim · agent CLIs · shell config · firewall · SSH
|
||||
hardening · DNS · swap · ballast · earlyoom · inotify · boot-on-start.
|
||||
|
||||
Fourteen sections that a leaf script can simply not contain.
|
||||
|
||||
### Values — never a branch
|
||||
|
||||
Username · install path · git name and email · port · public URL · Postgres connection · timezone ·
|
||||
locale · LAN CIDR · swap size · swappiness.
|
||||
|
||||
These stay as prompts even in a generated script, or arrive as environment variables. Baking them
|
||||
into a published file would mean publishing somebody's hostname.
|
||||
|
||||
---
|
||||
|
||||
## Where this collides with `--unattended`
|
||||
|
||||
`--unattended` and a generated leaf are the *same mechanism seen twice*: both are "answer these in
|
||||
advance". The difference is only whether the answer is baked in at build time or supplied at run
|
||||
time.
|
||||
|
||||
Worth deciding tomorrow whether a leaf script is literally `base.sh --unattended` with a header of
|
||||
constants, or whether the build truly strips the dead branches. The second is what makes it
|
||||
auditable-by-being-short; the first is what makes it maintainable. **They are not the same artifact,
|
||||
and the whole plan rests on which one we mean.**
|
||||
|
||||
One thing that already exists and should be preserved either way: with no tty, `install_config`
|
||||
keeps the user's file rather than replacing it. Every unattended answer needs to be conservative in
|
||||
that same way, and that is a property of each prompt, not of the flag.
|
||||
|
||||
---
|
||||
|
||||
## The combinatorics
|
||||
|
||||
4 OS × 3 roles × 3 tailnet states = **36 leaves** before any consent is considered, and consents
|
||||
multiply it past anything anyone would publish.
|
||||
|
||||
So the tree the install page walks cannot be the full product. Two ways out, to choose between:
|
||||
|
||||
1. **Publish a few opinionated leaves** — "Ubuntu VPS, new tailnet", "macOS dev machine", "Ubuntu
|
||||
homelab, existing tailnet" — and send everything else to the full interactive script.
|
||||
2. **Generate on demand** — the page composes the leaf when the questions are answered. Stronger, but
|
||||
the artifact is no longer a static file anyone can diff against the repo, which costs the
|
||||
verification property the whole design was for.
|
||||
|
||||
My inclination is (1), because (2) quietly trades away the thing that made per-leaf scripts worth
|
||||
building. But it is a real trade and it is yours.
|
||||
|
||||
---
|
||||
|
||||
## Open, for tomorrow
|
||||
|
||||
- Does a leaf strip dead code, or set constants and call the base?
|
||||
- How many leaves get published, and what happens to the rest?
|
||||
- Does the install page show the script before running it? It should — that is the moment auditing
|
||||
is cheap and nobody will do it afterwards.
|
||||
- The report from `install-report.md` names a script commit. A generated leaf needs to name the
|
||||
source commit it was generated from, or the report cannot be checked against anything.
|
||||
@@ -6,11 +6,11 @@ this file still exists. Email sync is deliberately NOT part of this any more —
|
||||
sidecar with its own scheduling, so it does not appear in the Jobs list.
|
||||
|
||||
**Goal:** every task run (script, pipeline, later agentic) becomes a persisted, background **job** —
|
||||
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone,
|
||||
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop _and_ phone,
|
||||
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
|
||||
|
||||
**Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
|
||||
on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script
|
||||
on 2026-08-07 — but because `tasks` is an `execution` permission: running a job means running a script
|
||||
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
|
||||
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
|
||||
Favor power-user affordances over guardrails.
|
||||
@@ -45,9 +45,10 @@ Favor power-user affordances over guardrails.
|
||||
## Plan
|
||||
|
||||
### Phase 1 — Unified jobs backend
|
||||
|
||||
- **1a. Data model.** Add `mode` (`pipeline|script|agentic`, default `pipeline`) + `exit_code` (int)
|
||||
to the jobs table. Log at `DATA_PATH/jobs/<id>.log` (derived from id). *Table/symbol rename
|
||||
`pipeline_jobs`→`jobs` is deferred as a cosmetic cleanup — add columns first, keep it working.*
|
||||
to the jobs table. Log at `DATA_PATH/jobs/<id>.log` (derived from id). _Table/symbol rename
|
||||
`pipeline_jobs`→`jobs` is deferred as a cosmetic cleanup — add columns first, keep it working._
|
||||
- **1b. Execution.** Generalize the job manager: `startJob` takes `mode` and dispatches — `pipeline`
|
||||
→ existing `executePipeline`; `script` → new `executeScript` (ports task-executor's
|
||||
`materializeScript`/`buildInputEnv`/bwrap sandbox/`killTree`/keepalive, but emits job events +
|
||||
@@ -57,31 +58,35 @@ Favor power-user affordances over guardrails.
|
||||
startup so a queued backlog resumes.
|
||||
|
||||
### Phase 2 — REST job API (decouples creation from the socket; enables the phone)
|
||||
|
||||
- `POST /jobs {taskDirName, inputs, cwd, action}` → `{jobId}` (create + start/queue, background).
|
||||
- `GET /jobs` (+`?live=1`), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`.
|
||||
- Consolidate the two WebSockets into one `/api/tasks/jobs/ws` doing only attach/stop/list.
|
||||
|
||||
### Phase 3 — Frontend
|
||||
|
||||
- `/jobs/new` → `NewJobScreen`: reads query params, renders the input UI lifted from
|
||||
`TaskRunnerModal` (`TaskInputForm` + per-group config + folder probing). Run/Queue per the
|
||||
concurrency UX. `JobDetail` gains a script branch (terminal output: live attach, or from log when
|
||||
idle). Retire `TaskRunnerModal`/`TaskRunnerDialog`/`useTaskRunner`. Header running-jobs indicator.
|
||||
|
||||
### Phase 4 — Notifications (later)
|
||||
|
||||
- One `notifyJobDone(job)` hook at finalize → push to the phone app.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] 1a data model — `mode` + `exit_code` columns (schema + applied to DB)
|
||||
- [x] 1b executeScript + manager dispatch — `execute-script.ts` (spawn/sandbox/killTree port, log file,
|
||||
abort poll, returns exitCode), `process-tree.ts` (shared killTree), `pipeline-job-manager` now
|
||||
dispatches by `mode` and finalizes script jobs by exit code. *Compiles; runtime-untested until
|
||||
a REST caller + restart exist.*
|
||||
dispatches by `mode` and finalizes script jobs by exit code. _Compiles; runtime-untested until
|
||||
a REST caller + restart exist._
|
||||
- [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()`
|
||||
on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending
|
||||
queue survives restart). `startJob` kept as a `enqueueJob(...,'start')` wrapper.
|
||||
- [x] 2 REST job API — `POST /jobs` (create script|pipeline, action start/queue), `GET /jobs` (+`?live=1`,
|
||||
now returns mode/exitCode/isLive), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`.
|
||||
Router mounted at `/jobs` and `/pipeline-jobs`. *Needs a restart to deploy; then curl/phone-testable.*
|
||||
Router mounted at `/jobs` and `/pipeline-jobs`. _Needs a restart to deploy; then curl/phone-testable._
|
||||
WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live).
|
||||
- [x] 3 frontend — master-detail `/jobs`, modal-as-creator, split list, header badges. Done.
|
||||
- [x] 3a jobs UI — **master-detail** `JobsPage` (like `/chat`): `WorkspaceLayout` with a list panel
|
||||
@@ -93,8 +98,8 @@ Favor power-user affordances over guardrails.
|
||||
an **inline** task runs ephemerally in-modal; a **non-inline** task `POST /jobs` (start) →
|
||||
navigates to `/jobs/:id`. When a job is already running, a red "Run now" + a "Queue" button
|
||||
(queue → `/jobs`). Reuses the modal's per-group input UI in place — no separate `/jobs/new`
|
||||
page or FileBrowser change needed. *(A standalone deep-linkable `/jobs/new` is deferred; the
|
||||
phone creates jobs directly via `POST /jobs`.)*
|
||||
page or FileBrowser change needed. _(A standalone deep-linkable `/jobs/new` is deferred; the
|
||||
phone creates jobs directly via `POST /jobs`.)_
|
||||
- [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton +
|
||||
UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling
|
||||
`GET /jobs/counts` → `{ running, runningJobId, queued }` every 3s; dim at 0.
|
||||
|
||||
@@ -139,7 +139,7 @@ will not meet it.
|
||||
| **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. |
|
||||
| **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. |
|
||||
|
||||
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a
|
||||
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing permission into a
|
||||
logout loop they cannot escape, because signing in again produces a credential with the same 403.
|
||||
|
||||
A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached.
|
||||
@@ -153,7 +153,7 @@ decides everything after.
|
||||
|
||||
- **The owner** (user 1) reaches everything.
|
||||
- **Any other account** reaches only what its role has been granted, and **can never** reach the
|
||||
`execution` capabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
|
||||
`execution` permissions — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
|
||||
user in the owner's home; they are refused structurally, not by policy.
|
||||
|
||||
Verified: a member's key returns the same status as that member's JWT on every route tried, 403s
|
||||
@@ -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.
|
||||
@@ -232,7 +233,7 @@ both, so the endpoint cannot be used to discover whether an id exists.
|
||||
|
||||
## Not built
|
||||
|
||||
- **Scopes.** A key cannot be narrowed to a subset of its holder's capabilities. The column and the check
|
||||
- **Scopes.** A key cannot be narrowed to a subset of its holder's permissions. The column and the check
|
||||
are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is
|
||||
there today. Design as if every key is full-authority, because it is.
|
||||
- **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave —
|
||||
@@ -326,4 +327,4 @@ service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
|
||||
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
|
||||
WebSocket upgrade in `server.tsx`, and the vault socket.
|
||||
- `src/servers/api/api-keys/router.ts` — the three endpoints.
|
||||
- `src/databases/officer_db/src/schema/api-keys.ts` — the table, and why it stores what it stores.
|
||||
- `src/databases/officer_db/src/api-keys/schema.ts` — the table, and why it stores what it stores.
|
||||
|
||||
@@ -60,7 +60,7 @@ speaks DAV.
|
||||
|
||||
- **Username** = the account's email address — the signed-in account's own, not a constant. (This said
|
||||
"Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable
|
||||
capability, so a member can hold their own app passwords and their own collections.)
|
||||
permission, so a member can hold their own app passwords and their own collections.)
|
||||
- **Password** = a **DAV app password**, not the login password.
|
||||
|
||||
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
|
||||
@@ -106,7 +106,7 @@ row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's stor
|
||||
|
||||
**Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer
|
||||
instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the
|
||||
`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection
|
||||
`calendar` permission, and a member's id is not 1. Derive it from `/auth/me` or from the collection
|
||||
paths; both work, and both stay correct when the caller is not the owner.
|
||||
|
||||
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
|
||||
|
||||
+30
-24
@@ -21,14 +21,13 @@
|
||||
> The rules in this file are current and authoritative; the findings table is a snapshot.
|
||||
>
|
||||
> **The runtime click-through has now happened** (2026-08-07, Playwright driving the system Brave against
|
||||
> the live server on 9010): 23 of 25 checks pass, and the two that did not are missing *data*, not
|
||||
> the live server on 9010): 23 of 25 checks pass, and the two that did not are missing _data_, not
|
||||
> regressions — the email account list and the Soulseek room list are both empty on this machine, so there
|
||||
> is nothing to click. Two further "failures" were the *test* being wrong, not the app: the Dock renders a
|
||||
> is nothing to click. Two further "failures" were the _test_ being wrong, not the app: the Dock renders a
|
||||
> user-pinned subset of 13 of 25 items, so `/plans` is absent by config; and `[data-sonner-toaster]` sits on
|
||||
> an inner `<ol>` that only exists while a toast is showing. **Suspect the instrument first.** Individual
|
||||
> "Needs runtime test" notes below may still be true — the sweep covered the routing claims, not every row.
|
||||
|
||||
|
||||
**Date:** 2026-07-30 · **Origin:** written as exploration before any of the routing work was done.
|
||||
|
||||
Prep work for the upcoming **full navigation refactor**. This catalogues every place the frontend
|
||||
@@ -43,6 +42,7 @@ imperative `navigate()` / global-channel setter **instead of a real `<Link to>`
|
||||
## The anti-pattern (definition)
|
||||
|
||||
A clickable element selects/opens something that has (or should have) a URL, but:
|
||||
|
||||
- **(a)** the entity id/slug is **not in the DOM** (no `href`, no `data-*`) — it lives only in an onClick closure;
|
||||
- **(b)** clicking **doesn't change the URL** (or does so only via an indirect state→URL effect);
|
||||
- **(c)** selection is held in **JS state / a global channel** (`usePanelChannel`, `useGlobal`), not the URL;
|
||||
@@ -50,7 +50,7 @@ A clickable element selects/opens something that has (or should have) a URL, but
|
||||
|
||||
**Exemplar (already fixed):** the `/chat` session list. Rows were `<button onClick={() => selectById(id)}>`
|
||||
(id only in the closure) → converted to `<Link to={`/chat/${session.id}`}>` (committed to master `f35c145`).
|
||||
That fix is the template for the HIGH items below. **Caveat:** the fix only did the *rows* — the chat
|
||||
That fix is the template for the HIGH items below. **Caveat:** the fix only did the _rows_ — the chat
|
||||
**detail panel** still selects via channel, not the URL (finding **C1**), so `/chat` is the model for both
|
||||
"done right" (rows) and "still to do" (detail).
|
||||
|
||||
@@ -72,7 +72,7 @@ was written.
|
||||
|
||||
**What's already correct** (lean on these in the refactor): the **Dock**, **Header** (logo + mobile sheet),
|
||||
**UserMenu**, **JobsIndicator** are all real `<Link>`s. Shared `NavLink.tsx` (query-string-appending `<Link>`
|
||||
wrapper — note: *not* react-router's NavLink, gives no active state) and `BackButton.tsx` (`<Link>` back arrow)
|
||||
wrapper — note: _not_ react-router's NavLink, gives no active state) and `BackButton.tsx` (`<Link>` back arrow)
|
||||
are good building blocks. The **Workspace/Panel framework** contains **zero** route navigation — it's orthogonal.
|
||||
|
||||
---
|
||||
@@ -82,7 +82,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
|
||||
### 🔴 HIGH — addressable route already exists; just needs a `<Link>` / URL-as-source-of-truth
|
||||
|
||||
| ID | file:line | Entity | Current impl | Fix |
|
||||
|----|-----------|--------|--------------|-----|
|
||||
| ------ | ------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H1 | `Screens/Dashboard/Jobs/JobsPage.tsx:108` | a job | `<button onClick={() => navigate(`/jobs/${job.id}`)}>` — id in closure | → `<Link to={`/jobs/${job.id}`}>`. Active-row already keys off `useParams().id`; keep the stop/delete button. **The exact twin of the /chat fix.** |
|
||||
| H2 | `workspaces/…/apps/Dashboards/DashboardListApp.tsx:133` | a dashboard | `<div onClick={handleClick}>` → `useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → `<Link to={`/dashboards/${ws.id}`}>`; drop the global as selection source (derive from `useParams`). Header is already a `<Link>` — app is internally inconsistent. |
|
||||
| H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `<div onClick={handleClick}>` → `useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → `<Link to={`/projects/${p.id}`}>`; retire `SELECTED_PROJECT` as source of truth. |
|
||||
@@ -97,13 +97,13 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
|
||||
### 🟠 MEDIUM — navigable entity with **no route yet** (add a route, then link)
|
||||
|
||||
| ID | file:line | Entity | Proposed route | Note |
|
||||
|----|-----------|--------|----------------|------|
|
||||
| ~~M1~~ | ~~`Screens/Dashboard/CapabilityPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `<Link>` row cannot imperatively reset them. |
|
||||
| ------- | ------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ~~M1~~ | ~~`Screens/Dashboard/PermissionPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `<Link>` row cannot imperatively reset them. |
|
||||
| ~~M2~~ | ~~`Screens/Dashboard/TaskLogs/index.tsx:104`~~ | a task-log run | `/task-logs/:id` | **Done.** As predicted — the detail fetch already keyed off the id, so only its source changed. `showDetail` is gone; the mobile swap and both back arrows derive from the param. |
|
||||
| ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived *string*, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". |
|
||||
| ~~M4~~ | ~~`FileBrowser/.../useFileBrowserApp.ts:269`~~, `FileItem.tsx:516`, ~~`Breadcrumb.tsx:16`~~ | a folder | `/files?path=<dir>` | **Partly done — the rest is an owner decision, not a defect.** `currentPath` is `?path=` on `/files`, so back/forward and linking a folder work, and the crumbs are `<Link>`s. Two things the audit line did not know: `?view=` is *ephemeral* (wiped on mount by `useFileViewerPanels`), so `path` is the screen's first durable param, and four `setSearchParams({…})` calls replaced the whole query string — opening any file would have silently reset the folder. They go through a `setViewerParams` helper now that keeps `path`. Opt-in via the parsed `WorkspaceIdentity` (`screens/files`), because a dashboard can hold two browsers and one shared param would move both. **Folder *items* stay buttons:** ⌘/Ctrl/Shift-click is already bound to multi-select in `FileItem.tsx` and open is double-click, so anchor semantics collide with an existing gesture. |
|
||||
| ~~M5~~ | ~~`CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33`~~ | open source file / active tab | `/code-editor?file=<path>` | **Done, minus `open=`.** The active file is `?file=`; tree *file* rows and tabs are `<Link>`s. **The tab set stays local** — it is a working session, not an address: it grows without bound, each entry costs a read on load, and nobody links someone else to a tab bar. A `?file=` naming a file that is not open now *opens* it, which is what makes a pasted link work; a path that fails to read is remembered so a bad link errors once instead of once per render, and the address is left alone rather than rewritten. **Tree folder rows stay buttons** — unlike the M4 case this needs no owner call, because expanding a directory is disclosure, not navigation. Two things fixed in passing: the tab close control was a `role="button"` span *nested inside* the tab (invalid then, a nested interactive inside an anchor now) and is a sibling `<button>` with an `aria-label`; and `closeFile` computed the next-active file *inside* a `setFiles` updater, which is exactly the impurity React double-invokes to catch. |
|
||||
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
|
||||
| ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived _string_, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". |
|
||||
| ~~M4~~ | ~~`FileBrowser/.../useFileBrowserApp.ts:269`~~, `FileItem.tsx:516`, ~~`Breadcrumb.tsx:16`~~ | a folder | `/files?path=<dir>` | **Partly done — the rest is an owner decision, not a defect.** `currentPath` is `?path=` on `/files`, so back/forward and linking a folder work, and the crumbs are `<Link>`s. Two things the audit line did not know: `?view=` is _ephemeral_ (wiped on mount by `useFileViewerPanels`), so `path` is the screen's first durable param, and four `setSearchParams({…})` calls replaced the whole query string — opening any file would have silently reset the folder. They go through a `setViewerParams` helper now that keeps `path`. Opt-in via the parsed `WorkspaceIdentity` (`screens/files`), because a dashboard can hold two browsers and one shared param would move both. **Folder _items_ stay buttons:** ⌘/Ctrl/Shift-click is already bound to multi-select in `FileItem.tsx` and open is double-click, so anchor semantics collide with an existing gesture. |
|
||||
| ~~M5~~ | ~~`CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33`~~ | open source file / active tab | `/code-editor?file=<path>` | **Done, minus `open=`.** The active file is `?file=`; tree _file_ rows and tabs are `<Link>`s. **The tab set stays local** — it is a working session, not an address: it grows without bound, each entry costs a read on load, and nobody links someone else to a tab bar. A `?file=` naming a file that is not open now _opens_ it, which is what makes a pasted link work; a path that fails to read is remembered so a bad link errors once instead of once per render, and the address is left alone rather than rewritten. **Tree folder rows stay buttons** — unlike the M4 case this needs no owner call, because expanding a directory is disclosure, not navigation. Two things fixed in passing: the tab close control was a `role="button"` span _nested inside_ the tab (invalid then, a nested interactive inside an anchor now) and is a sibling `<button>` with an `aria-label`; and `closeFile` computed the next-active file _inside_ a `setFiles` updater, which is exactly the impurity React double-invokes to catch. |
|
||||
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was _almost_ right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
|
||||
| ~~M7~~ | ~~`workspaces/components/Combobox.tsx:53`~~ | caller-supplied route | — | **Deleted, not fixed.** "Every caller inherits the opaque click" was the reason this ranked MEDIUM, and it is wrong: `Combobox` has **no callers**. Nothing has imported it since the initial commit, there is no barrel export, and nothing anywhere sets `href` on a `SelectOption` — so the navigate, the separator that only showed for `href` options, and the `href` field on both declarations of the type were all unreachable. Writing anchor semantics into a component that is never rendered is building, not fixing. Its `Command` primitives stay; `AIHarnessesSection` uses them. |
|
||||
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
|
||||
| ~~M9~~ | `Screens/Dashboard/Plans/index.tsx` | a plan document | `/plans/:name` | **Done.** Route pair, no `Navigate` guard — the bare route means "no plan open", which is a real state, so the auto-select-first effect was deleted rather than turned into a redirect. The `<select>` navigates instead of setting state; it stays a `<select>` on purpose (chrome for one document, not a master list) and therefore genuinely has no cmd-click — a native `<option>` cannot be an anchor. A name that no longer exists gets the empty pane, not a rewritten URL. Reading the server route for this also turned up a **path traversal**: hono percent-decodes route params, so `GET /api/plans/..%2F..%2Fsecret` reached `join(plansDir, '../../secret.md')`. Now `basename()`d. |
|
||||
@@ -134,16 +134,16 @@ is **one design decision** that cascades across many files:
|
||||
- ~~**Dock / Header active styling**~~ (`Dock.tsx` · `Header.tsx`) — **done.** Both are react-router
|
||||
`<NavLink>`s now and the two copies of `isActive` are gone, along with the `useLocation` each needed.
|
||||
One behavioural difference, deliberate: the hand-rolled version was a string `startsWith`, so `/task-logs`
|
||||
would also have matched a hypothetical `/task-logsomething`; `NavLink` matches by path *segment*, which
|
||||
would also have matched a hypothetical `/task-logsomething`; `NavLink` matches by path _segment_, which
|
||||
is what was meant. `end` is set for Home only — without it `NavLink` treats `/` as an ancestor of every
|
||||
route; with it on the others, a detail route (`/plans/x`, `/system-monitor/btop`) would lose its highlight.
|
||||
- ~~**Browser tabs**~~ (`Browser/TabList.tsx:93`) — **done, against this file's own advice.** The objection
|
||||
was that a CDP target id is ephemeral, so a durable `/browser/:tabId` is dubious. True of *bookmarking*,
|
||||
was that a CDP target id is ephemeral, so a durable `/browser/:tabId` is dubious. True of _bookmarking_,
|
||||
and irrelevant to everything else the URL buys: the id was in an onClick closure, three components read a
|
||||
`BROWSER_SELECTED_TAB` global, and the row could not be cmd-clicked. Staleness is handled where it
|
||||
actually shows up — the preview now distinguishes "no tab open" from "that tab is no longer attached"
|
||||
by checking the polled target list, which it gets from the same React Query key the list uses, so it
|
||||
costs no extra request. En route: the row's Focus and Close buttons were nested *inside* the row
|
||||
costs no extra request. En route: the row's Focus and Close buttons were nested _inside_ the row
|
||||
`<button>`, which is invalid HTML and only worked because of two `stopPropagation` calls; they are
|
||||
siblings of the anchor now. And its "Set up in Integrations" was a raw `<a href>` that reloaded the SPA.
|
||||
- **Jobs step/iteration** (`Jobs/JobDetail.tsx`) — **decided: skipped, and it is not an anti-pattern.**
|
||||
@@ -172,7 +172,7 @@ Every place an **addressable entity** is selected through a global channel / glo
|
||||
This is the primary surface to convert to URL-driven selection.
|
||||
|
||||
| Channel / global key | Entity held | Should map to | Files |
|
||||
|----------------------|-------------|---------------|-------|
|
||||
| -------------------------------------- | -------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `chat:selected-session` | open chat session | `/chat/:sessionId` | `ChatDetailPanel.tsx:136`, `SessionList.tsx:16` (H4) |
|
||||
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
|
||||
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
|
||||
@@ -184,7 +184,7 @@ This is the primary surface to convert to URL-driven selection.
|
||||
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`.
|
||||
(`FILE_VIEWER_CHANNEL` was listed here too; it had no publisher and has been deleted — the file viewer
|
||||
reads `?view=` from the URL. `preview:refresh` and `chat:active-session` were also listed, and
|
||||
`preview:refresh` was cited above as the exemplar of a *legitimate* channel — but both have a publisher
|
||||
`preview:refresh` was cited above as the exemplar of a _legitimate_ channel — but both have a publisher
|
||||
in `ChatPanelWrapper` and **no subscriber at all**, and `preview:refresh`'s reader, `PreviewProvider`, is
|
||||
no longer in the repo. They are declared in `officerdev/src/channels.ts` with that stated; deleting the
|
||||
publishers means changing the chat panel, which is another agent's, so it is written up in
|
||||
@@ -197,7 +197,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
"Link?" = a `<Link>`/`<NavLink>` is the right refactor.
|
||||
|
||||
| # | file:line | what | target | Link? | note |
|
||||
|---|-----------|------|--------|-------|------|
|
||||
| ------ | ------------------------------------------------------ | ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | `Jobs/JobsPage.tsx:109` | job list row | `/jobs/:id` | **YES** | H1 |
|
||||
| 2 | `Dashboards/DashboardListApp.tsx:90` | dashboard row (off-page) | `/dashboards/:id` | YES | H2 |
|
||||
| 3 | `Dashboards/DashboardListApp.tsx:114` | inside "New Dashboard" | `/dashboards` | ~ | create action |
|
||||
@@ -230,7 +230,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
|
||||
> **Progress — 2026-07-30, branch `navigation-refactor` (off master; NOT yet runtime-tested):**
|
||||
> H1, H2, H3 implemented and tsgo-clean. **Design correction for H2/H3:** the naive "row → `<Link to="/dashboards/:id">`"
|
||||
> would destroy the *preview-on-list* feature (that route is the full page). The faithful fix — which is what
|
||||
> would destroy the _preview-on-list_ feature (that route is the full page). The faithful fix — which is what
|
||||
> was implemented — moves selection out of the `SELECTED_*` global into a **`?selected=<id>` URL param** read by
|
||||
> the list, the screen (mobile panel), and the preview; rows are real `<Link>`s (`/…?selected=id` on-page,
|
||||
> `/…/:id` off-page) with the action buttons kept as **siblings** of the anchor, not nested inside it. Same
|
||||
@@ -238,10 +238,11 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
> create/edit/delete(/publish), and mobile-panel flows on `/dashboards` and `/projects`.
|
||||
|
||||
### Phase 1 — Quick wins (routes already exist; mechanical, high value)
|
||||
|
||||
- [x] **H1** Jobs rows → `<Link to={`/jobs/${job.id}`}>` (`JobsPage.tsx`). Done — `46482f3`. (active-row highlight already keyed off `useParams().id`.)
|
||||
- [x] **H2** Dashboards rows → `<Link>`; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** (The constant itself outlived its last reader by four months and has now been deleted; its siblings in `Dashboards/constants.ts` are dialog form state, not selection, and stay.)
|
||||
- [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.**
|
||||
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. *Deferred: overlaps the in-flight `sidecars-*` chat-comms work; do after that lands.*
|
||||
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. _Deferred: overlaps the in-flight `sidecars-_` chat-comms work; do after that lands.\*
|
||||
- [x] **H5** Email rows → `<Link>` driven by `useParams().emailId`; the `EMAIL_SELECTED` global and both state↔URL sync effects are gone. **Needs runtime test.** (`EMAIL_FOLDER` stays a `useGlobal` for now — it is read in one component and is view state, not selection; putting the folder in `?folder=` is a separate, smaller item.)
|
||||
- [x] **M8** Dead `/settings/resources` menu item removed from `UserMenu.tsx`, along with its now-orphaned `en`/`pt` locale keys. **Needs runtime test.**
|
||||
- [x] Verified + converted the preview "open" navigates. Four were listed; **one** was real. The two
|
||||
@@ -250,24 +251,27 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
post-mutation redirect and stays; only the "Open Dashboard" button in the edit form was pure
|
||||
navigation, and it is now `<Button asChild><Link …>`. The big click-through overlay on the preview
|
||||
was already a `<Link>`. `DashboardListApp`'s "New Dashboard" also stays a button: it sets six pieces
|
||||
of form state and only *then* conditionally navigates.
|
||||
of form state and only _then_ conditionally navigates.
|
||||
|
||||
### Phase 2 — Add a route, then link (per-entity, medium effort)
|
||||
|
||||
- [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (`<NavLink>`), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.**
|
||||
- [x] **M1** Capabilities → `/tasks|skills|processes/:dirName`, rows → `<Link>`; `CapabilityPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `<Navigate>` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.**
|
||||
- [x] **M1** Permissions → `/tasks|skills|processes/:dirName`, rows → `<Link>`; `PermissionPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `<Navigate>` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.**
|
||||
- [x] **M2** TaskLogs → `/task-logs/:id`; rows are `<Link>`s, `showDetail` deleted. **Needs runtime test.**
|
||||
- [x] **M3** Activity → `/activity/:id`; the `{label, query}` selection object is gone — the id is the URL and the stream query is derived from the registry row. `/activity` also had no `usePageTitle` rule (it read "Officer"); added. **Needs runtime test.**
|
||||
- [x] **M4** FileBrowser folders → `/files?path=`; breadcrumbs are `<Link>`s. Folder *rows* deliberately still buttons — ⌘-click is multi-select, open is double-click; converting them needs an owner call on the gesture.
|
||||
- [x] **M5** CodeEditor active file → `/code-editor?file=`; tree file rows and tabs are `<Link>`s. The open-tab *set* stays local state, on purpose — see the findings row.
|
||||
- [x] **M4** FileBrowser folders → `/files?path=`; breadcrumbs are `<Link>`s. Folder _rows_ deliberately still buttons — ⌘-click is multi-select, open is double-click; converting them needs an owner call on the gesture.
|
||||
- [x] **M5** CodeEditor active file → `/code-editor?file=`; tree file rows and tabs are `<Link>`s. The open-tab _set_ stays local state, on purpose — see the findings row.
|
||||
- [x] **M7** Combobox — **deleted instead**. Zero callers since the initial commit; `href` on `SelectOption` was never set by anything, so the whole branch was unreachable.
|
||||
|
||||
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
|
||||
|
||||
- [x] **Music** — `/music?path=<rel>`; `music:cwd` deleted; every drill-in (including the dock's now-playing tile, navigate-site 13) is a `<Link>`. `music:favorites` and `music:resync` stay — a view toggle and a refresh signal. **Needs runtime test.**
|
||||
- [x] **Soulseek** — `/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
|
||||
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
|
||||
- [x] **M9** Plans → `/plans/:name`; the auto-select-first effect is gone (the bare route is a real state: no plan open), and the `<select>` navigates instead of setting state. It stays a `<select>` — a native `<option>` cannot be an anchor, so this one has no cmd-click and the doc should not pretend otherwise; it is chrome for a single document, not a master list. Reading the route also turned up a path traversal in `GET /api/plans/:name` (hono percent-decodes params, so `..%2F..%2Fx` walked out of `plansDir`) — fixed with `basename()`. **Needs runtime test.**
|
||||
|
||||
### Phase 4 — Polish + borderline decisions
|
||||
|
||||
- [x] Dock + Header + mobile sheet → react-router `<NavLink>`; both `isActive` helpers and their `useLocation`s deleted. `end` on Home only. **Needs runtime test.**
|
||||
- [ ] "New Chat" → `<Link to="/chat/new">` (`SessionList.tsx:68`) once H4's channel cleanup lands.
|
||||
- [x] Jobs back button → `<Link to="/jobs">`, and `useNavigate` dropped from `PipelineJobDetail` (it had no
|
||||
@@ -279,6 +283,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
- [x] Decided/skipped: Jobs step deep-link (a feature, not a fix — owner's call), Preview slug (**void**: no such app), FileBrowser widget (stays local, on M4's rule). Reasoning for each in the LOW section. (Browser tabs: **done** — see the LOW section. Monitor scope: **done** as M10, it was not a view toggle. Music favorites: **decided** — stays a channel, reasoning in the LOW section.)
|
||||
|
||||
### Cross-cutting for the refactor itself
|
||||
|
||||
- [x] Standardise a URL-as-source-of-truth pattern for panel selection (replace the `usePanelChannel`/`useGlobal`
|
||||
selection channels in the map above with `useParams`/`useSearchParams`, keeping channels only for
|
||||
genuine signals/refresh buses). Done except `chat:selected-session` (H4), which is the chat agent's.
|
||||
@@ -332,6 +337,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
unimplemented. **The general lesson: cross-app intent that is not pure navigation should not be
|
||||
encoded as a URL.** Creating a dashboard is five ordered state writes; expressing that as a link was
|
||||
what made it silently breakable in the first place. See the status note §24 for the full write-up.
|
||||
|
||||
- [x] Adopt `<NavLink>` (real react-router) for all nav chrome so active state stops being JS-derived.
|
||||
Done — `39125b5`. Note `end={item.to === '/'}`: without it NavLink treats `/` as an ancestor of
|
||||
every route, and with it on the rest a detail route would lose its tile.
|
||||
|
||||
@@ -136,7 +136,7 @@ account manager in recoverable form, and synced to whatever backs that phone up.
|
||||
device, revocable per device, is the whole point.
|
||||
|
||||
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
|
||||
since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member
|
||||
since 2026-08-07 `calendar` is a **grantable** permission, so an app password can belong to a member
|
||||
and the column decides whose collection tree Radicale serves. It is load-bearing.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -26,7 +26,7 @@ engine. But `loadOpenCodeSession` reads the transcript through the legacy route
|
||||
run to completion with a real model reply:
|
||||
|
||||
| read | api-created session | legacy-created session |
|
||||
|---|---|---|
|
||||
| ------------------------------------------ | ---------------------- | ---------------------- |
|
||||
| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript |
|
||||
| `GET /api/session/{id}/message` | 200, 3 messages | **500** |
|
||||
| `GET /session/{id}` (the record) | 200, title + directory | 200 |
|
||||
@@ -42,7 +42,7 @@ rather than erroring.
|
||||
### 1b. The session list silently truncates at 50
|
||||
|
||||
`GET /api/session` defaults to **50 rows** and returns a `cursor.next`. Measured: with 50 sessions in
|
||||
the store the list returns 50 *and still offers a next cursor*; adding a 51st and asking `?limit=200`
|
||||
the store the list returns 50 _and still offers a next cursor_; adding a 51st and asking `?limit=200`
|
||||
returns 51 (and `limit` is capped at 100 — 200 is accepted for the list but `/history` rejects >100
|
||||
with `Expected a value less than or equal to 100`).
|
||||
|
||||
@@ -62,7 +62,7 @@ There is no version string "2.0" in the running server. `GET /doc` self-reports
|
||||
`{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists:
|
||||
|
||||
| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** |
|
||||
|---|---|---|---|
|
||||
| ------------ | --------------------------------------------------------- | ----------------------------------- | ------------------------------------------------- |
|
||||
| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` |
|
||||
| routes | 111 paths | 51 paths | ~100 paths, still moving |
|
||||
| operationIds | `session.list` | **`v2.session.list`** | — |
|
||||
@@ -70,12 +70,12 @@ There is no version string "2.0" in the running server. `GET /doc` self-reports
|
||||
| docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs |
|
||||
|
||||
So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its
|
||||
operation ids are literally `v2.*`. It is not something to adopt; it is something to *finish*.
|
||||
operation ids are literally `v2.*`. It is not something to adopt; it is something to _finish_.
|
||||
|
||||
Two qualifications, both from the source at tag `v1.18.16`:
|
||||
|
||||
- **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`,
|
||||
version `"0.0.1"`, described as *"Experimental HttpApi surface for selected instance routes"*, with
|
||||
version `"0.0.1"`, described as _"Experimental HttpApi surface for selected instance routes"_, with
|
||||
every group annotated the same way. Meanwhile `/session/*` is the surface the public docs actually
|
||||
document, and it is not deprecated. The internal direction is unambiguous; the external commitment is
|
||||
nil.
|
||||
@@ -88,15 +88,15 @@ Two qualifications, both from the source at tag `v1.18.16`:
|
||||
`session.next.*` today, but put the names behind one mapping table, because they are scheduled to
|
||||
change wholesale.
|
||||
|
||||
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: *"V1 coexistence is temporary… delete the
|
||||
V1 subtree when the legacy runtime is retired"* and *"Do not preserve `V2` as the permanent name for the
|
||||
replacement architecture."* Both halves of today's naming are transitional.
|
||||
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: _"V1 coexistence is temporary… delete the
|
||||
V1 subtree when the legacy runtime is retired"_ and _"Do not preserve `V2` as the permanent name for the
|
||||
replacement architecture."_ Both halves of today's naming are transitional.
|
||||
|
||||
**OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs
|
||||
carry the banner *"we may wipe your data, things may break, and APIs, configuration, and plugin APIs
|
||||
may change"*, releases ship ~6/day, and the migration guide states three intentional breaking changes
|
||||
(plugin API, server API contracts, TUI config), with *"Integrations that call the V1 server API must
|
||||
migrate to the V2 API"*. No deprecation date for the legacy surface is published anywhere.
|
||||
carry the banner _"we may wipe your data, things may break, and APIs, configuration, and plugin APIs
|
||||
may change"_, releases ship ~6/day, and the migration guide states three intentional breaking changes
|
||||
(plugin API, server API contracts, TUI config), with _"Integrations that call the V1 server API must
|
||||
migrate to the V2 API"_. No deprecation date for the legacy surface is published anywhere.
|
||||
|
||||
Two facts worth knowing regardless:
|
||||
|
||||
@@ -130,7 +130,7 @@ plus `GET /config/providers` for the model list (`list-models.ts:58`). The one e
|
||||
|
||||
### 4a. Adding context to a turn that is already running
|
||||
|
||||
The capability the subprocess path could never have, and the reason the migration happened.
|
||||
The permission the subprocess path could never have, and the reason the migration happened.
|
||||
|
||||
```
|
||||
POST /api/session/{id}/prompt
|
||||
@@ -139,12 +139,12 @@ POST /api/session/{id}/prompt
|
||||
"delivery": "steer" | "queue", "resume": true|false }
|
||||
```
|
||||
|
||||
Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is
|
||||
false."*
|
||||
Spec description: _"Durably admit one session input and schedule agent-loop execution unless resume is
|
||||
false."_
|
||||
|
||||
- **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the
|
||||
work in flight. No kill, no restart, no lost context. We already send it (`serve-runner.ts:199`) but
|
||||
only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI *asks* for
|
||||
only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI _asks_ for
|
||||
it, and nothing distinguishes "add this to what you're doing" from "here's my next message".
|
||||
- **`delivery: "queue"`** runs after the current turn. It must be stated explicitly — **the field
|
||||
defaults to `steer`** — or two quick messages merge into one turn (`serve-runner.ts:268`).
|
||||
@@ -188,13 +188,13 @@ that matter for building on it:
|
||||
- `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session.
|
||||
Omitting it replays the session from 0.
|
||||
- **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`,
|
||||
advances its cursor to the last row, and on every wake re-reads *the database* rather than draining a
|
||||
advances its cursor to the last row, and on every wake re-reads _the database_ rather than draining a
|
||||
pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit
|
||||
`Sequence mismatch` / `Replay diverged` errors.
|
||||
- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery,
|
||||
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||
|
||||
Note the two cursor kinds are unrelated: the session *list* uses an opaque base64url cursor
|
||||
Note the two cursor kinds are unrelated: the session _list_ uses an opaque base64url cursor
|
||||
(`cursor.previous` / `cursor.next`), this one is a plain integer.
|
||||
|
||||
**But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a
|
||||
@@ -233,14 +233,14 @@ The two "v2"s are not the same kind of change, which matters if we implement one
|
||||
`{action, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to
|
||||
`{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union
|
||||
`source: {type:"tool", messageID, callID}`; and the reply loses its free-text `message`. The public
|
||||
V2 docs say the same in config terms: *"Do not use `permission`, `bash`, or `task` in V2
|
||||
configuration."*
|
||||
V2 docs say the same in config terms: _"Do not use `permission`, `bash`, or `task` in V2
|
||||
configuration."_
|
||||
- **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of
|
||||
`{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and
|
||||
event names changed.
|
||||
|
||||
Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the
|
||||
`/api` protocol is *built* from excludes the v1 families, but the server wires the **full** manifest
|
||||
`/api` protocol is _built_ from excludes the v1 families, but the server wires the **full** manifest
|
||||
(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event`
|
||||
union on our own `/doc`.
|
||||
|
||||
@@ -277,7 +277,7 @@ Our mapper recognises 18 names and maps 7. The server emits **130 event type str
|
||||
`created`, `deleted`, `updated`, `diff`).
|
||||
|
||||
| dropped | what it would give |
|
||||
|---|---|
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode |
|
||||
| `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive |
|
||||
| `tool.progress` | long tools reporting instead of appearing hung |
|
||||
@@ -318,7 +318,7 @@ with no model, against a serve with no configured default, hangs silently.** Wor
|
||||
- **The transcript shape differs.** Legacy items are `{info:{role,…}, parts:[…]}` — what
|
||||
`opencode-sessions.ts:81` parses. `/api` items are
|
||||
`{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}],
|
||||
finish, cost, tokens}`. A second mapper, or a shared normaliser.
|
||||
finish, cost, tokens}`. A second mapper, or a shared normaliser.
|
||||
- **The SSE parser needs to grow up.** `serve-runner.ts:89` is `data:`-only: no `event:`, no `id:`, no
|
||||
comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream
|
||||
must resume at `?after=<last seq>`, not restart.
|
||||
@@ -352,7 +352,9 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
const client = createOpencodeClient({ baseUrl });
|
||||
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
|
||||
const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq });
|
||||
for await (const ev of events.stream) { /* ev.type, ev.durable.seq */ }
|
||||
for await (const ev of events.stream) {
|
||||
/* ev.type, ev.durable.seq */
|
||||
}
|
||||
```
|
||||
|
||||
`client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/
|
||||
@@ -402,7 +404,7 @@ Then the two that are real features needing UI: **permissions/questions** (§4d)
|
||||
- It does not put us on OpenCode 2.0. Note the direction of travel there: the beta **removes**
|
||||
`/api/session/{id}/history` and `/api/session/{id}/event` — the two durable routes item 6 depends on
|
||||
— replacing them with `GET /api/experimental/session/{id}/log?after=&follow=`. Same idea, new path,
|
||||
`experimental/` prefix. So item 6 is worth doing *and* worth writing behind one function.
|
||||
`experimental/` prefix. So item 6 is worth doing _and_ worth writing behind one function.
|
||||
|
||||
---
|
||||
|
||||
@@ -449,7 +451,7 @@ was published hours before this file was written.
|
||||
vs `Definitions` — the delta exclusion), `packages/schema/src/{permission,question}.ts` and their
|
||||
`v1/` counterparts, `packages/schema/src/session-input.ts` (`admittedSeq`), `packages/schema/AGENTS.md`
|
||||
(the V1/V2 naming intent), `packages/core/src/event.ts` (replay-then-live), `packages/sdk/js/script/
|
||||
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||
(the renames).
|
||||
- npm: `@opencode-ai/sdk` 1.18.16, `@opencode-ai/client@next`.
|
||||
- Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`,
|
||||
|
||||
@@ -86,13 +86,13 @@ Also fixed after the review, and not in this table because it was found by revie
|
||||
a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See
|
||||
`docs/opencode-phase1-review.md`.
|
||||
|
||||
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave *wrongly*
|
||||
is gone. What remains is bucket 1 — capabilities Claude has and OpenCode does not — and most of the
|
||||
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave _wrongly_
|
||||
is gone. What remains is bucket 1 — permissions Claude has and OpenCode does not — and most of the
|
||||
visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are
|
||||
downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork.
|
||||
|
||||
**The fork is REOPENED, unblocked, and worth taking.** The serve publishes a newer `/api/session/*` surface offering
|
||||
those capabilities natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both
|
||||
those permissions natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both
|
||||
verified working** — mid-turn injection and queueing, as primitives, plus `/interrupt` and a resumable
|
||||
per-session event stream. One blocker remains: `claude-sonnet-4-6` silently does not run on that surface
|
||||
(it runs fine under `opencode run`). `docs/opencode-fork-decision.md` has the evidence, the open
|
||||
@@ -102,7 +102,7 @@ passed that one model.
|
||||
Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on
|
||||
1.18.16.
|
||||
|
||||
**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` capability and carries
|
||||
**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` permission and carries
|
||||
`proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` /
|
||||
`getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode
|
||||
against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar
|
||||
@@ -110,7 +110,7 @@ restart stops in-flight turns and writes the reason to `chat_session_events`, an
|
||||
enumerates what is running.
|
||||
|
||||
**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault`
|
||||
and `opencode` receive no identity and are covered today only because those capabilities are owner-only —
|
||||
and `opencode` receive no identity and are covered today only because those permissions are owner-only —
|
||||
"a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses
|
||||
to share at any level, so this cannot be reached by a member. It is latent by construction.
|
||||
|
||||
@@ -123,7 +123,7 @@ something nothing renders. Left alone deliberately.
|
||||
put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path
|
||||
carries them today: the sidecar spills each image to a temp file for the turn and removes it in
|
||||
`settle`. Verified end to end — a red PNG over the chat socket to `opencode/claude-sonnet-4-6` came back
|
||||
"Red". `list-models` now reports each model's own `capabilities.input.image` instead of a hardcoded
|
||||
"Red". `list-models` now reports each model's own `permissions.input.image` instead of a hardcoded
|
||||
`false`, so the composer gate became load-bearing in the right direction.
|
||||
|
||||
---
|
||||
@@ -132,7 +132,7 @@ carries them today: the sidecar spills each image to a temp file for the turn an
|
||||
|
||||
Ordered roughly by user-visible value.
|
||||
|
||||
| Capability | Claude | OpenCode | Depends on the fork? |
|
||||
| Permission | Claude | OpenCode | Depends on the fork? |
|
||||
| --------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------- |
|
||||
| Token streaming | `delta` events from `stream_event` | **No** — `run` emits complete text parts (`runner.ts:176-177`) | **Yes** |
|
||||
| Mid-turn injection / queue-into-turn | streaming input queue | **No** — `stdin: 'ignore'` | **Yes** |
|
||||
|
||||
@@ -36,7 +36,7 @@ for a follow-up that touches the socket contract, is the right split.
|
||||
`013e629` flipped `images: true` → `false` for OpenCode models, and the commit says "61 OpenCode models
|
||||
now decline, the three Claude ones still accept".
|
||||
|
||||
**Nothing declines.** No code in `src/workspaces` or `src/apps` reads that capability — the composer's
|
||||
**Nothing declines.** No code in `src/workspaces` or `src/apps` reads that permission — the composer's
|
||||
image affordances are ungated. Grep for a consumer of the model's `images` field returns nothing:
|
||||
`InputArea`'s drop zone, the paste handler, and `AttachButton` all accept images regardless of model, and
|
||||
`useAttachments` collects them regardless.
|
||||
@@ -47,14 +47,14 @@ which is worth having, but B4 described a user-visible lie and that lie is still
|
||||
|
||||
Two ways to close it, and they are not equivalent:
|
||||
|
||||
1. **Gate the composer on the capability.** Read the selected model's `images` flag and hide the drop
|
||||
1. **Gate the composer on the permission.** Read the selected model's `images` flag and hide the drop
|
||||
zone, the paste path and the attach-image button when it is false. Cheap. Makes the flag load-bearing,
|
||||
so the flip in `013e629` starts doing something.
|
||||
2. **Plumb images through `OpenCodeRunParams`** (currently Phase 4). Removes the limitation rather than
|
||||
surfacing it.
|
||||
|
||||
(1) is the honest one-liner Phase 0 was for; (2) is the real fix. Doing (1) now costs nothing if (2)
|
||||
happens later — the gate simply stops firing once the capability is true.
|
||||
happens later — the gate simply stops firing once the permission is true.
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ finishes and had no effect on either test. Worth knowing it exists; not worth ch
|
||||
|
||||
## Suggested next work, in order
|
||||
|
||||
1. **B4 properly** — gate the composer on the model's `images` capability (above).
|
||||
1. **B4 properly** — gate the composer on the model's `images` permission (above).
|
||||
2. **Delete the `AGENTS.md` injection and the stale one-project comment**, now that `--dir` is verified.
|
||||
This is Phase 1 work and it is the thing Andre most wanted gone.
|
||||
3. **Then the rest of Phase 1** — the dead `event-mapper.ts` and SSE machinery, the wrong path names in
|
||||
|
||||
@@ -17,7 +17,7 @@ it_ — earned its place three separate times, detailed below.
|
||||
| `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory |
|
||||
| `492509a` | B2 — route a resumed OpenCode session to OpenCode |
|
||||
| `013e629` | B4 (first attempt), B5, B6, thinking selector |
|
||||
| `7774a25` | B4 properly — gate the composer on the capability |
|
||||
| `7774a25` | B4 properly — gate the composer on the permission |
|
||||
| `cfbf58c` | Delete the `AGENTS.md` injection + the one-project comment |
|
||||
| `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` |
|
||||
| `8b409e8` | Phase 1 finish — stale comments, version pin, first tests |
|
||||
@@ -91,8 +91,8 @@ conclusion independently, which was reassuring to read afterwards.)
|
||||
|
||||
**B4 — closed the way your review asked, not the way the parity doc did.** The doc offered the flag flip
|
||||
as "the honest one-liner"; you correctly pointed out that flipping it changed nothing observable because
|
||||
no code read the capability. The composer now gates on it — drop zone, paste path, attach menu — so the
|
||||
flag is load-bearing. Unknown model still allows images: a missing capability should not remove a
|
||||
no code read the permission. The composer now gates on it — drop zone, paste path, attach menu — so the
|
||||
flag is load-bearing. Unknown model still allows images: a missing permission should not remove a
|
||||
working control.
|
||||
|
||||
**Thinking selector — removed, not hidden.** The doc said hide; hiding a control that does nothing still
|
||||
@@ -135,7 +135,7 @@ Testing was explicitly de-prioritised for this pass, so these are recorded rathe
|
||||
|
||||
## Suggested next, if you are writing the following spec
|
||||
|
||||
1. **Exercise `opencode:list`** — it is the only new capability whose happy path is unproven.
|
||||
1. **Exercise `opencode:list`** — it is the only new permission whose happy path is unproven.
|
||||
2. **Decide the fork.** The blocker is gone; `opencode-serve-path.md` frames it. If the answer is "not
|
||||
yet", say so in the parity doc so it stops reading as pending work.
|
||||
3. **The remaining Phase 1 residue**: `sweepStaleServes` is `/proc`-based and a no-op on macOS (B8), and
|
||||
|
||||
@@ -15,7 +15,7 @@ works, it is verified end to end, and its limits are all consequences of that on
|
||||
|
||||
The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16:
|
||||
|
||||
| Capability | How | Verified |
|
||||
| Permission | How | Verified |
|
||||
| ------------------------ | --------------------------------------------------- | --------------------------------------------------- |
|
||||
| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn |
|
||||
| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors |
|
||||
@@ -86,7 +86,7 @@ written and tested, and it is the only phase with no user-visible risk.
|
||||
`POST /interrupt` for stop. Keep `opencode run` reachable by config so a bad day is one restart from the
|
||||
known-good path. The switch is the deliverable, not a detail.
|
||||
|
||||
**Phase C — the capabilities that motivated it.** `delivery: "steer"` wired to the existing "send now"
|
||||
**Phase C — the permissions that motivated it.** `delivery: "steer"` wired to the existing "send now"
|
||||
button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins
|
||||
and they are cheap once B holds.
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ behind what some machines run.
|
||||
|
||||
**Move turns onto the serve (`POST /session/{id}/message?directory=…`)**
|
||||
|
||||
- Unblocks the whole of parity Phase 3 at once — those six capabilities are all downstream of a
|
||||
- Unblocks the whole of parity Phase 3 at once — those six permissions are all downstream of a
|
||||
persistent, addressable session.
|
||||
- Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already
|
||||
exists in the deleted code, so the cost is smaller than it looks.
|
||||
|
||||
@@ -5,11 +5,11 @@ Agents are explicitly out of scope for the first pass.
|
||||
|
||||
## What this is for
|
||||
|
||||
Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the
|
||||
**owner's OS user in the owner's home**. That is why `capabilities/registry.ts` declares them
|
||||
Today every `execution` permission — terminal, chat, files, tasks, items, desktop, browser — runs as the
|
||||
**owner's OS user in the owner's home**. That is why `permissions/registry.ts` declares them
|
||||
`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one.
|
||||
The registry says so out loud: *"revisit only if per-user home confinement is ever solved — and that is a
|
||||
project, not a checkbox."*
|
||||
The registry says so out loud: _"revisit only if per-user home confinement is ever solved — and that is a
|
||||
project, not a checkbox."_
|
||||
|
||||
This is that project. A member gets a real Linux account whose home is the directory the platform already
|
||||
provisions for them, and the surfaces that execute code run **as that account**. The payoff is three
|
||||
@@ -64,21 +64,21 @@ code has ever had for a non-owner home.
|
||||
On this machine, verified 2026-08-11:
|
||||
|
||||
| path | mode | consequence |
|
||||
| --- | --- | --- |
|
||||
| ----------------- | ------- | ---------------------------------- |
|
||||
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
|
||||
| `…/officer.dev` | 775 | listable by anyone |
|
||||
| `…/platform/.env` | **664** | **world-readable** |
|
||||
|
||||
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
|
||||
a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than
|
||||
not doing it — the capability model would be intact and completely bypassed.
|
||||
not doing it — the permission model would be intact and completely bypassed.
|
||||
|
||||
So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is
|
||||
traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env`
|
||||
under the project root is group- or world-readable.** A prerequisite that is merely written down is a
|
||||
prerequisite that gets skipped.
|
||||
|
||||
The same applies to `capabilities/` (775 today) and to the repo checkout itself: a member can read the
|
||||
The same applies to `permissions/` (775 today) and to the repo checkout itself: a member can read the
|
||||
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
|
||||
|
||||
## The mechanism, and the trap in it
|
||||
@@ -88,7 +88,7 @@ platform source. That is acceptable — it is not secret — but anything creden
|
||||
Verified on bun 1.3.10, 2026-08-11. From uid 1000:
|
||||
|
||||
```js
|
||||
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }) // exit 0, prints "1000"
|
||||
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }); // exit 0, prints "1000"
|
||||
```
|
||||
|
||||
It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and
|
||||
@@ -98,7 +98,7 @@ Two honest qualifications, because the danger is narrower than it first looks:
|
||||
|
||||
- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by
|
||||
accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all.
|
||||
- What *can* reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
|
||||
- What _can_ reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
|
||||
sidecars are `.mjs`.
|
||||
|
||||
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
|
||||
@@ -115,7 +115,7 @@ sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv
|
||||
```
|
||||
|
||||
- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to.
|
||||
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the *owner's*
|
||||
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the _owner's_
|
||||
groups, which is a quiet way to retain access we just took away.
|
||||
- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and
|
||||
`PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`,
|
||||
@@ -123,8 +123,8 @@ sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv
|
||||
`.env`.
|
||||
|
||||
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
|
||||
`initgroups failed: Operation not permitted` for an unprivileged caller *even when reuid'ing to its own
|
||||
account* — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
|
||||
`initgroups failed: Operation not permitted` for an unprivileged caller _even when reuid'ing to its own
|
||||
account_ — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
|
||||
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
|
||||
user will ever see.
|
||||
|
||||
@@ -143,10 +143,10 @@ That last line is the whole security property, demonstrated rather than asserted
|
||||
test (`os-user.test.ts` → "does not pass the platform environment through").
|
||||
|
||||
`sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is
|
||||
sudoers *policy* — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
||||
sudoers _policy_ — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
||||
shell" must not depend on a config file someone may have edited.
|
||||
|
||||
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.
|
||||
@@ -227,7 +227,7 @@ crossed the ancestor that mattered.
|
||||
2026-08-11; the superseded text is in the git history of this file, and the working state is
|
||||
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
|
||||
|
||||
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen *outside* it, making a
|
||||
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen _outside_ it, making a
|
||||
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
|
||||
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
|
||||
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
|
||||
@@ -245,8 +245,9 @@ crossed the ancestor that mattered.
|
||||
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
|
||||
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
|
||||
harness stays the service user's; only `claude` itself drops privileges.
|
||||
|
||||
- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely
|
||||
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill *every*
|
||||
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill _every_
|
||||
session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell
|
||||
that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after.
|
||||
- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account
|
||||
@@ -258,7 +259,7 @@ Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `us
|
||||
below was **observed**, not reasoned about:
|
||||
|
||||
| attempted, as the member | result |
|
||||
| --- | --- |
|
||||
| ---------------------------------------- | ------------------------ |
|
||||
| write in own home | OK |
|
||||
| read `…/<email>/attachments/private.txt` | Permission denied |
|
||||
| `ls …/<email>/` (their own account dir) | Permission denied |
|
||||
@@ -275,11 +276,16 @@ Three bugs surfaced only by running it:
|
||||
2. **A member could read another member's home.** `provisionUserDirs` created directories at the default
|
||||
umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being
|
||||
unlistable is not protection when the child is world-readable and the attacker knows an email address.
|
||||
The skeleton is now created closed — `711` on the account directory, `700` inside — so *unconfined* is
|
||||
also *unreachable*.
|
||||
The skeleton is now created closed — `711` on the account directory, `700` inside — so _unconfined_ is
|
||||
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
|
||||
@@ -288,7 +294,7 @@ shell is. So:
|
||||
|
||||
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
|
||||
enforced by the platform;
|
||||
- the **terminal** cannot *read* anything above the home, but is not confined to it. Confining it would
|
||||
- the **terminal** cannot _read_ anything above the home, but is not confined to it. Confining it would
|
||||
mean a namespace or a chroot, which is a different and much larger feature.
|
||||
|
||||
Say "cannot see behind it", not "cannot leave it".
|
||||
@@ -300,9 +306,9 @@ themselves, able to have an agent do the same on their behalf. That needs two ke
|
||||
alternatives:
|
||||
|
||||
| | where | who holds the private half | what it is for |
|
||||
| --- | --- | --- | --- |
|
||||
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine |
|
||||
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | *the machine* authenticates to Gitea as them |
|
||||
| ------------ | ------------------------ | ---------------------------- | -------------------------------------------- |
|
||||
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | _they_ SSH into this machine |
|
||||
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | _the machine_ authenticates to Gitea as them |
|
||||
|
||||
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
|
||||
Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket
|
||||
@@ -311,14 +317,14 @@ inbound key is optional — an account without one is simply platform-only — a
|
||||
generated regardless.
|
||||
|
||||
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
|
||||
*password* login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
|
||||
_password_ login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
|
||||
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
|
||||
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
|
||||
Linux password would be a second door that changing the platform password does not close and deleting the
|
||||
platform account does not lock.
|
||||
|
||||
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
|
||||
pasted value containing a newline would silently install a *second* authorized key. `validatePublicKey`
|
||||
pasted value containing a newline would silently install a _second_ authorized key. `validatePublicKey`
|
||||
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
|
||||
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
|
||||
force a command.
|
||||
@@ -330,9 +336,9 @@ shell text, so nothing has to reason about quoting a value that came from a form
|
||||
|
||||
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
|
||||
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
|
||||
The failure this avoids is specific: the default setting makes a first connection *prompt*, and a prompt in
|
||||
The failure this avoids is specific: the default setting makes a first connection _prompt_, and a prompt in
|
||||
a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a
|
||||
*changed* host key, which is the attack that matters.
|
||||
_changed_ host key, which is the attack that matters.
|
||||
|
||||
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
|
||||
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
|
||||
@@ -441,5 +447,5 @@ Two consequences worth knowing:
|
||||
3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` +
|
||||
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
|
||||
security code.
|
||||
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` capability reopened.
|
||||
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` permission reopened.
|
||||
5. **Agents.** Separately, later, with the SDK problem solved first.
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
# The secret store
|
||||
|
||||
**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and
|
||||
`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built —
|
||||
the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts
|
||||
yet.
|
||||
|
||||
A small SQLite database holding every encryption and signing key the platform uses. It replaced
|
||||
`VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses instead of inventing
|
||||
its own.
|
||||
|
||||
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original
|
||||
plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`,
|
||||
`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one
|
||||
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and
|
||||
`headscale` — and every other purpose is created when its plugin first asks.
|
||||
|
||||
---
|
||||
|
||||
## What is wrong with today
|
||||
|
||||
Nothing is insecure. The separation is already right — the thing worth keeping is stated first so it is
|
||||
not lost in a refactor:
|
||||
|
||||
> **Secrets live in Postgres. The key that opens them does not.**
|
||||
|
||||
That is why `officer_db/src/crypto.ts` reads `VAULT_STORE_KEY` from the environment, and it is what
|
||||
makes `keys.ts:26` true: *"a stolen database dump is useless without .env, a stolen .env is useless
|
||||
without the passphrase"*.
|
||||
|
||||
What is wrong is narrower, and it is about **blast radius across processes**.
|
||||
|
||||
`.env` sits in the repository root, and Bun auto-loads it. `ecosystem.config.cjs` says so in as many
|
||||
words — it is the reason the Anthropic credential was moved out of the main process. So today
|
||||
`VAULT_STORE_KEY` is present in the environment of **all twenty pm2 processes**. `officer-music` holds
|
||||
the key that decrypts wallet seed envelopes. Anything that can read `/proc/<pid>/environ` for those
|
||||
processes has it, and nineteen of them have no reason to.
|
||||
|
||||
The second problem is that changing the key is currently unrecoverable rather than an operation. See
|
||||
[Rotation](#rotation).
|
||||
|
||||
---
|
||||
|
||||
## What the key actually protects
|
||||
|
||||
Worth listing, because it is wider than the name suggests. Everything below is AES-256-GCM ciphertext in
|
||||
Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`:
|
||||
|
||||
| column | what it is |
|
||||
| --- | --- |
|
||||
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" |
|
||||
| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
|
||||
| `jellyfin_servers.access_token` | Jellyfin session token |
|
||||
| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority |
|
||||
| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key |
|
||||
|
||||
`decryptSecret` throws when the key does not verify, so a wrong key is not a degraded mode — it is every
|
||||
one of those becoming unreadable at once.
|
||||
|
||||
The seed envelope is the only one protected by a second, independent secret (the owner passphrase, never
|
||||
persisted). Everything else in that table has exactly one lock.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. The store is SQLite, in the install, outside Postgres
|
||||
|
||||
Keys cannot live in the database they unlock. A dump would then contain both the ciphertext and the
|
||||
thing that opens it, and the property quoted at the top stops being true. Encrypting the key with a
|
||||
second key only moves the question — eventually exactly one secret has to be readable without any other
|
||||
secret, and the only real decision is *where it lives*.
|
||||
|
||||
SQLite rather than a flat file, for one reason that is not secrecy: **rotation needs key versions.** A
|
||||
rotation has to decrypt with the old key and re-encrypt with the new, and an interrupted rotation needs
|
||||
both to still exist. That is a table with `id, purpose, key, created_at, retired_at`, and it is awkward
|
||||
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
|
||||
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
|
||||
|
||||
### 2. It is NOT encrypted at rest — and that decision changed shape
|
||||
|
||||
**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking
|
||||
them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the
|
||||
other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user.
|
||||
|
||||
That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all.
|
||||
|
||||
The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher
|
||||
finding is still true and still the reason whole-file encryption is not on the table.
|
||||
|
||||
#### The original note
|
||||
|
||||
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
||||
|
||||
```
|
||||
$ bun --eval 'db.exec("PRAGMA key = \"supersecret\""); … insert …'
|
||||
read without key: THE-SECRET-VALUE
|
||||
strings enc.db | grep THE-SECRET-VALUE -> found
|
||||
```
|
||||
|
||||
Stock SQLite **silently ignores unknown pragmas**, so `PRAGMA key` succeeds, encrypts nothing, and the
|
||||
value sits in the file in plaintext. `bun:sqlite` ships stock SQLite 3.53.0, not SQLCipher.
|
||||
|
||||
Whole-file encryption therefore needs SQLCipher, which means a native module — and this project already
|
||||
knows what one of those costs, since node-pty has no Linux prebuild and compiles from source on every
|
||||
machine.
|
||||
|
||||
So the store holds **encrypted values in an unencrypted file**, the same shape as the Postgres columns.
|
||||
What leaks is metadata: which purposes have keys, and when they were rotated. That is an acceptable
|
||||
trade and it is written down here so nobody later assumes the file is opaque.
|
||||
|
||||
`[open]` SQLCipher, if the native-dependency cost ever becomes worth paying.
|
||||
|
||||
### 3. Where the file goes
|
||||
|
||||
**`$OFFICER_ROOT/secrets/officer-keys.db`** — a sibling of `platform/` and `data/`, decided 2026-08-13.
|
||||
|
||||
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
|
||||
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
|
||||
problem this design exists to avoid.
|
||||
|
||||
The setup script says so out loud when it creates the store, because "back this up, but not next to the
|
||||
other thing you back up" is not a rule anyone infers.
|
||||
|
||||
### 4. ~~One secret remains outside~~ — none does
|
||||
|
||||
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
|
||||
|
||||
The point of the exercise still holds, and it was always about blast radius rather than secrecy: **N
|
||||
secrets in twenty process environments becomes a file read on demand by the few processes that need
|
||||
it.** `.env` is auto-loaded by bun into every pm2 process, so a key there is readable from
|
||||
`/proc/<pid>/environ` of twenty processes — `officer-music` held the key that decrypts wallet seed
|
||||
envelopes. A file opened by the two or three processes that actually use a key does not.
|
||||
|
||||
### 5. What moves in
|
||||
|
||||
- `VAULT_STORE_KEY` — **split into one key per purpose**, rather than moved. See the status note at the
|
||||
top: the table above is seven unrelated things, and one key for all of them meant one leak opened all
|
||||
of them.
|
||||
- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must
|
||||
survive restarts, must never be regenerated silently, and benefits from versioning during a rotation.
|
||||
Leaving one in a store and one in `.env` would be the scattering this is meant to end.
|
||||
- **The anthropic proxy secret**, purpose `anthropic-proxy`. Agreed 2026-08-12. Neither an encryption
|
||||
key nor a signing key — a bearer credential, generated once by `ensureProxySecret` and presented by
|
||||
`officer-agent` to `officer-anthropic-proxy` on `127.0.0.1`. It qualifies on the same three
|
||||
properties: generated once, shared between two processes, fatal to regenerate silently.
|
||||
|
||||
It is in the store for a sharper reason than the other two, though. It is not in `.env` today — it
|
||||
is in `$DATA_PATH/sidecar/claude-state.json`, mixed in with session records. That is the one
|
||||
location [decision 3](#3-where-the-file-goes) rules out by name: `DATA_PATH` is what people back up,
|
||||
so the secret already travels in the same tarball as the data it protects.
|
||||
|
||||
**Naming.** It is called `ANTHROPIC_API_KEY` in `ensureAnthropicEnv`, and that name is wrong in both
|
||||
halves — it is not Anthropic's and it is not an API key. Anthropic's real credential is the OAuth
|
||||
token in `~/.claude/.credentials.json`, which the proxy swaps this one for on the way out. Our name
|
||||
for it is **anthropic-proxy-secret** everywhere we control.
|
||||
|
||||
The exception is the last line before the spawn. `claude` reads the variable `ANTHROPIC_API_KEY` and
|
||||
format-checks the `sk-ant-api03-` prefix, so both are the CLI's contract rather than ours and both
|
||||
stay. That one assignment keeps the CLI's name, with a comment saying why.
|
||||
|
||||
---
|
||||
|
||||
## Core, and plugins
|
||||
|
||||
The store is core infrastructure, created at first boot. It is **not** a side effect of installing any
|
||||
one sidecar — it exists on a machine that installs nothing, so that a plugin installed in six months
|
||||
finds it already there.
|
||||
|
||||
The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`,
|
||||
`officer-agent`, `officer-opencode`, `officer-pty` — **plus `officer-headscale`**.
|
||||
|
||||
Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet *is* the
|
||||
perimeter — origin checking was removed on 2026-08-13 precisely because the tailnet is what stands in
|
||||
its place, so the tailnet is now load-bearing rather than one layer of two. A security model
|
||||
that rests on the tailnet cannot treat administering the tailnet as an optional extra. Vaultwarden and
|
||||
the wallet are not load-bearing that way — nothing else stops working without them — so they become
|
||||
plugins.
|
||||
|
||||
Moving headscale into the light profile also removes it from the app store automatically:
|
||||
`catalogue.test.ts` asserts the catalogue equals `full − light`, so the test fails until the entry is
|
||||
deleted. That derivation is doing its job and should not be worked around.
|
||||
|
||||
Headscale is then the store's **first user**, not its creator — `headscale_servers.api_key` is the first
|
||||
core credential needing a key.
|
||||
|
||||
---
|
||||
|
||||
## The contract
|
||||
|
||||
What a plugin gets, and is bound by. To be written properly when the first one uses it; the shape is:
|
||||
|
||||
- **Ask for a key by purpose**, not by name. `getKey('vault')` returns the active key for that purpose,
|
||||
creating one on first use.
|
||||
- **Never hold it.** Read it at the point of use. A key cached in a long-lived process is the
|
||||
process-environment problem in a different container.
|
||||
- **Never write to another plugin's purpose.** Same rule `service_connections` already has for rows.
|
||||
- **Tolerate rotation.** A key may change between two calls. Anything that decrypts must be prepared to
|
||||
be handed the retired key for data written before a rotation.
|
||||
|
||||
---
|
||||
|
||||
## Rotation
|
||||
|
||||
The feature that makes the store worth building, and the reason versions exist.
|
||||
|
||||
Today, changing `VAULT_STORE_KEY` is not an operation — it is data loss. Every column above becomes
|
||||
unreadable, and for `wallets.seed_envelope` that is unrecoverable: the owner passphrase does not help,
|
||||
because it opens the inner envelope and the outer one is gone. Unless the mnemonic was written down
|
||||
offline, the coins are gone with it.
|
||||
|
||||
Rotation turns that into a supported action:
|
||||
|
||||
1. Mint a new key for the purpose, leaving the old one in the store as retired.
|
||||
2. For every ciphertext column belonging to that purpose: decrypt with the retired key, re-encrypt with
|
||||
the new one.
|
||||
3. Retire the old key only when every row has moved.
|
||||
|
||||
Two properties it must have, both learned from the failure it replaces:
|
||||
|
||||
- **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can
|
||||
tell which rows are which.
|
||||
- **Verify before writing.** Every row must decrypt with the retired key *before* anything is written.
|
||||
A key that is already wrong should fail loudly on row one rather than produce a second layer of
|
||||
unreadable data.
|
||||
|
||||
`[open]` Whether rotation is a UI action, a CLI command, or both. It is a long operation on a large
|
||||
wallet table and it cannot be interrupted safely, which argues for something that reports progress.
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
- Secrets stay in Postgres. This moves the **keys**, not the data.
|
||||
- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named
|
||||
at the call site, so it is `encryptSecret('headscale', plaintext)` now and all seven query modules
|
||||
were touched. That was the cost of the split, and it is worth stating plainly because this line
|
||||
originally promised the opposite.
|
||||
- The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent
|
||||
secrets is the property that makes a stolen `.env` insufficient, and it survives this design.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Where the file lives, given it must not be swept up by a backup of `data/`.
|
||||
2. Whether the store's own key stays in `.env` or moves to a file read on demand.
|
||||
3. Whether rotation is UI, CLI, or both — and how it reports progress on a table that takes minutes.
|
||||
4. SQLCipher, and whether whole-file encryption is ever worth a second native dependency.
|
||||
5. What happens to a plugin's keys when it is uninstalled. The app store already decided that
|
||||
uninstalling never deletes data; the same answer probably applies, but "probably" is not a decision.
|
||||
@@ -16,7 +16,7 @@ Three things are already true, which is why "nothing exactly blocks it":
|
||||
- **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment
|
||||
states it: features whose sidecars are absent report themselves unavailable rather than disappearing.
|
||||
So the app store never needs to mount or unmount routes.
|
||||
- **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by capability.
|
||||
- **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by permission.
|
||||
Installing one is starting a process, not teaching officer about it.
|
||||
- **`service_connections` already solves the multi-user case**, including the part nobody would get
|
||||
right independently — see below.
|
||||
@@ -88,7 +88,7 @@ health checks already correct, so "install Gitea" does not become a tutorial.
|
||||
platform/ the app
|
||||
data/ DATA_PATH
|
||||
dockers/ services the app store provisioned <- exclusively ours
|
||||
capabilities/ the file-based item store
|
||||
permissions/ the file-based item store
|
||||
```
|
||||
|
||||
`OFFICER_ROOT` is derived as the parent of `DATA_PATH` rather than configured separately — a second
|
||||
@@ -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.
|
||||
@@ -235,7 +239,7 @@ Two things it needs before third parties touch it:
|
||||
|
||||
What a plugin author is promised, and bound by. To be written properly; the shape is:
|
||||
|
||||
- **Register** by name + capabilities over `/api/sidecar/register`; be reachable by capability.
|
||||
- **Register** by name + permissions over `/api/sidecar/register`; be reachable by permission.
|
||||
- **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a
|
||||
schema.
|
||||
- **May reference** `users.id`, and use `service_connections` under its own ID.
|
||||
|
||||
@@ -32,7 +32,7 @@ Three consequences worth stating explicitly, because the audit turned on the thi
|
||||
`src/servers/api/slskd/` is **70 lines total** and does exactly the two things it should:
|
||||
|
||||
| File | Lines | Role |
|
||||
|---|---|---|
|
||||
| ------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `router.ts` | 51 | `all('/*')` catch-all. Forwards subpath + query + body, injects `X-Officer-User`, streams the response back. No routes of its own. |
|
||||
| `sidecar-server.ts` | 19 | Remembers the port the sidecar reports on connect (`slskd:server`). Nothing else. |
|
||||
|
||||
@@ -49,7 +49,7 @@ Today's three commits (`8032c8b`, `b7b91a2`, `dea9ee2`) touched **zero** platfor
|
||||
The `soulseek_*` tables live in the shared `officer_db` package (`schema/soulseek.ts`,
|
||||
`queries/soulseek.ts`) rather than in the sidecar. Only the sidecar reads them — this was a
|
||||
deliberate call (one database, schema isolated in its own file, `soulseek_` prefix) and it stands.
|
||||
The cost to remember: `bun db:push` diffs the *whole* schema, which is why soulseek DDL is
|
||||
The cost to remember: `bun db:push` diffs the _whole_ schema, which is why soulseek DDL is
|
||||
hand-applied.
|
||||
|
||||
## The smell: the frontend speaks slskd
|
||||
@@ -57,7 +57,7 @@ hand-applied.
|
||||
**37 raw `/slskd/api/v0/…` calls from React, against 10 `/slskd/_officer/…` calls.**
|
||||
|
||||
| File | Raw slskd calls |
|
||||
|---|---|
|
||||
| ----------------------- | --------------- |
|
||||
| `SoulseekTransfers.tsx` | 8 |
|
||||
| `SoulseekRooms.tsx` | 6 |
|
||||
| `SoulseekChat.tsx` | 5 |
|
||||
@@ -118,7 +118,7 @@ Under that line, the three files above are the work. The other six are a naming/
|
||||
`src/servers/sidecar/slskd/` — what "the sidecar owns its job" already looks like:
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `index.ts` | Reverse proxy to slskd on a random loopback port; documents the whole `/api/slskd/*` contract; reports its port to the platform. |
|
||||
| `upstream.ts` | The only holder of `SLSKD_URL` / `SLSKD_API_KEY`. |
|
||||
| `officer.ts` | The `/_officer/*` routes — favourites, browse snapshots, tree levels, filtered search, downloads. Features slskd has no concept of. |
|
||||
@@ -138,7 +138,7 @@ re-derived later.
|
||||
The eight sidecars, from `ecosystem.config.cjs`:
|
||||
|
||||
| PM2 process | Entry point |
|
||||
|---|---|
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| `officer-claude` | `src/servers/sidecar/claude/index.ts` |
|
||||
| `officer-opencode` | `src/servers/sidecar/opencode/index.ts` |
|
||||
| `officer-email` | `src/servers/sidecar/email/index.ts` |
|
||||
@@ -155,7 +155,7 @@ The eight sidecars, from `ecosystem.config.cjs`:
|
||||
entirely in the main process, with no sidecar owning any of it.
|
||||
|
||||
| Surface | Lines |
|
||||
|---|---|
|
||||
| ---------------------------------------------------------------------- | -------- |
|
||||
| Compliant proxy: `api/music/router.ts` + `api/music/sidecar-server.ts` | 88 |
|
||||
| `hono.ts` (3) + `protocol.ts` (1) | 4 |
|
||||
| `api/cliamp/websocket.ts` | 201 |
|
||||
@@ -171,20 +171,20 @@ entirely in the main process, with no sidecar owning any of it.
|
||||
`PULSE_SINK: 'virtual_out'` and `ALSA_CONFIG_PATH` injected. Pumps stdout/stderr into JSON frames
|
||||
(`:124-160`), forwards `{type:'input'}` to stdin (`:173-185`), kills the child on close (`:187-198`).
|
||||
Child processes are held in a module-level `Map` (`:22`).
|
||||
*Belongs in* `sidecar/music/`, which already runs its own loopback HTTP server
|
||||
(`sidecar/music/index.ts:140`). *Obstacle:* a browser-held WebSocket with bidirectional keystroke
|
||||
_Belongs in_ `sidecar/music/`, which already runs its own loopback HTTP server
|
||||
(`sidecar/music/index.ts:140`). _Obstacle:_ a browser-held WebSocket with bidirectional keystroke
|
||||
traffic — but the relay pattern already exists twice (`server.tsx:164-228` for dev-server,
|
||||
`server.tsx:323-326` for vault).
|
||||
2. **PulseAudio host-daemon bootstrap** — `server.tsx:391-436`. A startup IIFE that locates
|
||||
`pulseaudio`/`pactl`, runs `pulseaudio --start -D` if the daemon is down (`:401-411`), then greps
|
||||
`pactl list short sinks` and loads `module-null-sink sink_name=virtual_out` if absent (`:414-435`).
|
||||
Runs unconditionally at every boot even if nobody opens the player.
|
||||
*Belongs in* the music sidecar's startup. *Obstacle:* none technical — same host, `pactl` works
|
||||
_Belongs in_ the music sidecar's startup. _Obstacle:_ none technical — same host, `pactl` works
|
||||
identically. Must move together with (1) and (3), since the sink must exist before they start.
|
||||
3. **Host audio capture → browser PCM** — `api/cliamp/audio-ws.ts:1-91`. Spawns
|
||||
`parec --format=s16le --rate=44100 --channels=2 -d virtual_out.monitor` (`:28-33`) and pushes each
|
||||
chunk to the browser as a binary frame (`:44-73`). Hardcoded format, sample rate, channel count and
|
||||
monitor device name — pipeline domain knowledge. *Obstacle:* continuous binary PCM, so a relay hop
|
||||
monitor device name — pipeline domain knowledge. _Obstacle:_ continuous binary PCM, so a relay hop
|
||||
costs a copy per chunk.
|
||||
4. **ALSA config shipped inside the API tree** — `api/cliamp/asoundrc:1-9`, passed via
|
||||
`ALSA_CONFIG_PATH` (`websocket.ts:8`, `:112`). Upstream config in the thin-proxy process. Moves for
|
||||
@@ -232,7 +232,7 @@ The transport proxy is right; the platform owns the entire Vaultwarden **auth/se
|
||||
domain. This is the worst offender of the eight, and the one where placement has real consequences.
|
||||
|
||||
| Surface | Lines |
|
||||
|---|---|
|
||||
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
|
||||
| `api/vault/router.ts` | 169 |
|
||||
| `api/vault/websocket.ts` | 164 |
|
||||
| `api/vault/broker.ts` | 79 |
|
||||
@@ -249,7 +249,8 @@ For scale: the sidecar itself is 295 lines and is a genuine dumb pass-through
|
||||
`VAULTWARDEN_URL`).
|
||||
|
||||
Two structural notes before the findings:
|
||||
- `api/vault/router.ts:123` *is* an `all('/*')` catch-all, but it is not thin — it **replaces** the
|
||||
|
||||
- `api/vault/router.ts:123` _is_ an `all('/*')` catch-all, but it is not thin — it **replaces** the
|
||||
`Authorization` header with a platform-held upstream credential (`:137-141`) and implements
|
||||
401-refresh-retry (`:158-165`).
|
||||
- It does **not** inject `X-Officer-User` (contrast `api/slskd/router.ts:34`,
|
||||
@@ -274,10 +275,10 @@ Two structural notes before the findings:
|
||||
every proxied request.
|
||||
3. **Notifications WebSocket proxied twice, with token injection** — `api/vault/websocket.ts:1-164`.
|
||||
`injectToken` (`:45-52`) rewrites the SignalR query string: drops `token`, sets `access_token=<vw
|
||||
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
||||
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
||||
`ws://127.0.0.1:<sidecarPort>` and runs a full buffered bidirectional pipe — **which the sidecar
|
||||
already implements** (`sidecar/vault/index.ts:45-114`, `:143-151`). Frames are relayed twice.
|
||||
*Obstacle:* Bun requires a synchronous upgrade, hence the deferred validation at `:60-70`; that
|
||||
_Obstacle:_ Bun requires a synchronous upgrade, hence the deferred validation at `:60-70`; that
|
||||
pattern stays, the token lookup at `:73` should not.
|
||||
4. **The platform is the vault's key escrow** — `api/vault/router.ts:107-120`. `PUT /unlock-key`
|
||||
persists a `wrappedKey` (`:111`); `GET /unlock-key` hands it back to any owner session (`:117-119`).
|
||||
@@ -290,7 +291,7 @@ Two structural notes before the findings:
|
||||
`queries/vault.ts:22-23,34-35,67-68,83,88`. Derives an AES-256-GCM key as
|
||||
`SHA-256(VAULT_STORE_KEY)` (`:12-20`) and runs `createCipheriv`/`createDecipheriv` (`:23-39`).
|
||||
Because the vault router imports `officerdb` (`router.ts:10`, `token-store.ts:1`), all of this runs
|
||||
inside `officer`. *Obstacle:* `crypto.ts` lives in the shared package, so it is importable from
|
||||
inside `officer`. _Obstacle:_ `crypto.ts` lives in the shared package, so it is importable from
|
||||
anywhere; moving it means moving the vault queries out of the shared package or enforcing a
|
||||
sidecar-only import boundary. No config obstacle — both processes read the same `.env`.
|
||||
6. **Vault tables are read/written by the platform, not the sidecar** — `queries/vault.ts:18-101`,
|
||||
@@ -299,7 +300,7 @@ Two structural notes before the findings:
|
||||
imports **no** DB module at all. Exact inverse of the intended ownership.
|
||||
7. **Auth flows reach into vault storage directly** — `api/auth/signout.ts:10`,
|
||||
`revoke-handler.ts:18-19`, `panic-handler.ts:15-16`. Signout deletes the token row; distress and
|
||||
panic also burn the protector key. The *policy* is platform-level; the *mechanism* — direct DELETEs
|
||||
panic also burn the protector key. The _policy_ is platform-level; the _mechanism_ — direct DELETEs
|
||||
against the sidecar's tables — is not. All three are already best-effort `.catch(() => {})`, so
|
||||
failure semantics wouldn't worsen behind a sidecar call.
|
||||
8. **Dead weight** — `router.ts:38-48` is a hand-written `GET /_health` passthrough the catch-all
|
||||
@@ -309,13 +310,13 @@ Two structural notes before the findings:
|
||||
strings — fix (2) and it's unnecessary.
|
||||
9. **Mounted outside the protected tree** — `hono.ts:73-77`. `route('/api/vault', …)` sits outside
|
||||
`protectedRouter`, so the router re-implements its own stack (`router.ts:31-33`: `originMiddleware`,
|
||||
`userMiddleware`, `ownerGate`). *Real constraint, probably why:* it deliberately avoids
|
||||
`userMiddleware`, `ownerGate`). _Real constraint, probably why:_ it deliberately avoids
|
||||
`bodyParser()` so bodies stream (`router.ts:14`), and `protectedRouter` would inherit it from
|
||||
`hono.ts:88` and buffer vault attachments.
|
||||
10. **Stale comments on a security boundary** — `hono.ts:73-76` and
|
||||
`origin-validation.ts:36-39,61-64` both claim vault requests "carry their own Bitwarden bearer
|
||||
token, not a platform session JWT" and that `userMiddleware` would 401 them. Untrue since
|
||||
`router.ts:32-33` requires a valid platform JWT *and* owner status on every request. Also,
|
||||
`router.ts:32-33` requires a valid platform JWT _and_ owner status on every request. Also,
|
||||
`VAULT_AUTH_SPEC.md` (cited at `router.ts:12`, `schema/vault.ts:4`) and
|
||||
`BITWARDEN_SIDECAR_PROMPT.md` (cited at `sidecar/vault/upstream.ts:3`) **do not exist** in the repo.
|
||||
Not logic, but exactly the drift that makes someone loosen a gate by mistake.
|
||||
@@ -327,7 +328,7 @@ every `officerdb` vault export across `src/servers` and `src/databases`; the onl
|
||||
User-key derivation is genuinely client-side (the platform only relays `Kdf*` params at
|
||||
`router.ts:96-101`) — the one credential decision that is correctly placed.
|
||||
|
||||
*Shortest path to compliance (inferred, not attempted):* move `broker.ts`, `token-store.ts`,
|
||||
_Shortest path to compliance (inferred, not attempted):_ move `broker.ts`, `token-store.ts`,
|
||||
`/session/login`, `/unlock-key`, the vault queries and `crypto.ts` into `sidecar/vault/`; have the
|
||||
router inject `X-Officer-User` instead of `Authorization`; reduce `websocket.ts` to
|
||||
origin-check + verify + upgrade + dumb pipe; delete `/_health` and `proxy-util.ts`; replace the three
|
||||
@@ -342,7 +343,7 @@ The largest violation after email, and the one with the worst consequences, beca
|
||||
for survivability. They overlap deliberately.
|
||||
|
||||
There is **no `/api/claude` mount, no proxy router, and no `X-Officer-User` anywhere on this path.**
|
||||
Nothing here is shaped like slskd. The platform does not forward to the claude sidecar; it *drives* it,
|
||||
Nothing here is shaped like slskd. The platform does not forward to the claude sidecar; it _drives_ it,
|
||||
over a typed RPC vocabulary, and interprets everything that comes back.
|
||||
|
||||
A structural fact worth stating before the list, because it inverts the usual reading: **the sidecar
|
||||
@@ -363,7 +364,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
the machinery that makes a restart lossy — it is the durable writer, and it sits on the far side
|
||||
of the socket from the process producing the events.
|
||||
3. **`api/chat/claude-sessions.ts:1-361` — a reimplementation of Claude's transcript format.** The
|
||||
platform reads and *writes* `~/.claude/projects/<slug>/<uuid>.jsonl` directly: the slug encoding
|
||||
platform reads and _writes_ `~/.claude/projects/<slug>/<uuid>.jsonl` directly: the slug encoding
|
||||
(`:39`), the entry schema (`:69-78`), content-block decoding (`:143-227`), listing (`:350-361`),
|
||||
delete-by-unlink (`:253-258`), a 32KB `readSync` plus a `"cwd":"…"` regex to recover a session's
|
||||
directory (`:294-306`), and — the sharpest example — **rename implemented by appending a
|
||||
@@ -378,7 +379,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
`spawnAndWaitForRegistration`: a per-email `Bun.spawn` of `user-instance.ts` with
|
||||
`stdout: 'inherit', stderr: 'inherit'` (`:240-241`), a `claudeProcs` Map, a `claudeSpawnWaiters`
|
||||
Map, a 15s timeout and a 50ms registration poll (`:259-267`). Plus the claude verbs at `:306-358`
|
||||
and a broadcast fallback at `:339-346`. `:88-90` uses `capabilities.includes('proxy')` as a
|
||||
and a broadcast fallback at `:339-346`. `:88-90` uses `permissions.includes('proxy')` as a
|
||||
stand-in for "is this the claude sidecar", which is only true by accident of naming.
|
||||
6. **`generate-container-context.ts:135-182` (+ `:50-133`) — the platform writes the CLI's config.**
|
||||
It authors `~/.claude/settings.json`: a `Stop` hook curling
|
||||
@@ -387,7 +388,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
(`:163-179`). Called from `users/provision.ts:28-38`. Two notes: the hook points at the platform,
|
||||
so it fails during exactly the restart window that matters; and the permission posture is a
|
||||
deliberate documented choice (`platform/CLAUDE.md`: agents run unsandboxed as the owner) that is
|
||||
being *implemented in the wrong process*, not a mistake.
|
||||
being _implemented in the wrong process_, not a mistake.
|
||||
7. **`api/activity/router.ts:1-191` — the platform walks the agent's scratch tree.** Reads
|
||||
`/tmp/claude-<uid>/<encoded-cwd>/tasks/<id>.output` (`:24-61`, keyed on
|
||||
`startsWith('claude-')` at `:34`) and tails it over SSE (`:116-191`). Another private layout the
|
||||
@@ -419,7 +420,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
`sk-ant-api03-<uuid>` keys (`:10`) — the live copy is `sidecar/claude/proxy.ts:135`. A stale second
|
||||
implementation of the credential path is worth deleting on security grounds alone, not just tidiness.
|
||||
|
||||
*What the sidecar already has right:* the Anthropic proxy genuinely lives in the PM2-managed sidecar
|
||||
_What the sidecar already has right:_ the Anthropic proxy genuinely lives in the PM2-managed sidecar
|
||||
(`sidecar/claude/index.ts:20`), so the platform never holds an API key at rest, and `ANTHROPIC_BASE_URL`
|
||||
points at the sidecar (`sidecar-registry.ts:234`). The credential path is roughly correct. It is the
|
||||
process topology, the transport direction and the domain logic that are not.
|
||||
@@ -428,7 +429,7 @@ process topology, the transport direction and the domain logic that are not.
|
||||
|
||||
The worst of the eight by volume, and the only one where the arrow points backwards end to end:
|
||||
**≈3,238 platform lines** (2,875 of them in seven files) against a **314-line sidecar** — and the
|
||||
sidecar *imports platform code back out* (`sidecar/email/email-idle.ts:3` imports
|
||||
sidecar _imports platform code back out_ (`sidecar/email/email-idle.ts:3` imports
|
||||
`../../api/email/resync`). There is no proxy router, no `email:server` port event, and no forwarding of
|
||||
any kind. `emailRouter` implements 18 concrete endpoints itself.
|
||||
|
||||
@@ -460,7 +461,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
|
||||
work.** `gmailResync` (`:43-63`), `imapResync` (`:148-264`), `resolveImapAuth` (`:118-146`),
|
||||
`refreshCredentials` (`:21-41`), and `performResync` (`:276-284`) which coalesces concurrent
|
||||
resyncs through an **in-process Map**. It is imported by both `sidecar/email/email-cron.ts:2` and
|
||||
`email-idle.ts:3` *and* by `accounts.ts:159` — i.e. by two different processes. Each gets its own
|
||||
`email-idle.ts:3` _and_ by `accounts.ts:159` — i.e. by two different processes. Each gets its own
|
||||
copy of the Map, so the coalescing silently does nothing across the boundary. This is what
|
||||
"importing platform code back out" costs.
|
||||
6. **`api/email/accounts.ts:1-259` — account setup does live IMAP.** Validation by real connection on
|
||||
@@ -486,7 +487,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
|
||||
12. **`src/servers/sidecar/email-cron.ts` — 92 dead lines**, imported by nothing (the live one is
|
||||
`sidecar/email/email-cron.ts`).
|
||||
|
||||
*Shortest path (inferred):* this one is a rewrite, not a move. The realistic first step is not
|
||||
_Shortest path (inferred):_ this one is a rewrite, not a move. The realistic first step is not
|
||||
relocating `email-db.ts` — it is deleting the duplicate clients (items 7 and 8) and moving the two
|
||||
queue handlers (items 2 and 3) into the sidecar so sync stops dying with `officer`. The store itself
|
||||
can follow later, behind a proxy router.
|
||||
@@ -521,7 +522,7 @@ already does the same job. That makes this the cheapest of the non-compliant sur
|
||||
6. **`channels/send-opencode.ts:29-66` — the terminal-event set (`:33-37`) and a resume policy keyed
|
||||
on the `ses_` id prefix (`:42-45`).** Protocol knowledge encoded as a string prefix, in the
|
||||
platform.
|
||||
7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents capability
|
||||
7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents permission
|
||||
metadata for the results (`:42-45`).**
|
||||
8. **`api/chat/chat.ts:13-19, 44, 56-57, 68, 80-81, 91` — CRUD dispatch on `isOpenCodeSessionId`**
|
||||
(`opencode-sessions.ts:112`, a `startsWith('ses_')` test).
|
||||
@@ -573,11 +574,11 @@ already exists in the same codebase.
|
||||
backoff table (`:37` vs `connect.ts:22`). Its types are JSDoc (`:39`), so `protocol.ts:145-156` is
|
||||
unenforced against it. The actual blocker to moving it is mundane: sibling `templates/` files on
|
||||
disk (`:27-29, 61, 66, 71` — `.zshrc`, `.tmux.conf`, `starship-officer.toml`, and an unused
|
||||
`.zshenv`). So a `git mv`, not a rewrite. *(Inferred: the `.mjs`/node choice is probably a
|
||||
node-pty native-addon workaround — corroborated by the comment at `api/cliamp/websocket.ts:100`.)*
|
||||
`.zshenv`). So a `git mv`, not a rewrite. _(Inferred: the `.mjs`/node choice is probably a
|
||||
node-pty native-addon workaround — corroborated by the comment at `api/cliamp/websocket.ts:100`.)_
|
||||
3. **Every PTY byte transits the main process, double-JSON-encoded.** Plus terminal-specific query
|
||||
parsing in the shared upgrade handler (`server.tsx:248-252`, `WSData:51-52`) and wiring at `:7, 38,
|
||||
143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as
|
||||
143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as
|
||||
`userLabel` / `sessionId` (`websocket.ts:56, 65`) instead of as `X-Officer-User`.
|
||||
4. **`sidecar-registry.ts:395-407` plus PTY types threaded through generic plumbing** at
|
||||
`:12-13, 35, 93, 125, 153, 171, 180, 194`. One subtlety to preserve: the 30s
|
||||
@@ -588,7 +589,7 @@ already exists in the same codebase.
|
||||
than `getOwnerHomeDir` (`data-path.ts:34`), unlike the eight other host-executing surfaces. Same
|
||||
result on this machine (`HOME_DIR` is set and equals `HOME`), divergent anywhere it isn't.
|
||||
|
||||
*Shortest path (inferred):* `git mv` the sidecar into `src/servers/sidecar/pty/` with its templates,
|
||||
_Shortest path (inferred):_ `git mv` the sidecar into `src/servers/sidecar/pty/` with its templates,
|
||||
switch it to `connect.ts`, move the `PtyInitConfig` construction and cwd resolution into it, and
|
||||
replace `websocket.ts` with the `devServerWebsocket` relay shape. The detach-on-disconnect policy moves
|
||||
with it.
|
||||
@@ -625,7 +626,7 @@ pile of leaked logic.
|
||||
7. Wiring at `server.tsx:13, 45, 149, 234, 339` is fine, and **`hono.ts:37, 122` is already
|
||||
reference-shaped** (two lines).
|
||||
|
||||
*Adjacent, and its own domain rather than a vnc violation:* the browser relay —
|
||||
_Adjacent, and its own domain rather than a vnc violation:_ the browser relay —
|
||||
`server.tsx:369, 371` plus `api/browser/relay.ts` (677 lines), `api/browser/router.ts` (198, including
|
||||
`Bun.spawn(['zip', …])` at `:26-30`), `cdp.ts` (99) and `relay-auth.ts` (42); and
|
||||
`api/scrape/scrape.ts:9-19, 49+` launches chromium in-process. Noted for a future pass; not counted
|
||||
@@ -641,7 +642,7 @@ eight times.
|
||||
Sorted by how far each is from the reference. This is the whole audit in one view:
|
||||
|
||||
| sidecar | platform lines | verdict |
|
||||
|---|---:|---|
|
||||
| -------- | -------------: | ---------------------------------------------------- |
|
||||
| slskd | 70 | ✅ reference |
|
||||
| music | 88 | ✅ compliant (the cliamp subsystem beside it is not) |
|
||||
| pty | 169 | ✗ ~all of it is sidecar logic |
|
||||
@@ -652,7 +653,7 @@ Sorted by how far each is from the reference. This is the whole audit in one vie
|
||||
| email | ~2,875 | ✗ no proxy exists at all |
|
||||
|
||||
`hono.ts` mounts **36 routers. Three are thin sidecar proxies** — `:106` (music), `:107` (slskd), and
|
||||
`:77` (vault, mounted *outside* `protectedRouter`).
|
||||
`:77` (vault, mounted _outside_ `protectedRouter`).
|
||||
|
||||
For contrast, sidecar-side LOC: music 1,630 · claude 1,523 · slskd 653 · opencode 427 · vnc 326 ·
|
||||
email 314 · vault 295. Note the inversion on email: 314 sidecar lines to 2,875 platform lines.
|
||||
@@ -664,7 +665,7 @@ file-browser 1,465 · server-settings 1,452 · browser 1,105 · auth 607 · syst
|
||||
### 2. The protocol is not a transport
|
||||
|
||||
`sidecar/protocol.ts` is a **closed union of ~34 message types: 7 transport, 25+ domain.** Every new
|
||||
sidecar capability requires editing a shared platform file — which is why domain knowledge keeps
|
||||
sidecar permission requires editing a shared platform file — which is why domain knowledge keeps
|
||||
landing there (CLI flags, `display`/`pid`, `proxySecret`, spawn params).
|
||||
|
||||
Two specific consequences:
|
||||
@@ -675,7 +676,7 @@ Two specific consequences:
|
||||
string test (`server.tsx:80`, `sidecar/email/index.ts:31, 38`). So the "closed" union is already
|
||||
being bypassed where it was inconvenient — evidence that the closed shape is the wrong shape.
|
||||
|
||||
By contrast `registration-protocol.ts` (16 lines: `name` + `capabilities: string[]`) is genuinely
|
||||
By contrast `registration-protocol.ts` (16 lines: `name` + `permissions: string[]`) is genuinely
|
||||
generic. The registration handshake got this right; the command channel did not.
|
||||
|
||||
### 3. Ten WebSocket providers, and only three are tunnels
|
||||
@@ -708,10 +709,10 @@ is item 2 of the email section arriving from a different direction.
|
||||
### 5. Registry bugs that will bite during any migration
|
||||
|
||||
- **`unregisterSidecar` (`sidecar-registry.ts:80-85`) rejects the entire global pending-command map
|
||||
when *any single* sidecar disconnects.** So restarting `officer-music` fails in-flight claude, pty and
|
||||
when _any single_ sidecar disconnects.** So restarting `officer-music` fails in-flight claude, pty and
|
||||
vault commands. This will look like random unrelated breakage the moment sidecars restart
|
||||
independently — which is the entire goal.
|
||||
- **`:88-90` treats `capabilities.includes('proxy')` as "is this claude"** — true only by accident of
|
||||
- **`:88-90` treats `permissions.includes('proxy')` as "is this claude"** — true only by accident of
|
||||
the naming confusion documented in `CLAUDE_SIDECAR_ISOLATION.md`.
|
||||
|
||||
### 6. What the database says (the clearest signal in the audit)
|
||||
@@ -726,8 +727,8 @@ Table ownership tracks compliance exactly:
|
||||
- **`queries/email-accounts.ts` — split**, with `api/chat/websocket.ts:11, 61-62` reaching across
|
||||
domains into it.
|
||||
|
||||
**A useful rule falls out of this:** *if a table is read by exactly one sidecar and nothing else, that
|
||||
sidecar is probably compliant. If the platform reads it, the platform probably owns logic it shouldn't.*
|
||||
**A useful rule falls out of this:** _if a table is read by exactly one sidecar and nothing else, that
|
||||
sidecar is probably compliant. If the platform reads it, the platform probably owns logic it shouldn't._
|
||||
Cheaper to check than reading 3,000 lines.
|
||||
|
||||
---
|
||||
@@ -739,7 +740,7 @@ mirroring the slskd findings at the top of this document.
|
||||
|
||||
Same rule, applied one layer out. The question here is not "what logic runs in `officer`" but **"does
|
||||
the browser know things only the sidecar should know?"** — upstream URL shapes, wire formats, session-id
|
||||
conventions, retry and reconnect policy, capability catalogues.
|
||||
conventions, retry and reconnect policy, permission catalogues.
|
||||
|
||||
The slskd case at the top of this document is the template: **37 raw `/slskd/api/v0/…` calls against 10
|
||||
`/slskd/_officer/…` calls**, meaning the browser is a second client of the upstream API rather than a
|
||||
@@ -785,7 +786,7 @@ disconnected UI — a red "Disconnected" indicator (`ChatDetailPanel.tsx:38-52`)
|
||||
(`InputArea.tsx:84`), model switching locked (`ModelSelector.tsx:67`).
|
||||
|
||||
**So `seq` + `resume-cursor` already exist end to end.** Pass 1 found the matching backend half at
|
||||
`chat/websocket.ts:612-629` (`getChatEventsSince`). The protocol is not missing; the *writer* is simply
|
||||
`chat/websocket.ts:612-629` (`getChatEventsSince`). The protocol is not missing; the _writer_ is simply
|
||||
on the wrong side of the socket. That makes the durability stage of `CLAUDE_SIDECAR_ISOLATION.md`
|
||||
substantially smaller than I estimated — a relocation, not a new mechanism.
|
||||
|
||||
@@ -807,16 +808,16 @@ check when the writer moves.
|
||||
string, in the task runner. This one silently goes stale.
|
||||
3. **The CLI invocation string is in the browser.** `apps/Terminal/index.tsx:32-33` —
|
||||
`command="claude --dangerously-skip-permissions"`, `statePrefix="claude-code"`. The browser decides
|
||||
how the agent binary is invoked, including its permission flag. *(The unsandboxed posture is
|
||||
how the agent binary is invoked, including its permission flag. _(The unsandboxed posture is
|
||||
deliberate per `platform/CLAUDE.md`; the objection is only to where the decision lives — the
|
||||
browser is the furthest possible place from the sidecar that owns it.)*
|
||||
4. **Capability metadata crosses to the client.** `Chat/types.ts:11-19` types `contextWindow`,
|
||||
browser is the furthest possible place from the sidecar that owns it.)_
|
||||
4. **Permission metadata crosses to the client.** `Chat/types.ts:11-19` types `contextWindow`,
|
||||
`maxTokens` and `reasoning?`, and `ModelSelector.tsx:116` branches the UI on `reasoning`. The
|
||||
browser doesn't compute these, so this is acceptable *if* they come from the sidecar — but Pass 1
|
||||
browser doesn't compute these, so this is acceptable _if_ they come from the sidecar — but Pass 1
|
||||
found them hardcoded in the platform at `api/chat/list-models.ts:5-9`, so today the numbers
|
||||
originate two layers away from the thing they describe.
|
||||
5. **Claude CLI session conventions are documented in the browser.**
|
||||
`state/src/useClaudeSessions.ts:8-9` comments that the id *is* the transcript filename;
|
||||
`state/src/useClaudeSessions.ts:8-9` comments that the id _is_ the transcript filename;
|
||||
`SessionList.tsx:10-11` explains that clicking a session continues it "via --resume"; `:15` types
|
||||
`harness?: 'claude' | 'opencode'`. And the magic string **`'general_chat_sessions'`** — Claude's
|
||||
own default directory bucket — appears as a literal in `PwdSelector.tsx:7, 22, 62`,
|
||||
@@ -851,7 +852,7 @@ check when the writer moves.
|
||||
## email — routes are compliant, payloads and realtime are not
|
||||
|
||||
The mirror image of chat: **every one of the 20 API paths is Officer-shaped** — there is no
|
||||
`/imap/uid/…` anywhere — but the request *bodies* carry IMAP configuration, the compose path builds
|
||||
`/imap/uid/…` anywhere — but the request _bodies_ carry IMAP configuration, the compose path builds
|
||||
MIME, and the realtime channel cannot recover from a restart at all.
|
||||
|
||||
There is no windowed panel app; the email UI is screen-level under
|
||||
@@ -884,9 +885,10 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
- `GoogleOAuthConfig.tsx:197-210` — `GET /integrations/google/config` returns `clientSecret` in
|
||||
plaintext; held in `useState` (`:184`), shown at `:255-262`.
|
||||
|
||||
Neither is a mail credential *the sidecar owns*, and both are the owner's own secrets on the
|
||||
Neither is a mail credential _the sidecar owns_, and both are the owner's own secrets on the
|
||||
owner's own machine — but "GET returns the secret so the form can prefill" is the pattern worth
|
||||
changing, since a write-only field would work identically.
|
||||
|
||||
5. **The session bearer token is passed in a URL.** `EmailList.tsx:110-112` builds
|
||||
`new EventSource('/api/email/events?token=' + …)` from `localStorage`. Unavoidable for `EventSource`
|
||||
(it can't set headers), but it puts the JWT into browser history and any proxy access log. Worth
|
||||
@@ -895,7 +897,7 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
`EmailList.tsx:109-125` opens the SSE stream, expects `{ type: 'new-mail' }`, invalidates three
|
||||
query keys, and closes on unmount. There is **no `es.onerror`, no backoff, no reconnect, and no
|
||||
`Last-Event-ID` handling.** And the server never sends an `id:` field — Pass 1's
|
||||
`api/email/email.ts:101` emits only `data: {"type":"new-mail"}` — so even the browser's *native*
|
||||
`api/email/email.ts:101` emits only `data: {"type":"new-mail"}` — so even the browser's _native_
|
||||
`EventSource` retry cannot request replay. Any `new-mail` event emitted during a restart is lost
|
||||
silently until the next event arrives or the user hits Sync manually (`:134-154`).
|
||||
**Direct contrast with chat, in the same codebase: one channel has cursor-based replay, the other
|
||||
@@ -925,18 +927,18 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
- **No charset, quoted-printable, base64 or RFC-2047 decoding in the browser** — it receives decoded
|
||||
`text`/`html`/`snippet`. Reading is compliant; only composing leaks.
|
||||
- **No Gmail label ids and no Gmail query syntax constructed client-side.** The search box passes `q=`
|
||||
through untouched (`EmailList.tsx:83-85`); `:309`'s placeholder only *hints* at the syntax.
|
||||
through untouched (`EmailList.tsx:83-85`); `:309`'s placeholder only _hints_ at the syntax.
|
||||
- **Mail credentials are write-only.** The password is POSTed at `EmailAccounts.tsx:132` and never read
|
||||
back — `GET /email/accounts` returns no credential field. OAuth tokens never reach the browser at
|
||||
all: `:88-107` either redirects the page to `/api/integrations/google/authorize` or POSTs
|
||||
`credentials: { userIntegrationId: true }`, a boolean. This is the right shape, and it is worth
|
||||
noting that the *account* credential path is stricter than the *settings* ones in item 4.
|
||||
noting that the _account_ credential path is stricter than the _settings_ ones in item 4.
|
||||
- **No Message-Id handling** — and `Compose.tsx:382-384` documents the absence, noting `m.id` is a local
|
||||
hash and that threading currently leans on `Re:` + participants.
|
||||
|
||||
## opencode — the most compliant frontend of the eight
|
||||
|
||||
Genuinely surprising given Pass 1 found ≈792 non-compliant *backend* lines. **The string `opencode`
|
||||
Genuinely surprising given Pass 1 found ≈792 non-compliant _backend_ lines. **The string `opencode`
|
||||
appears in exactly four frontend files, and only one of those is logic.** Everything the backend leaks
|
||||
— the `ses_` prefix, the `opencode/<modelID>` id shape, `metadata.officer`, `auth.json`,
|
||||
`models.json`, the version pin — stops at the server. Verified by exhaustive grep: **zero frontend hits
|
||||
@@ -971,7 +973,7 @@ it just always sends `cwd`.
|
||||
(`value.slice(0,3) + '...' + value.slice(-3)`), and the browser uses the result only as a
|
||||
placeholder (`AIHarnessesSection.tsx:467`). A freshly typed key lives transiently in
|
||||
`keyInputs` state (`:74`) and is **deleted after the PUT** (`:121-125`). Never in `localStorage`,
|
||||
`sessionStorage`, or the query cache. Local-provider config returns the auth *type* only, never key
|
||||
`sessionStorage`, or the query cache. Local-provider config returns the auth _type_ only, never key
|
||||
material. **This is the pattern the email settings surface (Pass 2, email item 4) should copy.**
|
||||
5. **No hardcoded model catalogue.** `state/src/useModels.ts:30-51` fetches everything from
|
||||
`/chat/models`. The only hardcoded data is display-name maps — `ModelSelector.tsx:7-23` (14 pairs)
|
||||
@@ -987,16 +989,16 @@ it just always sends `cwd`.
|
||||
(routed at `App.tsx:38-40`), `useClaudeSessions.ts`, `useEmbeddableChat.ts`. All reachable.
|
||||
|
||||
**One thing the frontend displays that isn't real, and the cause is in the backend.**
|
||||
`api/chat/list-models.ts:24` stubs *every* opencode-routed model with constant metadata —
|
||||
`api/chat/list-models.ts:24` stubs _every_ opencode-routed model with constant metadata —
|
||||
`contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true`, with the comment "metadata is
|
||||
left at neutral defaults for now". The browser faithfully renders these (`ModelSelector.tsx:116`
|
||||
branches the thinking toggle on `reasoning`). So the capability numbers shown to the user for opencode
|
||||
branches the thinking toggle on `reasoning`). So the permission numbers shown to the user for opencode
|
||||
models are placeholders, and `reasoning: false` will suppress the thinking toggle for models that do
|
||||
support it. A backend defect, surfaced by a compliant frontend.
|
||||
|
||||
## terminal / pty — the browser reconnects, and then loses the session anyway
|
||||
|
||||
The mirror of the backend result. Pass 1 called pty the least compliant *backend* surface; the frontend
|
||||
The mirror of the backend result. Pass 1 called pty the least compliant _backend_ surface; the frontend
|
||||
is mostly well-behaved, has real reconnect logic, and yet contains **one bug that defeats the entire
|
||||
detach-not-kill design.**
|
||||
|
||||
@@ -1056,9 +1058,9 @@ between `pty-sidecar.mjs:37` and `connect.ts:22` (Pass 1). Four backoff policies
|
||||
sidecar keeps a capped 50KB buffer (`pty-sidecar.mjs:36`, `BUFFER_MAX`) and re-emits it on re-init
|
||||
(`:99-101`); the bridge forwards it as an ordinary `output` frame
|
||||
(`api/terminal/websocket.ts:71-79`), and `Terminal.tsx:181` `term.write()`s it indistinguishably from
|
||||
live output. No dedup, no historical marker. It works, passively. *(INFERRED: survival across a hard
|
||||
live output. No dedup, no historical marker. It works, passively. _(INFERRED: survival across a hard
|
||||
page reload depends on React cleanup not running during navigation teardown — standard behaviour, but
|
||||
not verified against `pagehide` here.)*
|
||||
not verified against `pagehide` here.)_
|
||||
|
||||
Session ids are **chosen by the browser** and persisted server-side through `useDashboardState` →
|
||||
`GET/PATCH /dashboards` (React Query key `['DASHBOARD_STATE']`, `staleTime: Infinity`), so they survive
|
||||
@@ -1072,9 +1074,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
|
||||
1. **The browser composes shell commands by string concatenation, unescaped.**
|
||||
```ts
|
||||
// Terminal.tsx:187-190
|
||||
const wrapped = onCommandDoneRef.current
|
||||
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
|
||||
: commandRef.current;
|
||||
const wrapped = onCommandDoneRef.current ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` : commandRef.current;
|
||||
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
|
||||
```
|
||||
That assumes a POSIX shell (`;`, `$?`, `echo`) and does not escape `command`. Same pattern at
|
||||
@@ -1104,7 +1104,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
|
||||
- **The backend's `cwd` handler is unreachable.** Pass 1 flagged
|
||||
`api/terminal/websocket.ts:129-138` for synthesizing `` `cd ${JSON.stringify(msg.path)}\r` ``.
|
||||
Repo-wide grep finds **zero** frontend senders of `{type:'cwd'}` — the browser does its own `cd`
|
||||
composition instead (item 1 above). So that branch is dead, and the capability it implements is
|
||||
composition instead (item 1 above). So that branch is dead, and the permission it implements is
|
||||
duplicated in the client.
|
||||
- **`detached` is dead in the other direction.** `Terminal.tsx:225-226` handles a `'detached'` message
|
||||
and writes `[Session taken over]`, but **no backend code ever emits it** — the only `detached` in
|
||||
@@ -1267,7 +1267,7 @@ settled before any of that code is moved: who is actually meant to talk to the v
|
||||
The single most useful thing in this pass. Ranked by frontend compliance:
|
||||
|
||||
| sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) |
|
||||
|---|---|---|
|
||||
| -------- | ------------------------------- | -------------------------------------------------------- |
|
||||
| slskd | ✅ compliant, 70 lines | ✗ **worst** — 37 raw upstream calls vs 10 Officer routes |
|
||||
| music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned |
|
||||
| opencode | ✗ ≈792 lines | ✅ **best** — 4 mentions, 1 of them logic |
|
||||
@@ -1285,7 +1285,7 @@ because **the sidecar exposes Officer-shaped routes** — the `/_officer/*` name
|
||||
proxying the upstream one.
|
||||
|
||||
So the rule as stated ("main server is a thin proxy") is necessary but not sufficient. The complete
|
||||
version is: *the sidecar owns the contract the browser consumes.* Thinning a router without adding
|
||||
version is: _the sidecar owns the contract the browser consumes._ Thinning a router without adding
|
||||
`/_officer/*` routes to the sidecar just moves domain logic from the platform into the browser, which is
|
||||
strictly worse — it is further from the data and unversioned.
|
||||
|
||||
@@ -1295,7 +1295,7 @@ Pass 1 found an architecture problem. Pass 2 mostly finds a **resilience** probl
|
||||
per-socket rather than systemic:
|
||||
|
||||
| channel | reconnect | replay |
|
||||
|---|---|---|
|
||||
| ---------------------------- | -------------------------------------- | --------------------------------------------------- |
|
||||
| chat WS | ✅ `min(5000, 300 × retry)` | ✅ `seq` + `resume-cursor` (best in repo) |
|
||||
| terminal / cliamp control WS | ✅ 5-entry table + visibility trigger | ◐ passive 50 KB sidecar buffer; browser unaware |
|
||||
| cliamp audio WS | ✗ none | — n/a (live capture) |
|
||||
@@ -1368,9 +1368,9 @@ Recorded because they surfaced during the audit, not because they're in scope:
|
||||
2. **Terminals don't re-fit after a resize.** `fitAddon.fit()` runs once per `connect()`
|
||||
(`Terminal.tsx:158`); there is no `ResizeObserver` or window listener, so dragging a splitter leaves
|
||||
the pty on stale dimensions until the next reconnect.
|
||||
3. **opencode model capabilities shown to the user are placeholder constants.**
|
||||
3. **opencode model permissions shown to the user are placeholder constants.**
|
||||
`api/chat/list-models.ts:24` stubs every opencode model at `contextWindow: 200000, maxTokens: 8192,
|
||||
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
|
||||
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ A sidecar is a **PM2 peer of `officer`** — never a child. It dials _in_; offic
|
||||
```
|
||||
PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
|
||||
→ it opens a WS to officer at /api/sidecar/register
|
||||
→ it sends { type:'register', name, capabilities[] }
|
||||
→ it sends { type:'register', name, permissions[] }
|
||||
→ officer replies { type:'registered', id }
|
||||
→ it sends { type:'<name>:server', port } (HTTP sidecars only)
|
||||
→ officer remembers the port and proxies <prefix>/* to it
|
||||
@@ -26,10 +26,10 @@ PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
|
||||
Officer's side of that is `src/servers/sidecar-registry.ts`; the sidecar's side is
|
||||
`src/servers/sidecar/connect.ts`.
|
||||
|
||||
**Nothing in this path is officer starting a process.** `waitForCapability` in the registry says so
|
||||
**Nothing in this path is officer starting a process.** `waitForPermission` in the registry says so
|
||||
explicitly — it replaced ~77 lines of spawn-and-poll (`ensureClaudeSidecar`,
|
||||
`spawnAndWaitForRegistration`, and per-email process maps). The only startup problem left is _ordering_,
|
||||
handled by waiting up to 15s for a capability to appear rather than failing the first request after boot.
|
||||
handled by waiting up to 15s for a permission to appear rather than failing the first request after boot.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,11 +56,11 @@ the reconnect loop. The ecosystem file says so in a comment, which is the right
|
||||
Four things, and three of them fail loudly if missed.
|
||||
|
||||
1. **A PM2 entry** in `ecosystem.config.cjs` (`script: 'bun'`, `args: 'run src/servers/sidecar/<n>/index.ts'`).
|
||||
2. **A registration** with a `name` and `capabilities[]`. Officer indexes by capability, not by name —
|
||||
`findSidecarByCapability` is how every caller reaches one.
|
||||
2. **A registration** with a `name` and `permissions[]`. Officer indexes by permission, not by name —
|
||||
`findSidecarByPermission` is how every caller reaches one.
|
||||
3. **A `'<name>:server'` event in `protocol.ts`**, if it serves HTTP. Without it the type does not exist
|
||||
and `createSidecarProxy`'s listener never matches.
|
||||
4. **A capability-registry entry**, if it mounts a router. `assertCapabilityTotality` runs in
|
||||
4. **A permission-registry entry**, if it mounts a router. `assertPermissionTotality` runs in
|
||||
`server.tsx` _before_ `serve()` and **throws**, so a missing entry means the server refuses to boot,
|
||||
naming what is missing. Alternatively an `EXEMPT_API_PREFIXES` entry _with a stated reason_.
|
||||
|
||||
@@ -102,19 +102,19 @@ contains two entrypoints that register as _different sidecars_:
|
||||
|
||||
| File | PM2 entry | Registers as | What it is |
|
||||
| ------------------------- | ------------------------- | ------------------------------------ | ---------------------------------------------------- |
|
||||
| `claude/index.ts` | `officer-anthropic-proxy` | name `proxy`, capability `['proxy']` | Holds the Anthropic credential, forwards API traffic |
|
||||
| `claude/user-instance.ts` | `officer-agent` | capability `['claude']` | The process that actually spawns `claude` |
|
||||
| `claude/index.ts` | `officer-anthropic-proxy` | name `proxy`, permission `['proxy']` | Holds the Anthropic credential, forwards API traffic |
|
||||
| `claude/user-instance.ts` | `officer-agent` | permission `['claude']` | The process that actually spawns `claude` |
|
||||
|
||||
So **capability `proxy` is the Anthropic proxy, and capability `claude` is the agent.** Nothing named
|
||||
"claude" registers the `claude` capability from `claude/index.ts`, which is exactly the sort of thing
|
||||
So **permission `proxy` is the Anthropic proxy, and permission `claude` is the agent.** Nothing named
|
||||
"claude" registers the `claude` permission from `claude/index.ts`, which is exactly the sort of thing
|
||||
that reads as a bug in a grep and is not one.
|
||||
|
||||
That resolves the special-casing: `isConnected()` returns "a sidecar with capability `proxy` exists" —
|
||||
That resolves the special-casing: `isConnected()` returns "a sidecar with permission `proxy` exists" —
|
||||
i.e. **the Anthropic proxy is up**, which is _not_ the same as "the agent is up", though the name reads
|
||||
that way. `[verified]` It currently has **no callers** outside the registry itself, so nothing is
|
||||
misreading it today. Worth either renaming or deleting before something starts trusting the name.
|
||||
|
||||
`registerSidecar` also fires a notification when a registration includes capability `claude`
|
||||
`registerSidecar` also fires a notification when a registration includes permission `claude`
|
||||
(`sidecar-registry.ts:75`) — "a new agent process has come up". That one is correctly aimed at the agent.
|
||||
|
||||
---
|
||||
@@ -135,11 +135,11 @@ lines?
|
||||
|
||||
## Open questions, in the order I would answer them
|
||||
|
||||
1. ~~What provides the `proxy` capability~~ — **answered above**: the Anthropic proxy, not the agent.
|
||||
1. ~~What provides the `proxy` permission~~ — **answered above**: the Anthropic proxy, not the agent.
|
||||
`isConnected()` has no callers; rename or delete it before its name misleads someone.
|
||||
2. **Is the sidecar-side boilerplate worth factoring**, given `create-proxy.ts` already proved the
|
||||
officer side was?
|
||||
3. **What happens on a partial boot** — officer up, a sidecar permanently down. `waitForCapability`
|
||||
3. **What happens on a partial boot** — officer up, a sidecar permanently down. `waitForPermission`
|
||||
throws after 15s; who catches it, and what does the user see?
|
||||
4. **Is the `PORT ?? '5000'` fallback reachable**, and should it fail loudly instead?
|
||||
5. **`sweepStaleServes` is `/proc`-based and a no-op on macOS** (already noted in the OpenCode parity
|
||||
@@ -150,12 +150,12 @@ lines?
|
||||
## Verified facts this document rests on
|
||||
|
||||
| Claim | How |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| 20 PM2 entries, 18 sidecar dirs | `ecosystem.config.cjs`, `ls src/servers/sidecar/` |
|
||||
| 16 sidecars report a port, 2 do not | `grep` for `':server'` in each `index.ts`, cross-checked against 16 declarations in `protocol.ts` |
|
||||
| `pty` is node + `.mjs` + its own reconnect loop | `ecosystem.config.cjs` comment and `ls sidecar/pty/` |
|
||||
| Officer spawns nothing | `waitForCapability` comment; no spawn call in the registry |
|
||||
| Officer spawns nothing | `waitForPermission` comment; no spawn call in the registry |
|
||||
| Ports change across restarts and officer follows | observed live tonight across five photos restarts |
|
||||
| Boot fails on a missing capability entry | `assertCapabilityTotality` throws before `serve()` |
|
||||
| `sidecar/claude/` is two processes with different capabilities | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
|
||||
| Boot fails on a missing permission entry | `assertPermissionTotality` throws before `serve()` |
|
||||
| `sidecar/claude/` is two processes with different permissions | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
|
||||
| `isConnected()` has no callers outside the registry | grep across `src/servers` |
|
||||
|
||||
@@ -8,13 +8,13 @@ vocabulary left between them. Every HTTP sidecar shares one `createSidecarProxy`
|
||||
happened is the part this document is actually about — fixed ports, the platform reading a table instead
|
||||
of being told at runtime, and `.env` feature toggles. Ports are still ephemeral and still announced.
|
||||
|
||||
Not to be confused with `sidecar-audit-2026-07.md`, which is the *audit* of the
|
||||
Not to be confused with `sidecar-audit-2026-07.md`, which is the _audit_ of the
|
||||
current state (what's misplaced, and where). This is where it's going.
|
||||
|
||||
## The premise that makes it simple
|
||||
|
||||
**The tailnet is the perimeter.** Everything moves behind Tailscale and devices are admitted by hand —
|
||||
friends and family included. Authentication *inside* that boundary is solving a problem we don't have, so
|
||||
friends and family included. Authentication _inside_ that boundary is solving a problem we don't have, so
|
||||
this design has no token work in it at all. Sidecars trust their caller exactly as they do today; the trust
|
||||
boundary just moves from loopback to the tailnet.
|
||||
|
||||
@@ -22,7 +22,7 @@ Until that lands, things stay exposed as they are now. The security model is del
|
||||
|
||||
## The design
|
||||
|
||||
1. **`ecosystem.config.cjs` is the source of truth.** PM2 starts every sidecar. They stay *peers* of
|
||||
1. **`ecosystem.config.cjs` is the source of truth.** PM2 starts every sidecar. They stay _peers_ of
|
||||
`officer` — never children. This is not a style preference: officer used to spawn the agent itself,
|
||||
which made it a grandchild, and PM2's tree-kill took the owner's chat session down on every restart.
|
||||
That was the worst thing about working on the platform, and it is fixed. Don't reintroduce it.
|
||||
@@ -43,7 +43,7 @@ The point of the exercise, and the reason it's worth doing:
|
||||
- `sidecar/connect.ts` — the dial-out-and-register loop, plus its per-sidecar reconnect backoff copies
|
||||
- every port announcement: `music:server`, `slskd:server`, `vault:server`, `opencode:server`,
|
||||
`pty:server`, `email:server`, `wallet:server`, `headscale:server`, … and `vnc:started`
|
||||
- most of `sidecar-registry.ts` — discovery, the pending-command map, capability lookup
|
||||
- most of `sidecar-registry.ts` — discovery, the pending-command map, permission lookup
|
||||
- officer's proxying for anything that isn't auth or layout state
|
||||
|
||||
## Migration order
|
||||
@@ -60,14 +60,14 @@ least urgent anyway.
|
||||
|
||||
- **Ecosystem file, or a shared table?** The platform parsing `ecosystem.config.cjs` couples the app to
|
||||
PM2 being the thing that started it, which matters for containerising this later and for `bun dev`.
|
||||
The alternative is one plain TypeScript table (name, script, port, capability, enabled) that
|
||||
The alternative is one plain TypeScript table (name, script, port, permission, enabled) that
|
||||
`ecosystem.config.cjs` generates its `apps:` array from and the platform imports directly — same single
|
||||
source of truth, no supervisor coupling. **Recommended, not yet decided.**
|
||||
- **Where do the things that are neither auth nor layout go?** The job/queue engine, the capabilities/items
|
||||
- **Where do the things that are neither auth nor layout go?** The job/queue engine, the permissions/items
|
||||
store, the chat session list, the file browser. Each needs a named home or officer quietly stays fat.
|
||||
- **Does the registration socket survive?** Not needed for discovery once ports are static. Possibly worth
|
||||
keeping for liveness — or replace it with a health probe on the known port.
|
||||
- **Capabilities.** Today a sidecar announces `capabilities: ['music']` and officer looks up by capability,
|
||||
- **Permissions.** Today a sidecar announces `permissions: ['music']` and officer looks up by permission,
|
||||
not by name — which is what let the agent's PM2 name change from `officer-claude` to `officer-agent`
|
||||
without touching a caller. In a static table it collapses to a column. Keep it; it's cheap.
|
||||
- **The non-owner account class may become dead weight.** `NON_OWNER_PATHS`, the music-only account
|
||||
@@ -81,7 +81,7 @@ Recorded so they aren't re-litigated:
|
||||
- **Platform spawns the sidecars.** Rejected — that's the tree-kill bug again. PM2 starts them; the
|
||||
platform only reads the topology.
|
||||
- **Platform mints a token, tells every sidecar it's valid, apps then call sidecars directly.** This was
|
||||
the original points 6–8. Dropped with the tailnet decision. Worth knowing *why* it was weak even on its
|
||||
the original points 6–8. Dropped with the tailnet decision. Worth knowing _why_ it was weak even on its
|
||||
own terms: it replicates session state across ten processes, and breaks whenever one restarts, is down
|
||||
at login, or has to be told about a logout.
|
||||
- **A dedicated public auth sidecar** issuing short-lived asymmetric tokens, with sidecars verifying via
|
||||
|
||||
@@ -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.
|
||||
@@ -5,7 +5,7 @@ does and does not protect against.
|
||||
|
||||
Authoritative for the crypto design. The code is `src/servers/sidecar/wallet/keys.ts` (sealing,
|
||||
derivation, unlock sessions), `src/databases/officer_db/src/crypto.ts` (storage encryption) and
|
||||
`src/databases/officer_db/src/queries/wallet.ts` (where the two meet).
|
||||
`src/databases/officer_db/src/wallet/queries.ts` (where the two meet).
|
||||
|
||||
## The requirement
|
||||
|
||||
|
||||
+50
-24
@@ -1,34 +1,60 @@
|
||||
# Working on Officer
|
||||
|
||||
The guide for anyone — human or agent — changing this deployment. It assumes you are working from the
|
||||
root of the install (the directory holding `platform/`, `capabilities/` and `data/`), which is where
|
||||
root of the install (the directory holding `platform/`, `permissions/` and `data/`), which is where
|
||||
agent sessions start.
|
||||
|
||||
Three directories sit there, and knowing which one a change belongs in is most of the job:
|
||||
|
||||
```
|
||||
officer/
|
||||
$OFFICER_ROOT/
|
||||
├── platform/ the application — a git repo
|
||||
├── capabilities/ what the agent can do — a separate git repo
|
||||
└── data/ runtime state — NOT version controlled
|
||||
├── permissions/ what the agent can do — a separate git repo
|
||||
├── data/ runtime state — NOT version controlled
|
||||
├── dockers/ containers the app store provisioned
|
||||
└── secrets/ the key store — 0600, and NOT in your data backup
|
||||
```
|
||||
|
||||
None of those paths is configured. `src/servers/data-path.ts` derives the root as
|
||||
`resolve(process.cwd(), '..')` and hangs the rest off it, which is why the pm2 `cwd` pin matters and
|
||||
why `assertInstallLayout` refuses to boot from the wrong directory.
|
||||
|
||||
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
|
||||
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
|
||||
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
|
||||
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
|
||||
admits **additional accounts holding a strict subset of it**, governed by per-role permission grants.
|
||||
|
||||
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
|
||||
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
|
||||
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
|
||||
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
|
||||
granted, because they run as the owner's OS user in the owner's home.
|
||||
So "which user" has three answers depending on the surface. For the **app** permissions (gitea,
|
||||
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
|
||||
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
|
||||
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
|
||||
For **execution** — tasks, items, desktop, browser — it is still always the owner, and those can
|
||||
never be granted at any level.
|
||||
|
||||
That is five kinds, not four: `core`, `app`, `confined`, `execution`, `admin`. Terminal, chat and
|
||||
files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts.
|
||||
|
||||
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
|
||||
and five non-owner accounts are live; treat the capability registry as the source of truth over any
|
||||
and five non-owner accounts are live; treat the permission registry as the source of truth over any
|
||||
prose, here or elsewhere.
|
||||
|
||||
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
|
||||
## What is switched off (2026-08-13)
|
||||
|
||||
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
|
||||
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
|
||||
plugin, and every plugin router is commented out in `hono.ts` with its permission's `api` claim
|
||||
commented beside it — they must move together or `assertPermissionTotality` refuses to boot.
|
||||
|
||||
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
|
||||
extraction into the plugin system.
|
||||
|
||||
Also gone: the four ecosystem files (generated now, at setup, and gitignored), origin validation,
|
||||
`OFFICER_OS_USERS` (per-user Linux accounts are unconditional), and the Task Logs feature.
|
||||
|
||||
`.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in
|
||||
`$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`.
|
||||
|
||||
`platform/` and `permissions/` each have their own `CLAUDE.md` with detail. This file is the layer
|
||||
above them: where things live, how to change them safely, and the things that are true of the running
|
||||
system but written down nowhere else.
|
||||
|
||||
@@ -36,19 +62,19 @@ system but written down nowhere else.
|
||||
|
||||
## Which directory does this change belong in?
|
||||
|
||||
**`capabilities/` — almost always start here.** Tasks, tools, skills, processes. It is *data*: plain
|
||||
**`permissions/` — almost always start here.** Tasks, tools, skills, processes. It is _data_: plain
|
||||
directories of Markdown and scripts, read fresh on every request. Adding a task, changing what a task
|
||||
does, renaming a category — none of that needs a code change or a restart.
|
||||
|
||||
**`platform/` — only when the mechanism itself is missing.** If a task needs a form control that
|
||||
doesn't exist, or an endpoint that isn't there, that's platform work. Adding a *capability* is not.
|
||||
doesn't exist, or an endpoint that isn't there, that's platform work. Adding a _permission_ is not.
|
||||
|
||||
**`data/` — never edit by hand.** `DATA_PATH`. Holds the owner's managed home, per-account email
|
||||
SQLite stores, job logs, the queue, sidecar state. It is not backed up by git; deleting things here
|
||||
destroys the only copy.
|
||||
|
||||
A useful test: **would this differ between two Officer installs?** Domain, paths, credentials → `.env`.
|
||||
Which tasks exist and what they're called → `capabilities/`. Everything else → `platform/`.
|
||||
Which tasks exist and what they're called → `permissions/`. Everything else → `platform/`.
|
||||
|
||||
## Git
|
||||
|
||||
@@ -61,19 +87,19 @@ support it needs, and a half-pushed pair leaves the deployment inconsistent.
|
||||
Keep history linear: `git pull --rebase`, not `git merge`. The remote moves — the owner develops on
|
||||
this box too — so expect to rebase before pushing. Say so before force-pushing anything.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes, explaining *why*.
|
||||
Commit messages: simple lowercase, no prefixes, explaining _why_.
|
||||
|
||||
---
|
||||
|
||||
## Running and checking your work
|
||||
|
||||
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-agent`,
|
||||
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
|
||||
`pm2 list` shows them; `pm2 logs officer` follows.
|
||||
|
||||
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential
|
||||
and proxies API traffic; `officer-agent` is the process that actually runs `claude`.**
|
||||
and proxies API traffic; `officer-claude-code` is the process that actually runs `claude`.**
|
||||
|
||||
**Which process to restart.** A change under `src/servers/sidecar/<name>/` needs that sidecar restarted;
|
||||
a change anywhere else needs `officer`. Both, if you changed the wire between them. Restarting `officer`
|
||||
@@ -112,7 +138,7 @@ This trips people up repeatedly. It is also why script tasks are handed `OFFICER
|
||||
### Services this box depends on
|
||||
|
||||
| port | what | used by |
|
||||
|------|------|---------|
|
||||
| ---- | ------------------------- | -------------- |
|
||||
| 9010 | Officer itself | — |
|
||||
| 9002 | Kokoro TTS | text-to-speech |
|
||||
| 8178 | whisper.cpp | transcription |
|
||||
@@ -129,16 +155,16 @@ transcription or OCR fails, check the service is up before reading any code.
|
||||
This is what most requests will be about. Tasks appear in the file browser's right-click menu under
|
||||
**Run Task**, grouped into submenus by category.
|
||||
|
||||
A task is a directory under `capabilities/tasks/<slug>/` with a `TASK.md` — frontmatter plus a body —
|
||||
A task is a directory under `permissions/tasks/<slug>/` with a `TASK.md` — frontmatter plus a body —
|
||||
and, for script mode, a sibling `run.sh` / `run.py` / `index.ts`. **The directory name is the task's
|
||||
identity**; renaming it breaks every reference to it.
|
||||
|
||||
`capabilities/CLAUDE.md` documents the format. It is accurate but **incomplete** — the following are
|
||||
`permissions/CLAUDE.md` documents the format. It is accurate but **incomplete** — the following are
|
||||
used heavily by real tasks and appear nowhere in it:
|
||||
|
||||
| convention | what it does |
|
||||
|---|---|
|
||||
| `category: Video` | which submenu the task appears in. Order comes from `capabilities/categories.yaml`; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders. |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `category: Video` | which submenu the task appears in. Order comes from `permissions/categories.yaml`; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders. |
|
||||
| `inline: true` | runs ephemerally in the modal instead of becoming a job |
|
||||
| `inline: ask` | offers both — **Run here** and **Run as job** |
|
||||
| `INPUT_INCLUDE` | newline-separated paths, injected by the modal on a multi-selection. **The single most used input in the library** — a task that ignores it silently processes the whole folder instead of the selection. |
|
||||
@@ -164,7 +190,7 @@ Two ways a task does work:
|
||||
House style for file-processing tasks, worth keeping consistent:
|
||||
|
||||
- Never delete or modify the source; write output beside it.
|
||||
- Handle a single file *and* a directory, recursively.
|
||||
- Handle a single file _and_ a directory, recursively.
|
||||
- Honour `INPUT_INCLUDE`.
|
||||
- No caching. Re-running redoes the work and overwrites — and say so in the body, because it also
|
||||
overwrites edits.
|
||||
|
||||
@@ -135,7 +135,7 @@ and the rename sequence leaves `workspaces` with no zombie.
|
||||
- [x] **`ws-terminals-{id}: null` on a live dashboard is a 500.** Same file, `:61-66` — the
|
||||
`ws-layout-*` branch has a `value === null` → `deleteDashboard` case (`:42`); the terminals
|
||||
branches do not. A null falls to the UPDATE branch and sets a `NOT NULL` column
|
||||
(`databases/officer_db/src/queries/dashboards.ts:70`) → 23502.
|
||||
(`databases/officer_db/src/dashboards/queries.ts:70`) → 23502.
|
||||
**Resolved.** A null on either terminals branch is now a no-op: it means "forget this key", and it
|
||||
only ever arrives paired with `ws-layout-{id}: null` on a rename, by which point the row is gone.
|
||||
|
||||
@@ -174,7 +174,7 @@ and the rename sequence leaves `workspaces` with no zombie.
|
||||
|
||||
`dashboards` is **empty (0 rows)** today, so none of this has fired yet. Members can now sign in
|
||||
(`d8ee678`), so it is a matter of time. Note `TODO.md`'s preamble still says the platform is collapsing
|
||||
to single-user — that predates the capability permission model and should not be used to deprioritise
|
||||
to single-user — that predates the permission permission model and should not be used to deprioritise
|
||||
these.
|
||||
|
||||
> **Re-measured 2026-08-07.** The premise above has moved and the section is no longer hypothetical.
|
||||
@@ -184,7 +184,7 @@ these.
|
||||
>
|
||||
> It also puts this section in **direct contradiction with `CLAUDE.md`**, which opens by calling
|
||||
> single-user "a hard invariant, not a stage" — no roles, no per-user isolation, and "if a change seems
|
||||
> to need *which user is this*, the answer is always the owner." Five rows in `users` says otherwise.
|
||||
> to need _which user is this_, the answer is always the owner." Five rows in `users` says otherwise.
|
||||
> One of the two documents is wrong about what this platform is, and that is a **product question, not a
|
||||
> defect**: the answer decides whether the item below is urgent or should be deleted along with the rest
|
||||
> of the section.
|
||||
@@ -202,7 +202,7 @@ these.
|
||||
> which uuid ids would not.
|
||||
|
||||
- [ ] **`dashboards.id` is a global primary key but ids are `slugify(name)`.**
|
||||
`databases/officer_db/src/schema/dashboards.ts` declares `id: text('id').primaryKey()`. Live:
|
||||
`databases/officer_db/src/dashboards/schema.ts` declares `id: text('id').primaryKey()`. Live:
|
||||
`"dashboards_pkey" PRIMARY KEY, btree (id)` plus a redundant
|
||||
`"uq_dashboards_user_id" UNIQUE, btree (user_id, id)` — evidence per-user ids were intended and
|
||||
half-built. Ids come from `DashboardPreview.tsx:300` (`slugify(trimmed) || generateSlug()`) and the
|
||||
@@ -213,7 +213,7 @@ these.
|
||||
(see `databases/CLAUDE.md` → "Composite keys") — harmless churn, but read the plan.
|
||||
|
||||
- [x] **`upsertDashboard`'s UPDATE has no `userId` predicate.**
|
||||
`databases/officer_db/src/queries/dashboards.ts:73` —
|
||||
`databases/officer_db/src/dashboards/queries.ts:73` —
|
||||
`db.update(dashboards).set(set).where(eq(dashboards.id, id))`. The `existing` lookup above it _is_
|
||||
scoped, so it cannot reach another user's row today, but it is a non-transactional read-then-write.
|
||||
**It becomes a live cross-user overwrite the moment the PK above is made composite.**
|
||||
@@ -371,7 +371,7 @@ playback, transcodes — not as a prerequisite for agent coordination._
|
||||
|
||||
**Measured `56ca411`.** `WorkspaceRenderer.test.tsx` mounts the real renderer against a mount-counting
|
||||
probe app and lets the real `layout-utils` mutators produce the "after" tree. The table below was written
|
||||
from reading the code; the test disagrees with its *diagnosis* in every row, and found one row it had
|
||||
from reading the code; the test disagrees with its _diagnosis_ in every row, and found one row it had
|
||||
missed entirely. Read this paragraph before acting on the bullets underneath it.
|
||||
|
||||
- **The key is not the cause.** A panel's React identity is its position plus `key={child.node.id}` on its
|
||||
@@ -391,7 +391,7 @@ missed entirely. Read this paragraph before acting on the bullets underneath it.
|
||||
host that does not move when the tree reshapes, the way maximize is a CSS toggle on the same element.
|
||||
That is a redesign, not a patch, and it is still Tier C.
|
||||
|
||||
The original table, kept because its *observations* hold even where its explanation did not:
|
||||
The original table, kept because its _observations_ hold even where its explanation did not:
|
||||
|
||||
| operation | remounts? | why |
|
||||
| ------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
|
||||
@@ -410,8 +410,7 @@ The original table, kept because its *observations* hold even where its explanat
|
||||
Kills rows 2 and 3.~~ **Withdrawn `56ca411`** — measured, and it kills neither. The element type at
|
||||
that position changes too, which React acts on first. It would also collide a panel id with a group
|
||||
id, and a panel id is an agent's address now.
|
||||
- [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row
|
||||
4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
|
||||
- [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row 4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
|
||||
- [ ] **Overlay the mobile ephemeral panel instead of replacing the workspace**
|
||||
(`WorkspaceView.tsx:165`). Affects `/files`, `/email`, `/chat`, `/browser`, `/dashboards`.
|
||||
- [ ] **Reference for how it should feel:** maximize (`PanelSlot.tsx:430-457`) is a CSS state toggle on
|
||||
@@ -463,6 +462,7 @@ The original table, kept because its *observations* hold even where its explanat
|
||||
that passes and reaches `PanelSlot.tsx:311-317`, which on a `locked` screen renders an empty
|
||||
teal-bordered box with no picker and no way for the user to recover. - `screens/QrTransferScreen.tsx:19-39` has the guard but no persist-back, so it re-normalises on
|
||||
every mount forever and never heals the row.
|
||||
|
||||
- [x] **~~Then collapse the three default-layout mechanisms~~ — inventoried and dropped.** Per-screen
|
||||
`defaultLayout.ts` (21, not 20 — `Home/defaultLayout.tsx` is misnamed), `createDefaultLayout()`
|
||||
in the core, and the 6-entry template array at `DashboardPreview.tsx:33-142`.
|
||||
@@ -520,7 +520,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
the repo. Meanwhile every PATCH computed and returned a full fresh state blob which the client
|
||||
**discarded** — 3 SELECTs per splitter release, thrown away.
|
||||
**Resolved `81ad3ef`** — both halves. The PATCH returns `{ok: true}`; nothing had ever read that
|
||||
body, and a caller that did would be reading state assembled *before* whatever concurrent write it
|
||||
body, and a caller that did would be reading state assembled _before_ whatever concurrent write it
|
||||
raced. The client refetches **on focus**, with three non-default guards, because this cache is
|
||||
optimistic: a refetch that started before an in-flight PATCH landed would overwrite the value
|
||||
already on screen — the same lost-update shape as the two items above, and self-healing only until
|
||||
@@ -529,7 +529,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
and `refetchOnWindowFocus` gated on a module-level in-flight count plus a 2 s quiet period.
|
||||
- [x] **Preserve sibling sizes on split.** `splitInner`/`insertPanel` redistribute evenly
|
||||
(`100 / newChildren.length`), so one split discards carefully tuned proportions.
|
||||
**Resolved `abea7a3`** — the new sibling takes half of the *target's* size and nothing else moves.
|
||||
**Resolved `abea7a3`** — the new sibling takes half of the _target's_ size and nothing else moves.
|
||||
One helper serves both call sites, because the drop path (`movePanel` → `insertPanel`) carried the
|
||||
identical bug. Two of the three tests were already in `layout-utils.test.ts` asserting the even
|
||||
split, written to the old behaviour deliberately; they now assert the new one. The third documents
|
||||
@@ -542,8 +542,8 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
**Resolved `6fd60e5`** — the templates now call the core's `uid()`, which is exported from the
|
||||
Workspace barrel for the first time so that there is exactly one way to mint a panel id. The
|
||||
duplicate minter is deleted rather than fixed: a second implementation of "make me an id" is how
|
||||
this happened, and the collision was no longer only a settings mix-up — `agent_panels` addresses a
|
||||
panel by `(dashboardId, panelId)`, so two dashboards built from templates in the same page load
|
||||
this happened, and the collision was no longer only a settings mix-up — `agent_panels`addresses a
|
||||
panel by`(dashboardId, panelId)`, so two dashboards built from templates in the same page load
|
||||
could hand two different agents the same address.
|
||||
|
||||
### 5.6 Registry
|
||||
@@ -557,7 +557,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
(`AppRegistry.test.ts`) plus a `console.error` at runtime — the mistake is caught before it ships
|
||||
and named if it somehow does. Confirmed: all 44 keys are unique today, and the test says so.
|
||||
Getting the real list into a test needed one thing beyond exporting it: `test-setup.ts` was not
|
||||
providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at *import* time, so the
|
||||
providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at _import_ time, so the
|
||||
whole app graph was unimportable from a test. That is now fixed, which unblocks testing anything
|
||||
else that pulls in a panel app.
|
||||
- [x] **Seeding depends on undocumented mount ordering.** Three call sites call `useAppRegistry()` with
|
||||
@@ -581,7 +581,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
`availableOnPanel: false`, so it can't be picked. If it ever appeared in a layout it would say
|
||||
"No file selected" forever.
|
||||
**Resolved `9fcc9c2`** — traced and confirmed dead, then removed rather than repaired. The file
|
||||
viewer that users actually see is mounted by `useFileViewerPanels` as an *ephemeral* panel, which
|
||||
viewer that users actually see is mounted by `useFileViewerPanels` as an _ephemeral_ panel, which
|
||||
supplies `FileViewerBody`/`FileViewerHeader` itself with a provider reading the path from
|
||||
`?view=`/`?ephemeral=` — it never touched the registry. No stored layout referenced the key
|
||||
(checked across `dashboards`, `screens`, `dashboard_defaults`, `user_state`, `user_settings`: zero
|
||||
@@ -624,7 +624,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
`TaskRunnerModal.tsx:1320` renders a Stop button while `phase === 'running'`. And a bare `ws.close()`
|
||||
is not abandonment: `task-executor.ts:303-309` kills the process tree on socket close, the same
|
||||
`killTree` the Stop button reaches. What is true is the last clause: there is no re-attach, so an
|
||||
inline run dies with its modal. That is defensible — inline is the *ephemeral* mode and the job path
|
||||
inline run dies with its modal. That is defensible — inline is the _ephemeral_ mode and the job path
|
||||
exists for everything else — so this is left alone deliberately rather than left undone.
|
||||
- [ ] **`VideoPlayer` kills the transcode on incidental unmount.** `apps/Jellyfin/VideoPlayer.tsx:217-223`
|
||||
POSTs `stopped`, killing server-side ffmpeg, then renegotiates. Fires on every "yes" row in 5.2 —
|
||||
@@ -639,7 +639,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
- [x] **`PanelSlot` defines a component inside render.** — _resolved `c0fae47`_. `DefaultHeader` is gone: the
|
||||
header is now an element, not a component type, so there is nothing for React to fail to match.
|
||||
- [x] **The context value is a fresh literal.** — _resolved `c0fae47`_. `useMemo` over the eighteen members.
|
||||
Note what it does *not* buy: the value still changes whenever `layout` does, because half the
|
||||
Note what it does _not_ buy: the value still changes whenever `layout` does, because half the
|
||||
callbacks close over it. What it stops is the renders that change nothing a panel can see — the
|
||||
ephemeral pane opening, a mobile panel switch, every frame of a maximize animation.
|
||||
|
||||
@@ -736,7 +736,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
touch the framework half, so the abstraction holds in one direction; the leak is entirely outbound.
|
||||
|
||||
**§5.9 is closed as of 2026-08-07.** The context is 15 fields, and the outbound half is `workspace`,
|
||||
`cwd`, `root` — all three facts about *where the panel is*, which is the one thing a framework of this
|
||||
`cwd`, `root` — all three facts about _where the panel is_, which is the one thing a framework of this
|
||||
shape genuinely owes an app. Nothing left on it is an app's vocabulary: the file-browser pair is
|
||||
deleted, the chat's system prompt is a prop on the chat, and the key three apps used to parse is a
|
||||
parsed identity. The two hand-written copies of the inert half are one named constant.
|
||||
@@ -759,7 +759,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
|
||||
Email can supply a pre-configured chat by panel id.
|
||||
**Done in `d3922bd`**, exactly that way: both screens put their own `ChatPanelWrapper` in
|
||||
`components` under the chat panel's id and pass the prefix as a prop. `PanelSlot` prefers a
|
||||
`components` entry over the registry for the *body* only, so the panel keeps its registry header —
|
||||
`components` entry over the registry for the _body_ only, so the panel keeps its registry header —
|
||||
the screens did not have to reproduce any chrome. `ChatPanelWrapper` is exported from the barrel
|
||||
for it. The same prop came off `WorkspaceLayout`, where it had no callers at all: every settings
|
||||
pane and job detail rendering through it had always been passing its chat panels `undefined`.
|
||||
@@ -793,7 +793,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
|
||||
workspace, plus the state those interactions run on — are one exported `inertInteraction`, spread
|
||||
by `WorkspaceLayout` and by the `createContext` default. `root` stays omitted, and that is now a
|
||||
stated decision rather than an oversight: it is only ever read when `cwd` is scoped, and no caller
|
||||
of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root *of*.
|
||||
of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root _of_.
|
||||
|
||||
### 5.10 Channel hygiene — _(found 2026-08-07)_
|
||||
|
||||
@@ -953,7 +953,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
- [x] `screens.terminals` / `screens.hostTerminals` columns — never read (confirmed), **but "never
|
||||
written" was stale**: `upsertScreen` accepted and inserted them, so all 15 rows hold the `{}` it
|
||||
wrote. The dead parameters and inserts are gone. **The columns themselves are not dropped** — that
|
||||
needs `bun db:push`, which diffs the *whole* schema, and this tree currently holds another agent's
|
||||
needs `bun db:push`, which diffs the _whole_ schema, and this tree currently holds another agent's
|
||||
uncommitted `schema/agent-panels.ts`. Drop them in a push of their own.
|
||||
- [x] ~~`SELECTED_DASHBOARD`~~ **`SELECTED_DASHBOARD_KEY`** constant — zero consumers. The parenthetical
|
||||
claiming `SELECTED_DASHBOARD_KEY` was the live one was **backwards**: `'SELECTED_DASHBOARD'` is the
|
||||
@@ -1009,17 +1009,17 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
tests in `56ca411`. The Workspace directory is 76 tests across three files and green.
|
||||
**Genuinely still untested: `WorkspaceView` and `PanelSlot`.** But note §5.5's lost updates are no
|
||||
longer what makes that urgent — every mutation in `WorkspaceView` now goes through `onLayoutChange`
|
||||
as an *updater*, never as a computed tree, which is the structural fix; a test there would be
|
||||
as an _updater_, never as a computed tree, which is the structural fix; a test there would be
|
||||
guarding the fix rather than finding the bug. Checked, not assumed — `bun test src/workspaces/officerdev/src/components/Workspace/`.
|
||||
|
||||
- [x] **`useDashboardState`, and the strongest argument this section has for itself.** _(`4f8046d`,
|
||||
branch `agent-coordination-mvp`)_ — 14 tests over the store every layout and every
|
||||
`config.agentName` is persisted through. They found a live Tier-A-class defect on the first run,
|
||||
in code written three days earlier to *stop* silent write loss: `revert` decided whether to roll
|
||||
in code written three days earlier to _stop_ silent write loss: `revert` decided whether to roll
|
||||
back by asking "does the cache still hold exactly what I wrote?" **by reference**, and
|
||||
`setQueryData` runs React Query's structural sharing, which rebuilds the object it stores rather
|
||||
than keeping the one it was handed. Measured against @tanstack/react-query 5.101.4 — an object
|
||||
value comes back `!==`, a string comes back `===`. So the guard was false for every *container*
|
||||
value comes back `!==`, a string comes back `===`. So the guard was false for every _container_
|
||||
the store exists to hold, and a refused write kept its optimistic value in the cache while the
|
||||
toast said it had been rolled back; the change then vanished at the next reload. Only primitives
|
||||
ever reverted, which is exactly why nobody saw it. Replaced with a per-key write sequence, which
|
||||
@@ -1029,7 +1029,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
been read carefully twice — which is the case for §9 stated better than any argument. And
|
||||
`mock.module` is **process-wide and permanent** in Bun: a stub that does not spread the real
|
||||
module deletes exports out from under files that never heard of it. Likewise
|
||||
`@testing-library/react` auto-registers `afterEach(cleanup)` at *import* time, so it lands in
|
||||
`@testing-library/react` auto-registers `afterEach(cleanup)` at _import_ time, so it lands in
|
||||
whichever test file imports the library first and every later file silently gets none — that is
|
||||
now registered in `test-setup.ts`, where preload's lack of a file scope makes it global. Adding
|
||||
one test file broke fourteen assertions in `DataTable.test.tsx` before both were understood.
|
||||
@@ -1037,10 +1037,10 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
- [x] **`WorkspaceView`, and the second consecutive bug a test found that review had not.** _(`bfa9967`,
|
||||
branch `agent-coordination-mvp`)_ — 11 tests over the last untested mutator, driving the real
|
||||
`WorkspaceView` through the real `WorkspaceRenderer` and `PanelSlot`, so the buttons under test
|
||||
are the buttons. Two properties: every layout write is an *updater* rather than a computed tree
|
||||
are the buttons. Two properties: every layout write is an _updater_ rather than a computed tree
|
||||
(two of the paths are deferred — the 500 ms resize debounce, and a window resize firing `onLayout`
|
||||
on every group at once — so a computed tree silently undoes the write before it and resurrects an
|
||||
older `config`); and `usePanelClose` fires on close *intent* only, never on the unmounts a drag,
|
||||
older `config`); and `usePanelClose` fires on close _intent_ only, never on the unmounts a drag,
|
||||
a swap or a mobile switch cause.
|
||||
Four of the eleven failed on the first run, all on one defect. `TrafficLights` took `onRemove`
|
||||
**and** `isLastPanel` and used `isLastPanel` only to pick the tooltip: the red button read "Close
|
||||
@@ -1103,7 +1103,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
branch `agent-coordination-mvp`)_ — the first two items of §5.1, and the terminal orphan leak with
|
||||
them. `usePanelClose(panelId, handler)`, fired by `WorkspaceView` from `handleRemove` and from
|
||||
`handleSetApp` when the app actually changes, and from nowhere else.
|
||||
The interesting part is what it is *not*. This item used to propose diffing the layout before and
|
||||
The interesting part is what it is _not_. This item used to propose diffing the layout before and
|
||||
after; two tests now stand in `layout-utils.test.ts` to stop anyone trying it, because `movePanel`
|
||||
mints a fresh panel id on the way and `swapPanels` exchanges contents between stationary ones — so
|
||||
a drag reads as a close and a swap reads as two. A panel id is a position in the tree, not an app
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'officer',
|
||||
script: 'bun',
|
||||
args: 'start',
|
||||
watch: false,
|
||||
},
|
||||
// The Anthropic credential proxy. Despite the old name (`officer-claude`) this process does NOT
|
||||
// run agents — it holds the proxy secret and forwards to api.anthropic.com. The process that runs
|
||||
// agents is `officer-agent` below.
|
||||
{
|
||||
name: 'officer-anthropic-proxy',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/claude/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The process that actually runs `claude`. It used to be spawned on demand by the main server,
|
||||
// which made every agent session a grandchild of `officer` and killed it on every restart. As a PM2
|
||||
// peer it survives them. It resolves the owner from the database and the proxy secret from the
|
||||
// proxy's state file, so it needs nothing from `officer` in order to start.
|
||||
{
|
||||
name: 'officer-agent',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/claude/user-instance.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-opencode',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/opencode/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-email',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/email/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The only sidecar run by `node` rather than `bun`, and the only one that is not TypeScript: node-pty
|
||||
// is a native addon. It also does not use sidecar/connect.ts, and carries its own copy of the
|
||||
// reconnect loop.
|
||||
{
|
||||
name: 'officer-pty',
|
||||
script: 'node',
|
||||
args: 'src/servers/sidecar/pty/index.mjs',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-vnc',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/vnc/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-music',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/music/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-vault',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/vault/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-slskd',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/slskd/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-headscale',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/headscale/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-transmission',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/transmission/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The books. Wraps a self-hosted InvoiceShelf. Instances, their Sanctum tokens and the company each one
|
||||
// is pinned to are set by the owner from /invoices/settings and stored encrypted in
|
||||
// `invoiceshelf_accounts` — read here, never from the environment, because Bun auto-loads `.env` into
|
||||
// every process in this directory and `officer` would hold the token too.
|
||||
{
|
||||
name: 'officer-invoiceshelf',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Video. Wraps a self-hosted Jellyfin. Servers, and the access token each one is signed in with, are set
|
||||
// by the owner from /jellyfin and stored encrypted in `jellyfin_servers` — read here, never from the
|
||||
// environment. Video only: Officer's own player owns audio.
|
||||
{
|
||||
name: 'officer-jellyfin',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/jellyfin/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the
|
||||
// owner from the UI and stored in `service_connections` — read here, never from the environment.
|
||||
{
|
||||
name: 'officer-memos',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/memos/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Code hosting. Wraps a self-hosted Gitea. The instance URL and its personal access token are set by
|
||||
// the owner from /gitea and stored in `service_connections` — read here, never from the environment.
|
||||
{
|
||||
name: 'officer-gitea',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/gitea/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
|
||||
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
|
||||
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
|
||||
// the platform authenticates and forwards. See docs/nextcloud-replacement.md.
|
||||
{
|
||||
name: 'officer-caldav',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/caldav/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from
|
||||
// /photos/settings and stored encrypted in `photos_config` — read here, never from the environment,
|
||||
// because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too.
|
||||
{
|
||||
name: 'officer-photos',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/photos/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
// The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so
|
||||
// it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks.
|
||||
// The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the
|
||||
// Discord webhook, behind one interface. A sidecar rather than platform code because the producers
|
||||
// are spread across sidecars, and a platform-owned notifier would make every one of them call back in.
|
||||
{
|
||||
name: 'officer-notify',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/notify/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-wallet',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/wallet/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
// Linux light profile — the platform without the self-hosted estate around it.
|
||||
//
|
||||
// For a machine that should run the file browser, the terminal and Claude/opencode chat, and nothing
|
||||
// else. Paired with `OFFICER_PROFILE=light bash scripts/setup.sh`, which installs only what these
|
||||
// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
|
||||
//
|
||||
// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
|
||||
// for the two checks that make a drifted profile fail loudly instead of silently starting less than it
|
||||
// claims. To change what runs, edit INCLUDE. To change HOW something runs, edit ecosystem.config.cjs
|
||||
// and every profile follows.
|
||||
//
|
||||
// The app itself is unchanged: every API route stays mounted, so features whose sidecars are absent
|
||||
// report themselves unavailable rather than disappearing. A profile decides which processes start, not
|
||||
// which code ships.
|
||||
//
|
||||
// Start with: pm2 startOrRestart ecosystem.light.config.cjs
|
||||
|
||||
const { defineProfile } = require('./ecosystem.profile.cjs');
|
||||
|
||||
module.exports = defineProfile({
|
||||
file: 'ecosystem.light.config.cjs',
|
||||
|
||||
include: [
|
||||
'officer', // the app: SPA, /api, websockets
|
||||
'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream
|
||||
'officer-agent', // spawns `claude` — chat is dead without it
|
||||
'officer-opencode', // the alternative agent
|
||||
'officer-pty', // the terminal
|
||||
],
|
||||
|
||||
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
|
||||
// A light install simply is not running the thing behind it.
|
||||
excluded: {
|
||||
// Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs
|
||||
// nothing installed locally. True, and beside the point: a baseline process appears in the Permissions
|
||||
// screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea
|
||||
// access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which
|
||||
// makes "is Gitea here" one question with one answer instead of two that disagree.
|
||||
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
||||
'officer-vnc': 'no desktop to mirror on a light install',
|
||||
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
|
||||
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
|
||||
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
|
||||
'officer-slskd': 'supervises the slskd daemon',
|
||||
'officer-headscale': 'fronts a headscale server',
|
||||
'officer-transmission': 'fronts a transmission daemon',
|
||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
||||
'officer-jellyfin': 'fronts a Jellyfin container',
|
||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
||||
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
||||
'officer-caldav': 'supervises Radicale, which the light profile does not install',
|
||||
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
|
||||
'officer-wallet': 'holds seed and node credentials',
|
||||
},
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
// macOS light profile — the same process set as the Linux light profile, on a laptop.
|
||||
//
|
||||
// Paired with scripts/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
|
||||
// chat; nothing else.
|
||||
//
|
||||
// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this
|
||||
// file specifically: written on 2026-07-28 as a hand-copied process list, it was broken within days by
|
||||
// two changes it could not see. It ran `officer-claude` against the Anthropic proxy's entry point
|
||||
// while the process that actually spawns `claude` was never started, and it pointed at a pty sidecar
|
||||
// that had moved. Both failures were silent — the processes simply did not come up. See
|
||||
// ecosystem.profile.cjs for the checks that now make that loud.
|
||||
//
|
||||
// WHY THIS IS SEPARATE FROM ecosystem.light.config.cjs, given both currently run the same five apps:
|
||||
// the exclusions mean different things. On macOS officer-vnc cannot run — there is no Xorg to mirror.
|
||||
// On a Linux light install it could run perfectly well; you have chosen not to. Those diverge as soon
|
||||
// as one profile gains something the other cannot have, and collapsing them would lose the reason.
|
||||
//
|
||||
// Start with: pm2 startOrRestart ecosystem.mac.light.config.cjs
|
||||
|
||||
const { defineProfile } = require('./ecosystem.profile.cjs');
|
||||
|
||||
module.exports = defineProfile({
|
||||
file: 'ecosystem.mac.light.config.cjs',
|
||||
|
||||
include: [
|
||||
'officer', // the app: SPA, /api, websockets
|
||||
'officer-anthropic-proxy', // holds the Anthropic credential, forwards to api.anthropic.com
|
||||
// Spawns `claude`. Reads the proxy secret from disk, so it needs no ordering against the proxy
|
||||
// above: if the secret is not written yet it warns and re-reads before the next spawn.
|
||||
'officer-agent',
|
||||
'officer-opencode', // the alternative agent
|
||||
// The terminal. Runs under node rather than bun — node-pty binds a native addon built against
|
||||
// node's ABI. That detail lives in ecosystem.config.cjs, not here.
|
||||
'officer-pty',
|
||||
],
|
||||
|
||||
excluded: {
|
||||
// Cannot run on macOS at all.
|
||||
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
|
||||
|
||||
// Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed
|
||||
// locally — it points at a remote instance over the network — but a baseline process shows up in the dock
|
||||
// and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It
|
||||
// is an app-store install now: `existing` mode, URL and token, same as any other remote service.
|
||||
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
||||
|
||||
// Would run, but needs something setup_mac_light.sh deliberately does not install.
|
||||
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
|
||||
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
|
||||
'officer-music': 'the ffprobe indexer works, but a full ~/Music index is expensive to start by default',
|
||||
|
||||
// Fronts a container or daemon a laptop is not running.
|
||||
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
|
||||
'officer-slskd': 'supervises the slskd daemon',
|
||||
'officer-headscale': 'fronts a headscale server',
|
||||
'officer-transmission': 'fronts a transmission daemon',
|
||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
||||
'officer-jellyfin': 'fronts a Jellyfin container',
|
||||
|
||||
// Needs an owner-configured external service.
|
||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
||||
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
||||
|
||||
// Deliberate, for what it holds or who feeds it.
|
||||
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
|
||||
'officer-wallet': 'holds seed and node credentials; not on a laptop',
|
||||
},
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
// Shared machinery for the pm2 install profiles (ecosystem.light.config.cjs,
|
||||
// ecosystem.mac.light.config.cjs).
|
||||
//
|
||||
// A profile is a SUBSET of ecosystem.config.cjs, declared as names plus reasons. It never restates how
|
||||
// a process is launched — `script` and `args` are read from the host file at load — because a
|
||||
// hand-copied process list is exactly what failed here: the macOS list was written on 2026-07-28 and
|
||||
// within days was starting a sidecar that had been split in two and pointing at a pty entry point that
|
||||
// had moved. Neither failure said anything; the processes simply did not come up.
|
||||
//
|
||||
// So the rule is: ecosystem.config.cjs is the only place a launch command is written down, and a
|
||||
// profile only decides which of them to run.
|
||||
//
|
||||
// Two consistency checks, both of which turn a silent breakage into a loud one at load:
|
||||
// 1. a name the profile INCLUDES that the host no longer defines — the app was renamed or removed
|
||||
// 2. an app the host defines that the profile neither includes nor excludes — a new sidecar, which
|
||||
// must be classified deliberately rather than defaulting to absent because nobody noticed
|
||||
//
|
||||
// The second is the one that matters over time. Without it, every sidecar added to the host silently
|
||||
// stays out of every profile, and the profiles quietly stop meaning what their comments claim.
|
||||
|
||||
/**
|
||||
* @param {object} spec
|
||||
* @param {string} spec.file this profile's filename, for error messages
|
||||
* @param {string[]} spec.include app names to run, in start order
|
||||
* @param {Record<string,string>} spec.excluded app name → why it is not in this profile
|
||||
*/
|
||||
function defineProfile({ file, include, excluded }) {
|
||||
const full = require('./ecosystem.config.cjs');
|
||||
const byName = new Map(full.apps.map((app) => [app.name, app]));
|
||||
|
||||
const missing = include.filter((name) => !byName.has(name));
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`${file}: ${missing.join(', ')} not found in ecosystem.config.cjs — the app was renamed or ` +
|
||||
`removed. Update this profile's include list.`,
|
||||
);
|
||||
}
|
||||
|
||||
const unclassified = full.apps
|
||||
.map((app) => app.name)
|
||||
.filter((name) => !include.includes(name) && !(name in excluded));
|
||||
if (unclassified.length) {
|
||||
throw new Error(
|
||||
`${file}: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither included nor ` +
|
||||
`excluded here. Add it to the include list, or to the excluded map with a reason.`,
|
||||
);
|
||||
}
|
||||
|
||||
// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does
|
||||
// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls
|
||||
// back to PORT=5000 with no POSTGRES_URL. __dirname is the repo root — this file sits beside
|
||||
// ecosystem.config.cjs.
|
||||
return { apps: include.map((name) => ({ ...byName.get(name), cwd: __dirname })) };
|
||||
}
|
||||
|
||||
module.exports = { defineProfile };
|
||||
+3
-3
@@ -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",
|
||||
@@ -19,13 +19,13 @@
|
||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||
"build:landing": "bun run ./scripts/build/landing.ts",
|
||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||
"db:push": "cd src/databases/officer_db && bun run push",
|
||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||
"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/install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# Extracting a feature into a plugin
|
||||
|
||||
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
||||
|
||||
**This directory holds documentation, not plugins.** Every plugin is its own repository as of
|
||||
2026-08-15, and arrives in `plugins/<name>/` by `git clone` when somebody installs it — so on a fresh
|
||||
checkout the four references below are URLs, and on a machine where they are installed they are also
|
||||
directories. Both are given.
|
||||
|
||||
**Read first, in this order:**
|
||||
|
||||
1. [`plugins/offscale` → `PLUGIN.md`](https://gitea.officer.dev/plugins/offscale/src/branch/main/PLUGIN.md)
|
||||
— every decision and why, including the three that reversed
|
||||
2. [`plugins/example`](https://gitea.officer.dev/plugins/example) — the reference implementation,
|
||||
deliberately the smallest thing that is still a real plugin
|
||||
3. [`plugins/offscale`](https://gitea.officer.dev/plugins/offscale) — the worked example, all four parts
|
||||
4. [`plugins/music` → `PLUGIN.md`](https://gitea.officer.dev/plugins/music/src/branch/main/PLUGIN.md)
|
||||
— the MESSY worked example: three pieces that stayed behind, and why each is a seam rather than a
|
||||
loose end. Read it if your feature has anything the platform also uses.
|
||||
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`,
|
||||
`schema`, `generate`. This one IS in this repository: the platform owns the plugin system, and only
|
||||
the plugins left.
|
||||
|
||||
---
|
||||
|
||||
## The rules. These are not preferences
|
||||
|
||||
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
||||
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
||||
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
||||
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||
|
||||
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
||||
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||
|
||||
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||
|
||||
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
||||
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
||||
structural is convention — presence is the declaration:
|
||||
|
||||
```
|
||||
manifest.ts required
|
||||
api/router.ts a backend router, mounted at mountPrefix()
|
||||
db/schema.ts tables, prefixed <app-name>_
|
||||
sidecar/index.ts a process (.mjs instead means node)
|
||||
web/panels.ts panels — REQUIRED with web/
|
||||
web/layout.ts layout — REQUIRED with web/
|
||||
```
|
||||
|
||||
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
||||
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
||||
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
||||
Music added the field; see its PLUGIN.md for what it is guarding against.
|
||||
|
||||
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||
|
||||
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||
anywhere else means two systems, and only one gets tested.
|
||||
|
||||
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
||||
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
||||
on uninstall. Only deleting a plugin's source can lose its data.
|
||||
|
||||
---
|
||||
|
||||
## The order that worked
|
||||
|
||||
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
||||
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
||||
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
||||
`plugins/<name>/db/`.
|
||||
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
||||
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
||||
reference a plugin may make.
|
||||
4. **Write `manifest.ts`.**
|
||||
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
||||
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||
and `helpers/clipboard` stay as they are.
|
||||
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
||||
import, the `permissions/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
||||
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||
**both** database barrels (`index.ts` and `schema.ts`).
|
||||
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
||||
8. **Verify on the live server** — see below.
|
||||
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
||||
|
||||
---
|
||||
|
||||
## Verification — run all of it
|
||||
|
||||
```
|
||||
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||
|
||||
| Step | Expect |
|
||||
| ------------------------------- | ----------------------------------------------------------- |
|
||||
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||
| the plugin's API | answers |
|
||||
| the plugin's screen | renders as a Workspace |
|
||||
| dock | tile appears |
|
||||
| permissions page | its permission is listed, read/write/none |
|
||||
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||
| enable | comes back |
|
||||
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||
| install again | identical to the first install |
|
||||
|
||||
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||
|
||||
---
|
||||
|
||||
## Traps, all of which cost real time once
|
||||
|
||||
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
||||
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
||||
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
||||
`install.ts`; do not reorder it.
|
||||
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
||||
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
||||
pending for the whole protocol.
|
||||
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
||||
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||
contract change. Regenerate rather than hand-edit.
|
||||
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `permissionAvailability`
|
||||
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
||||
`plugin_installs`. A leftover catalogue entry therefore makes the permission permanently `unavailable`,
|
||||
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
||||
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
||||
`catalogue.ts` is the one to read.
|
||||
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
||||
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
||||
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
||||
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
||||
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||
it. Find those before you plan the split; they decide it for you.
|
||||
|
||||
---
|
||||
|
||||
## Music is done. What it changed about this runbook
|
||||
|
||||
Extracted 2026-08-15 and verified live through the whole table above.
|
||||
[`plugins/music` → `PLUGIN.md`](https://gitea.officer.dev/plugins/music/src/branch/main/PLUGIN.md) is the
|
||||
record; the parts worth carrying forward are already folded into the rules and traps above.
|
||||
|
||||
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
||||
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
||||
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
||||
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
||||
none, so it came out whole and made the job look cleaner than it is.
|
||||
|
||||
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
||||
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
||||
not tidy them into a plugin as a warm-up.
|
||||
|
||||
### The global-overlay question is answered, and the answer is no
|
||||
|
||||
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
||||
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
||||
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
||||
gated on its plugin's permission so it switches itself off with the plugin.
|
||||
|
||||
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
||||
|
||||
---
|
||||
|
||||
## Which one next
|
||||
|
||||
No decision has been made. What the tree says, for whoever picks it:
|
||||
|
||||
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
||||
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
||||
is exactly what its extraction needs.
|
||||
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
||||
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
||||
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
||||
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
||||
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
||||
way neither of the first two did. Read `docs/secret-store.md` first.
|
||||
|
||||
## Still open, platform-wide. Do not rediscover these
|
||||
|
||||
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
||||
music would have been the first and cliamp being out of scope is what let it pass.
|
||||
- **`assertPermissionTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||
The live example is the two cliamp sockets: served in the route table, claimed by no permission, and
|
||||
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
||||
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||
owner's. Its own job, not the platform's.
|
||||
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
||||
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
||||
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
||||
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
||||
|
||||
---
|
||||
|
||||
## Where the plugins went
|
||||
|
||||
| Plugin | Repository | In this repo? |
|
||||
| ---------- | ------------------------------------ | ------------- |
|
||||
| `example` | `gitea.officer.dev/plugins/example` | no |
|
||||
| `offscale` | `gitea.officer.dev/plugins/offscale` | no |
|
||||
| `music` | `gitea.officer.dev/plugins/music` | no |
|
||||
|
||||
All three are public and clone anonymously over https, which is what the marketplace requires — it
|
||||
clones with no credentials on purpose, so a private plugin cannot be installed from it at all.
|
||||
|
||||
`.gitignore` ignores `/plugins/*/` — directories only, so this file and anything beside it stay tracked.
|
||||
The rule exists because a cloned plugin carries its own `.git`, and a tracked one turns `git add -A` into
|
||||
a commit of a gitlink: a pointer to a commit this repository does not contain. That is not a mistake to
|
||||
be careful about, it is what installing a plugin does, so it is handled by rule.
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+121
@@ -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
|
||||
@@ -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"
|
||||
|
||||
+25
-4
@@ -16,18 +16,39 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const template = join(root, 'src/apps/officer-web/index.html');
|
||||
const output = join(root, 'src/apps/officer-web/index.gen.html');
|
||||
|
||||
// The server reads .env through --env-file, but this script runs standalone.
|
||||
// Where the URL comes from, most specific first:
|
||||
//
|
||||
// 1. the first argument `bun gen:index https://officer.example.com`
|
||||
// 2. PUBLIC_URL in the environment
|
||||
// 3. PUBLIC_URL in .env (this script runs standalone; the server gets it via --env-file)
|
||||
//
|
||||
// The argument exists so changing the public address is one command rather than an edit plus a
|
||||
// regenerate — and so a second address can be generated for without touching the install's own .env.
|
||||
const argUrl = process.argv[2]?.trim();
|
||||
|
||||
const envPath = join(root, '.env');
|
||||
if (!process.env.PUBLIC_URL && existsSync(envPath)) {
|
||||
if (!argUrl && !process.env.PUBLIC_URL && existsSync(envPath)) {
|
||||
for (const line of (await Bun.file(envPath).text()).split('\n')) {
|
||||
const match = line.match(/^\s*PUBLIC_URL\s*=\s*(.*)$/);
|
||||
if (match) process.env.PUBLIC_URL = match[1]!.trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
const publicUrl = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
|
||||
const publicUrl = (argUrl || process.env.PUBLIC_URL || '').replace(/\/+$/, '');
|
||||
if (!publicUrl) {
|
||||
console.error('[gen-index] PUBLIC_URL is not set — set it in .env (e.g. https://officer.example.com)');
|
||||
console.error('[gen-index] no public URL. Pass one — `bun gen:index https://officer.example.com` —');
|
||||
console.error('[gen-index] or set PUBLIC_URL in .env.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Caught here rather than left to a crawler: a relative or scheme-less value substitutes without
|
||||
// complaint and produces OpenGraph tags nothing can resolve, which is invisible until someone shares a
|
||||
// link and the preview is blank.
|
||||
try {
|
||||
const parsed = new URL(publicUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('not http(s)');
|
||||
} catch {
|
||||
console.error(`[gen-index] "${publicUrl}" is not an absolute http(s) URL — OpenGraph tags need one.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { discoverPlugins } from '../src/servers/plugins/discover';
|
||||
import { generatePluginSchemas } from '../src/servers/plugins/schema';
|
||||
|
||||
// Write `plugin-schemas.gen.ts` from whatever plugins are on disk. Runs as the first half of `db:push`.
|
||||
//
|
||||
// ── The bug this closes ──
|
||||
//
|
||||
// `officer_db/src/schema.ts` ends with `export * from './plugin-schemas.gen'` — UNCONDITIONAL, because
|
||||
// drizzle-kit needs one file listing every table. That generated file is gitignored (it describes this
|
||||
// machine, not the project), and until 2026-08-15 its only writer was `installPlugin`.
|
||||
//
|
||||
// So on a fresh clone the file did not exist, and `bun db:push` died with MODULE_NOT_FOUND before
|
||||
// creating a single table. That is exactly what `scripts/setup/officer-setup.sh` section 8 runs, so
|
||||
// setup failed at the schema step on any clean machine — and the only thing that would have written the
|
||||
// file was installing a plugin, which needs a working database. A deadlock, shipped.
|
||||
//
|
||||
// It is wired into the `db:push` SCRIPT rather than added as a setup step on purpose. Setup is not the
|
||||
// only caller: `pushSchema()` shells out to the same command, a developer types it by hand, and
|
||||
// `git clean -xfd` removes the file at any time. A step someone has to remember is one they only forget
|
||||
// once — the barrel now cannot be stale when push reads it, because push regenerates it.
|
||||
//
|
||||
// Empty is a correct answer, and the common one: a machine with no plugins gets a barrel with no
|
||||
// exports, which is what `schema.ts` needs in order to import it at all.
|
||||
|
||||
const { plugins, broken } = await discoverPlugins();
|
||||
|
||||
// Reported, never thrown. A plugin with an unreadable manifest must not stop the platform's own tables
|
||||
// from being created — the same rule discovery follows everywhere else.
|
||||
for (const { appName, error } of broken) {
|
||||
console.warn(`[plugin-schemas] skipping ${appName}: ${error}`);
|
||||
}
|
||||
|
||||
const withSchema = plugins.filter((p) => p.schema);
|
||||
generatePluginSchemas(plugins);
|
||||
|
||||
console.log(
|
||||
withSchema.length
|
||||
? `[plugin-schemas] ${withSchema.length} plugin schema(s): ${withSchema.map((p) => p.appName).join(', ')}`
|
||||
: '[plugin-schemas] no plugin schemas on disk — wrote an empty barrel',
|
||||
);
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# Officer — install
|
||||
# =============================================================================
|
||||
#
|
||||
# One command, blank machine to running platform. It runs the two halves in
|
||||
# order and does nothing else itself:
|
||||
#
|
||||
# setup/machine-setup/machine-setup.sh a usable machine — packages, tailnet,
|
||||
# runtimes, docker, shell
|
||||
# setup/officer-setup.sh the platform on top of it — repo,
|
||||
# dependencies, postgres, .env, secret
|
||||
# store, schema, build, pm2
|
||||
#
|
||||
# They stay two scripts because they answer two different questions and are worth
|
||||
# running separately: a machine you already trust needs only the second, and a
|
||||
# machine you are rebuilding needs only the first. This is the wrapper for the
|
||||
# case where you want both, which is most first runs.
|
||||
#
|
||||
# Both are re-runnable. Each remembers the steps it finished and skips them, so
|
||||
# stopping halfway and coming back costs nothing.
|
||||
#
|
||||
# Run it as yourself — it asks for administrator rights when it needs them.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MACHINE="$SCRIPT_DIR/setup/machine-setup/machine-setup.sh"
|
||||
OFFICER="$SCRIPT_DIR/setup/officer-setup.sh"
|
||||
|
||||
BOLD='\033[1m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
say() { echo -e "$*"; }
|
||||
die() {
|
||||
echo -e "${YELLOW}error:${NC} $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -r "$MACHINE" ]] || die "missing $MACHINE"
|
||||
[[ -r "$OFFICER" ]] || die "missing $OFFICER"
|
||||
|
||||
# Which halves to run. Both by default.
|
||||
RUN_MACHINE=true
|
||||
RUN_OFFICER=true
|
||||
|
||||
# Kept before the loop below eats them: this script re-executes itself through sudo
|
||||
# further down, and `shift` would otherwise leave it re-running with no arguments —
|
||||
# silently dropping --officer-only and turning a platform-only run into a full one.
|
||||
#
|
||||
# The `${x[@]+"${x[@]}"}` form is for `set -u`: expanding an empty array unquoted-safe
|
||||
# is an error on bash before 4.4, and this runs on whatever the machine came with.
|
||||
ORIGINAL_ARGS=(${@+"$@"})
|
||||
|
||||
# A `while`/`shift` loop rather than `for arg in "$@"`, because --repo takes a value
|
||||
# and a for-loop cannot consume the argument after it.
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--machine-only) RUN_OFFICER=false ;;
|
||||
--officer-only) RUN_MACHINE=false ;;
|
||||
--repo)
|
||||
[[ -n "${2:-}" ]] || die "--repo needs a URL"
|
||||
OFFICER_REPO="$2"
|
||||
shift
|
||||
;;
|
||||
--repo=*) OFFICER_REPO="${1#--repo=}" ;;
|
||||
# Every question that HAS a default answers itself. The ones with no possible
|
||||
# default still ask — see the note above the run below.
|
||||
--unattended | -y)
|
||||
export UNATTENDED=1 ASSUME_YES=1
|
||||
;;
|
||||
-h | --help)
|
||||
say "usage: install.sh [--machine-only | --officer-only] [--repo <url>]"
|
||||
say ""
|
||||
say " no flags both halves, machine first"
|
||||
say " --machine-only stop after the machine is provisioned"
|
||||
say " --officer-only the platform only, on a machine you already trust"
|
||||
say " --repo <url> clone the platform from here instead of the default"
|
||||
say " --unattended take the default for every question that has one (-y)"
|
||||
say ""
|
||||
say " The default is a private Gitea over SSH, which only authenticates on a"
|
||||
say " machine whose key it already knows. Pass an https URL on a fresh box."
|
||||
say ""
|
||||
say " --unattended still asks the questions that have no possible default:"
|
||||
say " the username, the Tailscale control plane / login server / auth key,"
|
||||
say " an SSH public key when the account has none, and the git identity."
|
||||
say " Answer those ahead of time with SETUP_USERNAME, TS_LOGIN_SERVER,"
|
||||
say " TS_AUTHKEY and TIMEZONE to reduce it further."
|
||||
exit 0
|
||||
;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Exported so `officer-setup.sh` reads it from the environment and this script does
|
||||
# not have to forward arguments it does not own. `lib/repo.sh` takes it as
|
||||
# `${OFFICER_REPO:-<default>}`, so unset here still means the default there.
|
||||
[[ -n "${OFFICER_REPO:-}" ]] && export OFFICER_REPO
|
||||
|
||||
KERNEL="$(uname -s)"
|
||||
case "$KERNEL" in
|
||||
Darwin)
|
||||
[[ "$EUID" -eq 0 ]] && die "do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
|
||||
;;
|
||||
Linux) ;;
|
||||
*) die "unsupported system: $KERNEL. Officer installs on Linux and macOS." ;;
|
||||
esac
|
||||
|
||||
SELF="$SCRIPT_DIR/install.sh"
|
||||
|
||||
# One report for the whole run, not one per half. Both scripts append to this
|
||||
# file, so the person reviewing it sees a single account of what happened rather
|
||||
# than two they have to stitch together and hope are complete.
|
||||
#
|
||||
# Exported before either half starts, and timestamped once here — if each script
|
||||
# made its own name they would differ by however long the first one took.
|
||||
export REPORT_FILE="${REPORT_FILE:-${HOME}/officer-install-report-$(date '+%Y%m%d-%H%M%S').md}"
|
||||
|
||||
# ── Privileges: asked for, not demanded ──
|
||||
#
|
||||
# Run this as YOURSELF. On Linux it needs root for apt, systemd units, useradd,
|
||||
# netplan, ufw and for creating directories owned by the service account — so it
|
||||
# asks, once, through sudo, and re-executes itself. Typing `sudo` yourself works
|
||||
# too and changes nothing, but it should not be the price of starting.
|
||||
#
|
||||
# Variables are passed to sudo explicitly rather than with -E. `env_reset` is the
|
||||
# sudoers default and strips the environment, which is how DATA_PATH was lost
|
||||
# once already; naming them on the command line survives it.
|
||||
#
|
||||
# macOS never escalates. Homebrew refuses to run as root, and nothing in the
|
||||
# macOS path needs it — the account running this IS the owner, so there is
|
||||
# nothing to chown and nothing to drop privileges to.
|
||||
if [[ "$KERNEL" != "Darwin" && "$EUID" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || die "this needs root and sudo is not installed — run it as root"
|
||||
say ""
|
||||
say " This needs administrator rights. You will be asked for your password."
|
||||
say ""
|
||||
exec sudo \
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
||||
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
||||
REPORT_FILE="${REPORT_FILE:-}" \
|
||||
UNATTENDED="${UNATTENDED:-}" \
|
||||
ASSUME_YES="${ASSUME_YES:-}" \
|
||||
OFFICER_REPO="${OFFICER_REPO:-}" \
|
||||
bash "$SELF" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
||||
fi
|
||||
|
||||
|
||||
say ""
|
||||
say "${BOLD}Officer install${NC}"
|
||||
say " system: $KERNEL"
|
||||
$RUN_MACHINE && say " 1/2 machine setup"
|
||||
$RUN_OFFICER && say " $($RUN_MACHINE && echo 2/2 || echo 1/1) officer setup"
|
||||
say ""
|
||||
say " Either half can be run on its own later:"
|
||||
say " scripts/setup/machine-setup/machine-setup.sh"
|
||||
say " scripts/setup/officer-setup.sh"
|
||||
say ""
|
||||
|
||||
# Not `set -e`'s job: a half that exits non-zero should say which half, and stop
|
||||
# before the next one starts on a machine that is not ready for it.
|
||||
# ── Who says "you are still root" ──
|
||||
#
|
||||
# Both halves end as root and both need to say so, but only the LAST one to run
|
||||
# should — otherwise a full install says it twice, once in the middle where it is
|
||||
# wrong, because officer-setup is about to run and still needs the privilege.
|
||||
#
|
||||
# So the rule is "say it if nothing follows you", and this is the only place that
|
||||
# knows whether anything does.
|
||||
if $RUN_MACHINE; then
|
||||
$RUN_OFFICER && export OFFICER_SETUP_FOLLOWS=1
|
||||
bash "$MACHINE" || die "machine setup did not finish — fix what it reported, then run this again"
|
||||
unset OFFICER_SETUP_FOLLOWS
|
||||
fi
|
||||
|
||||
if $RUN_OFFICER; then
|
||||
bash "$OFFICER" || die "officer setup did not finish — fix what it reported, then run this again"
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "${GREEN}Done.${NC}"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -10,7 +10,9 @@
|
||||
import type { BrowsedFile } from 'officerdb';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { db, finishSoulseekBrowse } from 'officerdb';
|
||||
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/schema';
|
||||
// soulseek is a plugin, so its tables are commented out of officerdb's schema aggregator —
|
||||
// import them from the feature directly.
|
||||
import { soulseekBrowseSnapshots, soulseekBrowseDirs } from 'officerdb/soulseek/schema';
|
||||
import { buildTree } from '../src/servers/sidecar/slskd/browse';
|
||||
|
||||
const snapshots = await db
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Trigger the music library index on the officer-music sidecar and follow its progress live,
|
||||
* ending with a summary report. Run from the platform repo: bun scripts/reindex-music.ts
|
||||
*
|
||||
* It reads the sidecar's port from DATA_PATH/music/.server (written by the sidecar on startup) and
|
||||
* consumes its /reindex/stream SSE endpoint — the same stream the app subscribes to.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const PORT_FILE = join(DATA_PATH, 'music', '.server');
|
||||
|
||||
type Progress = {
|
||||
running: boolean;
|
||||
foldersScanned: number;
|
||||
albumsBuilt: number;
|
||||
albumsSkipped: number;
|
||||
tracksIndexed: number;
|
||||
coversSaved: number;
|
||||
currentPath: string;
|
||||
};
|
||||
type Report = {
|
||||
albums: number;
|
||||
built: number;
|
||||
skipped: number;
|
||||
foldersScanned: number;
|
||||
tracksIndexed: number;
|
||||
coversSaved: number;
|
||||
discographies: number;
|
||||
elapsedSec: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function readPort(): number {
|
||||
try {
|
||||
const p = parseInt(readFileSync(PORT_FILE, 'utf8').trim(), 10);
|
||||
if (Number.isInteger(p) && p > 0) return p;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
console.error(`✗ Could not read the sidecar port from ${PORT_FILE}.`);
|
||||
console.error(' Is officer-music running? pm2 restart officer-music');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isTTY = Boolean(process.stdout.isTTY);
|
||||
const cols = () => (process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100);
|
||||
|
||||
function printProgress(p: Progress): void {
|
||||
const line =
|
||||
`♪ indexing… folders ${p.foldersScanned} · tracks ${p.tracksIndexed} · ` +
|
||||
`built ${p.albumsBuilt} · skipped ${p.albumsSkipped} · covers ${p.coversSaved}` +
|
||||
(p.currentPath ? ` · ${p.currentPath}` : '');
|
||||
if (isTTY) {
|
||||
const clipped = line.length > cols() - 1 ? line.slice(0, cols() - 2) + '…' : line;
|
||||
process.stdout.write('\r\x1b[2K' + clipped);
|
||||
} else {
|
||||
process.stdout.write(line + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function printReport(r: Report): void {
|
||||
if (isTTY) process.stdout.write('\r\x1b[2K');
|
||||
const l = (k: string, v: string | number) => console.log(` ${k.padEnd(9)} ${v}`);
|
||||
console.log('\n─── Music index complete ───');
|
||||
l('Albums:', `${r.albums} (${r.built} built, ${r.skipped} unchanged)`);
|
||||
l('Tracks:', `${r.tracksIndexed} indexed`);
|
||||
l('Covers:', `${r.coversSaved} compressed`);
|
||||
l('Discogs:', `${r.discographies} artist${r.discographies === 1 ? '' : 's'}`);
|
||||
l('Folders:', `${r.foldersScanned} scanned`);
|
||||
l('Elapsed:', `${r.elapsedSec}s`);
|
||||
if (r.error) l('Error:', r.error);
|
||||
console.log('────────────────────────────\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const port = readPort();
|
||||
const url = `http://127.0.0.1:${port}/reindex/stream`;
|
||||
console.log(`Triggering music index via ${url}\n`);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url);
|
||||
} catch (err) {
|
||||
console.error(`✗ Could not reach the sidecar at 127.0.0.1:${port}: ${String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
console.error(`✗ Sidecar returned ${res.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let sep: number;
|
||||
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
||||
const frame = buf.slice(0, sep);
|
||||
buf = buf.slice(sep + 2);
|
||||
let event = 'message';
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
if (event === 'progress') printProgress(JSON.parse(data) as Progress);
|
||||
else if (event === 'done') printReport(JSON.parse(data) as Report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,137 @@
|
||||
import postgres from 'postgres';
|
||||
|
||||
// One-time database migration for the 2026-08-15 rename: `role_capabilities` → `role_permissions`.
|
||||
//
|
||||
// ── Why this is a script and not `bun db:push` ──
|
||||
//
|
||||
// drizzle-kit does not understand renames. It sees a table gone and a table added, and with `--force` it
|
||||
// resolves that by DROPPING and CREATING — which would delete every grant on the server and silently
|
||||
// reduce every member to core-only access. There is no prompt to catch it, because `--force` exists to
|
||||
// answer prompts.
|
||||
//
|
||||
// So the database is renamed by hand, first, and `db:push` afterwards should report "No changes
|
||||
// detected" — which is the proof that the two now agree.
|
||||
//
|
||||
// ── Why a raw connection rather than `officerdb/db` ──
|
||||
//
|
||||
// It used `officerdb/db` and died on its first real use, on a production server: that module imports
|
||||
// `schema.ts`, which imports the gitignored `plugin-schemas.gen.ts`, which does not exist on a fresh
|
||||
// clone. MODULE_NOT_FOUND, before a single statement ran. It had been tested against a scratch database
|
||||
// on a machine where the barrel happened to exist.
|
||||
//
|
||||
// A migration issues ALTER statements. It has no business needing the application's schema barrel, its
|
||||
// table objects or its query layer — so it opens its own connection and takes none of them.
|
||||
//
|
||||
// ── Safe to run twice, and safe to run on a server that never had the old names ──
|
||||
//
|
||||
// Every step checks first. A machine already migrated prints "already done" and touches nothing; a fresh
|
||||
// install that never had `role_capabilities` is not an error either. That matters because this will be
|
||||
// run by hand, on more than one machine, possibly twice on the same one.
|
||||
//
|
||||
// Run it BEFORE restarting the platform on the new code. The old code cannot read `role_permissions` and
|
||||
// the new code cannot read `role_capabilities`, so the window between them is the outage — keep it short:
|
||||
//
|
||||
// pm2 stop officer && bun run scripts/rename-capabilities-to-permissions.ts && bun db:push && pm2 restart all
|
||||
//
|
||||
// If it goes wrong: the OWNER is unaffected either way. `getEffectivePermissions` short-circuits on
|
||||
// `role === 'Super Admin'` before it reads the table at all, so the account that can fix things can
|
||||
// always sign in. Non-owners degrade to core-only until the rename completes.
|
||||
|
||||
const url = process.env.POSTGRES_URL;
|
||||
if (!url) {
|
||||
console.error(' POSTGRES_URL is not set. Run from the platform directory so Bun loads .env.');
|
||||
process.exit(1);
|
||||
}
|
||||
const db = postgres(url);
|
||||
|
||||
/** Every read below. Parameterised where it takes a value; nothing here interpolates user input. */
|
||||
const q = (text: string, params: unknown[] = []): Promise<Record<string, unknown>[]> =>
|
||||
db.unsafe(text, params as never[]) as unknown as Promise<Record<string, unknown>[]>;
|
||||
|
||||
const tableExists = async (name: string): Promise<boolean> =>
|
||||
((await q('select to_regclass($1) as t', [`public.${name}`]))[0]?.t ?? null) !== null;
|
||||
|
||||
const columnExists = async (table: string, column: string): Promise<boolean> =>
|
||||
(await q('select 1 from information_schema.columns where table_name = $1 and column_name = $2', [table, column]))
|
||||
.length > 0;
|
||||
|
||||
const relationExists = async (name: string): Promise<boolean> =>
|
||||
((await q('select to_regclass($1) as t', [`public.${name}`]))[0]?.t ?? null) !== null;
|
||||
|
||||
const constraintExists = async (table: string, name: string): Promise<boolean> =>
|
||||
(await q('select 1 from pg_constraint where conname = $1 and conrelid = to_regclass($2)', [name, `public.${table}`]))
|
||||
.length > 0;
|
||||
|
||||
async function main() {
|
||||
const hasOld = await tableExists('role_capabilities');
|
||||
const hasNew = await tableExists('role_permissions');
|
||||
|
||||
if (!hasOld && !hasNew) {
|
||||
console.log(' Neither table exists — nothing to migrate. `bun db:push` will create role_permissions.');
|
||||
return;
|
||||
}
|
||||
if (!hasOld && hasNew) {
|
||||
console.log(' Already migrated: role_permissions exists and role_capabilities does not. Nothing to do.');
|
||||
const rows = await q('select count(*)::int as n from role_permissions');
|
||||
console.log(` Grants on this server: ${rows[0]?.n}`);
|
||||
return;
|
||||
}
|
||||
if (hasOld && hasNew) {
|
||||
console.error(' BOTH tables exist. That is not a state this script can resolve safely — stopping.');
|
||||
console.error(' Look at both by hand and decide which holds the real grants.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Count before, so the transaction can be checked against something rather than trusted.
|
||||
const before = Number((await q('select count(*)::int as n from role_capabilities'))[0]?.n ?? 0);
|
||||
console.log(` Found role_capabilities with ${before} grant(s). Renaming…`);
|
||||
|
||||
// EVERY existence check happens HERE, before the transaction, and against the OLD names.
|
||||
//
|
||||
// They used to be inside it, and that was a real bug caught by testing against a copy of a production
|
||||
// database rather than by reading: the helpers run on the pool, not on `tx`, so inside the transaction
|
||||
// they cannot see its uncommitted rename. `columnExists('role_permissions', 'capability')` answered
|
||||
// false — the table did not exist yet as far as that connection was concerned — so the column rename
|
||||
// was silently skipped and the migration produced a `role_permissions` table with a `capability`
|
||||
// column. Half migrated, and the failure only surfaced on the next query.
|
||||
const hasOldColumn = await columnExists('role_capabilities', 'capability');
|
||||
const hasOldUnique = await relationExists('uq_role_capabilities_role_capability');
|
||||
const hasOldPkey = await relationExists('role_capabilities_pkey');
|
||||
const oldChecks: string[] = [];
|
||||
for (const suffix of ['role', 'level', 'not_owner']) {
|
||||
if (await constraintExists('role_capabilities', `ck_role_capabilities_${suffix}`)) oldChecks.push(suffix);
|
||||
}
|
||||
|
||||
// One transaction. A partial rename leaves drizzle-kit seeing a table it half-recognises, and the next
|
||||
// `push --force` would resolve that difference by dropping it.
|
||||
await db.begin(async (tx) => {
|
||||
await tx.unsafe('ALTER TABLE role_capabilities RENAME TO role_permissions');
|
||||
if (hasOldColumn) await tx.unsafe('ALTER TABLE role_permissions RENAME COLUMN capability TO permission');
|
||||
// Index and constraint names are renamed too. drizzle-kit diffs on the NAME, so leaving them would
|
||||
// make every future push want to drop and recreate them.
|
||||
if (hasOldUnique) {
|
||||
await tx.unsafe('ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission');
|
||||
}
|
||||
if (hasOldPkey) await tx.unsafe('ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey');
|
||||
for (const suffix of oldChecks) {
|
||||
// `suffix` comes from a hardcoded list three lines up, never from input.
|
||||
await tx.unsafe(
|
||||
`ALTER TABLE role_permissions RENAME CONSTRAINT ck_role_capabilities_${suffix} TO ck_role_permissions_${suffix}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const after = Number((await q('select count(*)::int as n from role_permissions'))[0]?.n ?? 0);
|
||||
console.log(` Renamed. Grants after: ${after}${after === before ? ' — unchanged, as expected.' : ' — MISMATCH!'}`);
|
||||
if (after !== before) process.exitCode = 1;
|
||||
|
||||
const rows = await q('select role, permission, level from role_permissions order by role, permission');
|
||||
for (const r of rows) console.log(` ${r.role} → ${r.permission} (${r.level})`);
|
||||
|
||||
console.log('\n Next: `bun db:push` (expect "No changes detected"), then `pm2 restart all`.');
|
||||
}
|
||||
|
||||
await main();
|
||||
await db.end();
|
||||
process.exit(process.exitCode ?? 0);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
Executable
+258
@@ -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 ""
|
||||
Executable
+1179
File diff suppressed because it is too large
Load Diff
@@ -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 ""
|
||||
@@ -0,0 +1,707 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — shared foundation
|
||||
# =============================================================================
|
||||
#
|
||||
# Sourced by machine-setup.sh before anything runs. DEFINITIONS ONLY: this file
|
||||
# declares state and functions and must never install, write or restart
|
||||
# anything. Sourcing it has to be safe at any point, including from a step that
|
||||
# is only being read for its variables.
|
||||
#
|
||||
# The one thing it expects from its caller, because they are facts about the
|
||||
# entry point rather than about this library:
|
||||
#
|
||||
# SCRIPT_DIR directory of the script being run
|
||||
# PROGRESS_FILE where completed step names are recorded
|
||||
#
|
||||
# Everything else below is owned here.
|
||||
|
||||
# Guard against being sourced twice — steps will eventually source this
|
||||
# directly so they can be run on their own, and re-running it would reset
|
||||
# SUMMARY and lose everything recorded so far.
|
||||
[[ -n "${MACHINE_SETUP_BASE_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_BASE_LOADED=1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared state
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
SUMMARY=() # what was done, printed at the end
|
||||
ERRORS=() # non-fatal failures, printed at the end
|
||||
CURRENT_STEP=""
|
||||
SKIP_STEP=false
|
||||
|
||||
# What machine this is. Filled in by detect_os() before any step runs; every step
|
||||
# after that branches on these rather than assuming apt on x86_64.
|
||||
OS="" # os-release ID: ubuntu | debian | arch | fedora | macos | …
|
||||
OS_NAME="" # pretty name, for the banner
|
||||
OS_VERSION="" # version id; empty on rolling releases
|
||||
PM="" # apt | pacman | dnf | brew
|
||||
ARCH="" # amd64 | arm64, normalised — upstream tarballs disagree on spelling
|
||||
IS_WSL=false
|
||||
|
||||
# What this box is FOR. Asked once in pre-flight and consulted by the steps
|
||||
# afterwards, because several of them have a different right answer per role and
|
||||
# no way to work it out on their own:
|
||||
#
|
||||
# homelab a machine you physically control on a network you own
|
||||
# vps rented, public IP, someone else's DHCP and console
|
||||
# dev a laptop or desktop you sit at
|
||||
#
|
||||
# Set MACHINE_ROLE in the environment to answer it ahead of time — hence the
|
||||
# :- default rather than a plain assignment, which would wipe what the caller
|
||||
# passed in before ask_machine_role ever looked at it.
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Output
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}::${NC} $*"; }
|
||||
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
||||
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
||||
fail() {
|
||||
echo -e " ${RED}FAIL${NC}: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Steps and resume
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# A step announces itself, and is skipped when its name is already in the
|
||||
# progress file. step_ok records it. The pattern at each call site is:
|
||||
#
|
||||
# step "Name"
|
||||
# if ! skip; then
|
||||
# …
|
||||
# step_ok
|
||||
# fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Remembering the answers
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Pre-flight asks four things — role, account, where Officer goes — and every
|
||||
# section needs them. Asking again on every run made a resumed run re-answer
|
||||
# questions it had already been told, and made --only unusable: four questions to
|
||||
# reach one section.
|
||||
#
|
||||
# Saved beside the progress file, and loaded before anything is asked. The
|
||||
# environment still wins, so SETUP_USERNAME=x on the command line overrides what
|
||||
# was saved.
|
||||
|
||||
ANSWERS_FILE="${ANSWERS_FILE:-}"
|
||||
|
||||
save_answers() {
|
||||
[[ -n "$ANSWERS_FILE" ]] || return 0
|
||||
cat >"$ANSWERS_FILE" <<EOF
|
||||
# Written by machine-setup. Delete this to be asked again.
|
||||
MACHINE_ROLE=${MACHINE_ROLE}
|
||||
SETUP_USERNAME=${USERNAME}
|
||||
OFFICER_ROOT=${OFFICER_ROOT}
|
||||
EOF
|
||||
chmod 600 "$ANSWERS_FILE"
|
||||
}
|
||||
|
||||
# Loaded as assignments, not sourced as a script: this file sits beside the
|
||||
# script and is read by a root run, so it should not be able to execute anything.
|
||||
load_answers() {
|
||||
[[ -n "$ANSWERS_FILE" && -r "$ANSWERS_FILE" ]] || return 0
|
||||
local key value
|
||||
while IFS='=' read -r key value; do
|
||||
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
||||
[[ -n "$value" ]] || continue
|
||||
# The environment wins over what was saved.
|
||||
#
|
||||
# Written as if/then rather than `[[ … ]] && assign`.
|
||||
#
|
||||
# That form returns non-zero when the test is false. Harmless on its own —
|
||||
# `set -e` exempts the left side of an && list — but here it is the last
|
||||
# thing the case runs, the case is the last thing the loop body runs, and the
|
||||
# loop is the last thing THE FUNCTION runs. So load_answers returned
|
||||
# non-zero, and calling a function that returns non-zero is a plain command
|
||||
# failure, which does end the script.
|
||||
#
|
||||
# It needed the answers file to exist AND the variables to be set already, so
|
||||
# it only appeared when running with env overrides. The trap reported "Step:
|
||||
# unknown" at a line inside this library, before pre-flight had run.
|
||||
#
|
||||
# The general rule this is an instance of: a function whose last statement
|
||||
# can return non-zero fails when it is called, however innocuous the
|
||||
# statement looks.
|
||||
case "$key" in
|
||||
MACHINE_ROLE) if [[ -z "${MACHINE_ROLE:-}" ]]; then MACHINE_ROLE="$value"; fi ;;
|
||||
SETUP_USERNAME) if [[ -z "${SETUP_USERNAME:-}" ]]; then SETUP_USERNAME="$value"; fi ;;
|
||||
OFFICER_ROOT) if [[ -z "${OFFICER_ROOT:-}" ]]; then OFFICER_ROOT="$value"; fi ;;
|
||||
esac
|
||||
done <"$ANSWERS_FILE"
|
||||
}
|
||||
|
||||
# Set by --only. When it is set, every step whose name does not match is passed
|
||||
# over in silence, and the one that does matches runs regardless of the progress
|
||||
# file — the point of asking for a single step is to run that step.
|
||||
ONLY_STEP="${ONLY_STEP:-}"
|
||||
|
||||
# ── Steps that do not exist on macOS ──
|
||||
#
|
||||
# A Mac running Officer is a DEV MACHINE, never a server. That is not a
|
||||
# simplification to revisit: nobody puts a laptop behind a public hostname and
|
||||
# hands it a tailnet exit node, and the sections below are all about being a
|
||||
# server that is on all the time.
|
||||
#
|
||||
# Most would fail rather than misbehave — there is no systemd, no ufw, no
|
||||
# netplan, no useradd, no /etc/ssh/sshd_config.d. But a few would SUCCEED and be
|
||||
# wrong, which is worse: stopping a laptop from sleeping, or freezing its address
|
||||
# on a network it moves between every day.
|
||||
#
|
||||
# Keyed on the step title, so the sections themselves stay Linux code with no
|
||||
# `if macos` branches threaded through them. The reason is printed, because a
|
||||
# silent skip and a missing step look identical.
|
||||
declare -A MACOS_SKIP=(
|
||||
["User account"]="accounts are System Settings' business on a Mac, not a script's"
|
||||
["Disk space"]="ballast and swap tuning are server concerns"
|
||||
["Locale"]="macOS manages locale itself"
|
||||
["Timezone"]="macOS manages the timezone itself"
|
||||
["Swap"]="macOS sizes its own swap dynamically"
|
||||
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
|
||||
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
|
||||
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
|
||||
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
|
||||
["Boot hang"]="a systemd boot ordering fix"
|
||||
["SSH access"]="hardening a door a dev machine should not be opening"
|
||||
["DNS"]="systemd-resolved"
|
||||
["Network address"]="netplan, and a laptop moves between networks by design"
|
||||
["fail2ban"]="brute-force protection for an exposed SSH port"
|
||||
["Unattended upgrades"]="apt; macOS updates through Software Update"
|
||||
["Firewall"]="ufw; macOS has its own application firewall"
|
||||
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
|
||||
)
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
# The report follows the step, rather than each section remembering to say
|
||||
# which one it is. Twenty-six sections, one place.
|
||||
declare -F report_section >/dev/null && report_section "$1"
|
||||
|
||||
if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
else
|
||||
SKIP_STEP=true
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
||||
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
}
|
||||
|
||||
skip() { [[ "$SKIP_STEP" == true ]]; }
|
||||
|
||||
step_ok() {
|
||||
# A single step run on its own is not progress through the script, and
|
||||
# recording it would make the next full run skip it.
|
||||
[[ -n "$ONLY_STEP" ]] && return 0
|
||||
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
||||
}
|
||||
|
||||
# Try a command, log error but don't exit
|
||||
try() {
|
||||
local label="$1"
|
||||
shift
|
||||
if "$@" 2>&1; then
|
||||
ok "$label"
|
||||
else
|
||||
warn "$label — failed (non-critical, continuing)"
|
||||
ERRORS+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Input
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
prompt_value() {
|
||||
local varname="$1" message="$2" default="$3"
|
||||
# If env var already set, use it silently
|
||||
if [[ -n "${!varname:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
local input
|
||||
if [[ -n "$default" ]]; then
|
||||
read -rp "$message [$default]: " input
|
||||
eval "$varname=\"\${input:-$default}\""
|
||||
else
|
||||
read -rp "$message: " input
|
||||
eval "$varname=\"\$input\""
|
||||
fi
|
||||
}
|
||||
|
||||
# Show long output a screen at a time.
|
||||
#
|
||||
# Only when there is a terminal to page on: with output redirected or piped —
|
||||
# a transcript, a log, the test harness — it has to come through whole, and a
|
||||
# pager would either block or mangle it. `more` rather than `less` because it
|
||||
# exits at the end of the file instead of sitting there waiting to be quit,
|
||||
# which is what you want for something you asked to read once.
|
||||
page() {
|
||||
if [[ -t 1 ]] && command -v more &>/dev/null; then
|
||||
more
|
||||
else
|
||||
cat
|
||||
fi
|
||||
}
|
||||
|
||||
# Ask before acting. Every section that changes the machine goes through this, so
|
||||
# a run is a sequence of things you agreed to rather than a wall of output you
|
||||
# read afterwards to find out what happened.
|
||||
#
|
||||
# Enter means yes — unlike the machine-role question, which has no default. These
|
||||
# are "do the thing you already asked for", and making twenty of them require a
|
||||
# deliberate keystroke would train people to hold the y key down.
|
||||
#
|
||||
# ASSUME_YES=1 answers all of them, for an unattended run.
|
||||
|
||||
# A numbered menu's answer, or its own default when running unattended.
|
||||
#
|
||||
# menu_answer DNS_CHOICE " Which one? (1-5) [1]: "
|
||||
#
|
||||
# ── Why empty, rather than a default passed in ──
|
||||
#
|
||||
# Every menu in this script reads its choice and then consumes it as
|
||||
# `${CHOICE:-<n>}`, so the default already lives at the point of use — which is the
|
||||
# right place, next to the options it selects between. Setting the variable EMPTY is
|
||||
# therefore exactly what pressing Enter does, and it cannot drift from the default
|
||||
# the prompt advertises the way a second copy passed in here would.
|
||||
#
|
||||
# `read <<<''` rather than `eval` or `declare -g`: no eval, and `declare -g` is bash
|
||||
# 4.2+, which rules out the bash 3.2 that macOS still ships.
|
||||
#
|
||||
# The prompt is still printed, with the reason, because a transcript that silently
|
||||
# skips a question reads as a question that was never asked.
|
||||
menu_answer() {
|
||||
local var="$1" prompt="$2"
|
||||
if [[ "${UNATTENDED:-}" == "1" ]]; then
|
||||
printf '%s%s\n' "$prompt" "— unattended, taking the default"
|
||||
read -r "$var" <<<''
|
||||
return 0
|
||||
fi
|
||||
read -rp "$prompt" "$var" || {
|
||||
echo ""
|
||||
fail "No answer."
|
||||
}
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local message="${1:-Proceed?}"
|
||||
# Second argument flips the default. Most questions here are "do the thing you
|
||||
# already asked for" and Enter should mean yes; a few are genuine extras, where
|
||||
# defaulting to yes would have people agreeing to them by reflex.
|
||||
local default="${2:-y}"
|
||||
# Third is the name of a function that explains the question. Where one is
|
||||
# given, `?` becomes an answer — so the explanation is available to whoever
|
||||
# wants it without being in the way of whoever does not.
|
||||
local help_fn="${3:-}"
|
||||
local answer prompt
|
||||
|
||||
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
|
||||
|
||||
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
|
||||
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
|
||||
|
||||
while true; do
|
||||
# EOF is not a yes. Without this an unattended run without ASSUME_YES would
|
||||
# spin here forever.
|
||||
if ! read -rp " ${message} ${prompt}: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
||||
fi
|
||||
[[ -z "$answer" ]] && answer="$default"
|
||||
case "$answer" in
|
||||
y | Y | yes | Yes) return 0 ;;
|
||||
n | N | no | No) return 1 ;;
|
||||
"?")
|
||||
if [[ -n "$help_fn" ]]; then
|
||||
echo ""
|
||||
"$help_fn" | page
|
||||
echo ""
|
||||
else
|
||||
warn "Answer y or n."
|
||||
fi
|
||||
;;
|
||||
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Which account this machine is being set up for.
|
||||
#
|
||||
# Asked at the top because two later questions default off it — where Officer is
|
||||
# installed, and where the disk ballast goes — so it has to be settled before
|
||||
# either is put to the user.
|
||||
#
|
||||
# Defaults to whoever invoked sudo. On a re-run, or on a machine that is already
|
||||
# somebody's, that is the answer every time, and typing it again is a chance to
|
||||
# typo it into creating a second account.
|
||||
#
|
||||
# SETUP_USERNAME in the environment answers it ahead of time. Deliberately not
|
||||
# USERNAME: that name is set by some login environments, and a variable this
|
||||
# script silently obeys should not be one that might already be in the
|
||||
# environment for unrelated reasons.
|
||||
ask_username() {
|
||||
local default="${SUDO_USER:-}" answer
|
||||
|
||||
# root invoked the script directly rather than through sudo. It is never the
|
||||
# account being set up, so there is nothing to suggest.
|
||||
[[ "$default" == "root" ]] && default=""
|
||||
|
||||
if [[ -n "${SETUP_USERNAME:-}" ]]; then
|
||||
answer="$SETUP_USERNAME"
|
||||
else
|
||||
echo ""
|
||||
info "Which account is this machine for?"
|
||||
echo " The account you log in and work as, day to day. It will be created"
|
||||
echo " if it does not exist."
|
||||
echo ""
|
||||
warn "Strongly advised: use a normal account, not root."
|
||||
echo " Working as root means everything runs with no safety net. A typo in"
|
||||
echo " a path deletes instead of refusing, anything you run has the whole"
|
||||
echo " machine, and nothing distinguishes you from a process that got out"
|
||||
echo " of hand. sudo gives you the same power when you ask for it, and"
|
||||
echo " only then — which is why root is not accepted as an answer here."
|
||||
|
||||
# Whether this was started FROM a root session, which usually means root is
|
||||
# how they log in. That is exactly the situation the advice above is for, and
|
||||
# the one where general advice is easiest to assume is aimed at somebody else.
|
||||
#
|
||||
# Two ways to be in it, and the second is the one that hides: no SUDO_USER at
|
||||
# all, or a SUDO_USER that is itself uid 0. Some providers ship an image whose
|
||||
# default account is uid 0 under an ordinary-looking name, so `sudo` from it
|
||||
# sets SUDO_USER to something that looks like a normal user and is not.
|
||||
local invoker_uid=""
|
||||
[[ -n "${SUDO_USER:-}" ]] && invoker_uid="$(id -u "$SUDO_USER" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "${SUDO_USER:-}" || "$invoker_uid" == "0" ]]; then
|
||||
echo ""
|
||||
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||
warn "You are running this from '${SUDO_USER}', which is uid 0 — the root account."
|
||||
else
|
||||
warn "You are running this as root directly, not through sudo."
|
||||
fi
|
||||
echo " If that is how you normally log into this machine, now is the"
|
||||
echo " moment to make an account and stop doing that."
|
||||
fi
|
||||
echo ""
|
||||
while [[ -z "${answer:-}" ]]; do
|
||||
if ! read -rp " Username${default:+ [$default]}: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set SETUP_USERNAME=<name> to answer this ahead of time."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
[[ -z "$answer" ]] && warn "There is no default here — type a username."
|
||||
done
|
||||
fi
|
||||
|
||||
# The portable shape of a Linux account name. Worth checking rather than
|
||||
# letting adduser refuse it later, because by then several questions have been
|
||||
# answered against a name that was never going to work.
|
||||
[[ "$answer" =~ ^[a-z_][a-z0-9_-]*\$?$ && ${#answer} -le 32 ]] ||
|
||||
fail "'${answer}' is not a usable Linux username — lower case, starting with a letter or underscore."
|
||||
# By uid, not by name. "root" is a label — what makes an account root is uid 0,
|
||||
# and some providers ship an image whose default login is uid 0 under a
|
||||
# friendlier name. Refusing only the string would let exactly that case through,
|
||||
# which is the one worth catching.
|
||||
local answer_uid
|
||||
answer_uid="$(id -u "$answer" 2>/dev/null || true)"
|
||||
if [[ "$answer_uid" == "0" ]]; then
|
||||
if [[ "$answer" == "root" ]]; then
|
||||
fail "root is not the account to set up here — see the warning above."
|
||||
fi
|
||||
fail "'${answer}' is uid 0 — the root account under another name, and not what to set up here."
|
||||
fi
|
||||
|
||||
USERNAME="$answer"
|
||||
|
||||
# Looked up, not assumed. The original built "/home/$USERNAME", which is merely
|
||||
# the usual answer — an account created with a different home, or one whose home
|
||||
# was moved, would have every later step writing to a directory that is not
|
||||
# theirs.
|
||||
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||
}
|
||||
|
||||
# Where Officer will live.
|
||||
#
|
||||
# Asked in pre-flight with the rest of the questions rather than at the point it
|
||||
# is first needed, because it decides the shape of several later steps — the
|
||||
# directory the repository is cloned into, where DATA_PATH sits beside it, and
|
||||
# which filesystem the app store's containers bind-mount out of. Answering it
|
||||
# once at the start also means the run can be described before it begins.
|
||||
#
|
||||
# One directory holding four, per docs/sidecar-app-store.md:
|
||||
#
|
||||
# <root>/platform/ the app
|
||||
# <root>/data/ DATA_PATH
|
||||
# <root>/dockers/ services the app store provisioned
|
||||
# <root>/capabilities/ the file-based item store
|
||||
#
|
||||
# OFFICER_ROOT in the environment answers it ahead of time.
|
||||
ask_officer_root() {
|
||||
local default="${USER_HOME}/officerdev" answer
|
||||
|
||||
if [[ -n "${OFFICER_ROOT:-}" ]]; then
|
||||
answer="$OFFICER_ROOT"
|
||||
else
|
||||
echo ""
|
||||
info "Where should Officer be installed?"
|
||||
echo " One directory holding the app, its data, the item store and any"
|
||||
echo " containers the app store provisions — so it can be moved, backed"
|
||||
echo " up or deleted as a unit."
|
||||
echo ""
|
||||
if ! read -rp " Path [${default}]: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set OFFICER_ROOT=<path> to answer this ahead of time."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
fi
|
||||
|
||||
# A leading ~ arrives as a literal when it comes from a read or an environment
|
||||
# variable — nothing expands it there — and would create a directory named "~".
|
||||
answer="${answer/#\~/$USER_HOME}"
|
||||
|
||||
[[ "$answer" == /* ]] || fail "That needs to be an absolute path, starting with / — got '${answer}'"
|
||||
|
||||
OFFICER_ROOT="${answer%/}"
|
||||
}
|
||||
|
||||
# The account's PRIMARY GROUP, asked of the system rather than assumed to be
|
||||
# named after the user.
|
||||
#
|
||||
# Debian and Ubuntu create a group per user, so "pastilhas:pastilhas" is right on
|
||||
# most machines — but not on one where the account came from LDAP, or was made
|
||||
# with `useradd -g users`, or is a cloud image with a shared group. There
|
||||
# `chown user:user` fails with "invalid group" and `install -g user` refuses,
|
||||
# both of which abort the step.
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# Run a block as the created user (login shell, inherits HOME)
|
||||
as_user() {
|
||||
sudo -u "$USERNAME" -i bash -c "$1"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# sudoers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Grant an account passwordless sudo, safely.
|
||||
#
|
||||
# A malformed file in /etc/sudoers.d breaks sudo COMPLETELY — and you cannot sudo
|
||||
# to repair it, so on a remote machine that is unrecoverable short of a rescue
|
||||
# console. The same is true of one with loose permissions: sudo refuses to read
|
||||
# its own configuration and every sudo on the box fails.
|
||||
#
|
||||
# The original wrote the file into /etc/sudoers.d first and validated it after,
|
||||
# with a chmod later still. Both of those leave a window where a broken or
|
||||
# world-readable sudoers file is live. This validates a temp file first and then
|
||||
# places it with its mode in a single install(1) — so what lands in /etc is
|
||||
# already known good and already 0440.
|
||||
grant_passwordless_sudo() {
|
||||
# Declared separately, deliberately. In `local a="$1" b="${a}"` bash expands
|
||||
# $a before it has been assigned, so b comes out with the name missing — which
|
||||
# here meant every account's rule landing in the same /etc/sudoers.d/99--nopasswd,
|
||||
# each one silently overwriting the last, and has_passwordless_sudo never
|
||||
# finding the file it was looking for.
|
||||
local user="$1"
|
||||
local dest="/etc/sudoers.d/99-${user}-nopasswd"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
[[ -n "$user" ]] || fail "grant_passwordless_sudo needs a username"
|
||||
|
||||
echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"
|
||||
|
||||
if ! visudo -c -f "$tmp" >/dev/null 2>&1; then
|
||||
rm -f "$tmp"
|
||||
fail "visudo rejected the sudoers entry for '${user}' — not installing it"
|
||||
fi
|
||||
|
||||
install -m 0440 -o root -g root "$tmp" "$dest"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
has_passwordless_sudo() {
|
||||
local user="$1"
|
||||
[[ -f "/etc/sudoers.d/99-${user}-nopasswd" ]] ||
|
||||
grep -rqsE "^${user}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD" /etc/sudoers /etc/sudoers.d 2>/dev/null
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Operating system detection
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Read one key out of /etc/os-release without leaking the rest of it into this
|
||||
# script. That file defines NAME, VERSION and ID — all generic enough to collide
|
||||
# with something here — so it is sourced in a subshell and only the one value
|
||||
# asked for comes back.
|
||||
os_release() {
|
||||
[[ -r /etc/os-release ]] || return 1
|
||||
# shellcheck disable=SC1091
|
||||
(
|
||||
. /etc/os-release 2>/dev/null
|
||||
printf '%s' "${!1:-}"
|
||||
)
|
||||
}
|
||||
|
||||
# Identify the machine, or refuse to guess.
|
||||
#
|
||||
# /etc/os-release rather than probing for a binary: a box can have more than one
|
||||
# package manager on PATH (a Homebrew install on Linux, a leftover apt on a
|
||||
# converted box), and only os-release can say which distribution the machine
|
||||
# actually IS, or give a version worth reporting.
|
||||
#
|
||||
# ID_LIKE is the fallback so derivatives resolve without being listed by name —
|
||||
# Pop!_OS, Mint and EndeavourOS all answer correctly without appearing below.
|
||||
detect_os() {
|
||||
local kernel like
|
||||
kernel="$(uname -s)"
|
||||
|
||||
case "$kernel" in
|
||||
Darwin)
|
||||
OS="macos"
|
||||
OS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)"
|
||||
OS_NAME="macOS ${OS_VERSION}"
|
||||
PM="brew"
|
||||
;;
|
||||
Linux)
|
||||
OS="$(os_release ID || true)"
|
||||
OS_NAME="$(os_release PRETTY_NAME || true)"
|
||||
OS_VERSION="$(os_release VERSION_ID || true)"
|
||||
like="$(os_release ID_LIKE || true)"
|
||||
|
||||
case "$OS" in
|
||||
ubuntu | debian | linuxmint | pop | raspbian | elementary) PM="apt" ;;
|
||||
arch | manjaro | endeavouros | cachyos | garuda) PM="pacman" ;;
|
||||
fedora | rhel | centos | rocky | almalinux) PM="dnf" ;;
|
||||
*)
|
||||
case " $like " in
|
||||
*" debian "* | *" ubuntu "*) PM="apt" ;;
|
||||
*" arch "*) PM="pacman" ;;
|
||||
*" fedora "* | *" rhel "*) PM="dnf" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
# WSL reports itself as Linux, but has no real systemd session: masking
|
||||
# sleep targets, restarting logind and anything touching the boot path
|
||||
# either fail or silently do nothing. Worth knowing before those steps run.
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true; fi
|
||||
;;
|
||||
MINGW* | MSYS* | CYGWIN*)
|
||||
fail "Windows is not supported. Run this inside WSL2 with an Ubuntu image instead."
|
||||
;;
|
||||
*)
|
||||
fail "Unrecognised kernel '$kernel' — cannot tell what this machine is."
|
||||
;;
|
||||
esac
|
||||
|
||||
# Normalised once here because upstream projects spell it differently:
|
||||
# Neovim ships aarch64, Go and Docker ship arm64, and lazygit ships x86_64.
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) ARCH="amd64" ;;
|
||||
aarch64 | arm64) ARCH="arm64" ;;
|
||||
*) fail "Unsupported CPU architecture '$(uname -m)' — this script installs amd64/arm64 binaries only." ;;
|
||||
esac
|
||||
|
||||
[[ -n "$OS" ]] || fail "Could not identify this distribution (no readable /etc/os-release)."
|
||||
[[ -n "$OS_NAME" ]] || OS_NAME="$OS${OS_VERSION:+ $OS_VERSION}"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Machine role
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# The interface packets actually leave by, which is not always the first one up.
|
||||
default_iface() {
|
||||
ip route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
|
||||
}
|
||||
|
||||
# Ask what this machine is, unless the environment already said.
|
||||
#
|
||||
# Asked in pre-flight rather than at the point of use so that the run knows its
|
||||
# own shape before it starts: the steps that care are spread from swap through to
|
||||
# the firewall, and being asked "is this a VPS?" for the fourth time halfway down
|
||||
# a provisioning run is how people start answering without reading.
|
||||
#
|
||||
# NO DEFAULT, deliberately, and it is the only question in the script like that.
|
||||
# A guessed default is right often enough to be trusted and wrong in exactly the
|
||||
# case that costs the most: pinning a static IP on a rented box, or leaving the
|
||||
# firewall open on one. Every branch downstream is about what this machine is
|
||||
# exposed to, so it is worth one deliberate keystroke rather than an Enter.
|
||||
ask_machine_role() {
|
||||
# Not a question on a Mac. Officer on macOS is a dev helper on a machine
|
||||
# somebody sits at — there is no homelab or VPS answer that would make sense,
|
||||
# and every section that branches on the role branches toward "server".
|
||||
if [[ "${OS:-}" == "macos" && -z "$MACHINE_ROLE" ]]; then
|
||||
MACHINE_ROLE="dev"
|
||||
info "macOS — treated as a dev machine. The server-only sections are skipped."
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "$MACHINE_ROLE" ]]; then
|
||||
case "$MACHINE_ROLE" in
|
||||
homelab | vps | dev) return ;;
|
||||
*) fail "MACHINE_ROLE must be homelab, vps or dev — got '$MACHINE_ROLE'" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "What is this machine? Several later steps depend on the answer."
|
||||
echo " [1] homelab — yours, on a network you control"
|
||||
echo " [2] vps — rented, public IP, provider's DHCP and console"
|
||||
echo " [3] dev — a laptop or desktop you sit at"
|
||||
echo ""
|
||||
|
||||
local choice
|
||||
while [[ -z "$MACHINE_ROLE" ]]; do
|
||||
# A failed read means EOF, not a wrong answer — without this the loop would
|
||||
# spin forever when stdin is closed, which is how an unattended run hangs.
|
||||
if ! read -rp " Which one? (1/2/3): " choice; then
|
||||
fail "No answer, and this question has no default. Set MACHINE_ROLE=homelab|vps|dev to answer it ahead of time."
|
||||
fi
|
||||
case "$choice" in
|
||||
1 | homelab) MACHINE_ROLE=homelab ;;
|
||||
2 | vps) MACHINE_ROLE=vps ;;
|
||||
3 | dev) MACHINE_ROLE=dev ;;
|
||||
"") warn "There is no default here — pick 1, 2 or 3." ;;
|
||||
*) warn "Not one of the options: '$choice'" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Convenience for the steps that branch on it.
|
||||
is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; }
|
||||
is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }
|
||||
@@ -0,0 +1,407 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — the development environment
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_DEV_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_DEV_LOADED=1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# git
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Read and written as the account, not as root. `git config --global` writes to
|
||||
# $HOME/.gitconfig, so running it under sudo without -H would write root's.
|
||||
#
|
||||
# ── Why this asks before touching an existing identity ──
|
||||
#
|
||||
# The original set all four values unconditionally on every run. Re-running it on
|
||||
# a machine somebody already uses replaces the name and email they had with
|
||||
# whatever is typed — and prompt_value accepts an empty answer, so pressing
|
||||
# Enter twice wrote `user.name = ""`. An empty name is worse than none at all:
|
||||
# unset makes git refuse to commit and say why, empty makes it commit with a
|
||||
# blank author and never mention it.
|
||||
#
|
||||
# ── And why it is worth being careful about here in particular ──
|
||||
#
|
||||
# docs/agent-git-identity.md: every agent Officer runs commits AS THE OWNER,
|
||||
# because it runs as the owner. So this is not only the human's identity — it is
|
||||
# what `git log` will attribute every agent commit on this machine to.
|
||||
|
||||
# Run from / rather than wherever the script was launched.
|
||||
#
|
||||
# `git config --global` reads and writes $HOME/.gitconfig and needs no repository
|
||||
# — but git still stats the working directory on the way, looking for one. The
|
||||
# script is typically launched from somewhere under the invoking user's home,
|
||||
# which is 0750, so the target account cannot stat it and every call dies with
|
||||
#
|
||||
# fatal: failed to stat '<cwd>': Permission denied
|
||||
#
|
||||
# Found because the writes failed silently: the section reported "written" while
|
||||
# nothing had been. Both wrappers now run in a subshell from /, which every
|
||||
# account can stat, and their exit status is checked by the caller.
|
||||
# `git config --get` exits NON-ZERO when the key is simply unset, and
|
||||
# `VAR="$(git_get …)"` propagates that under `set -e`. So on a machine where git
|
||||
# has never been configured — the fresh machine this script exists for — reading
|
||||
# the current value aborted the run before the section had printed anything.
|
||||
# Missing a value is an answer here, not a failure.
|
||||
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
|
||||
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
|
||||
|
||||
# Is there anything configured at all?
|
||||
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
|
||||
|
||||
# Ask for a value that must not be empty. The original's prompt accepted empty
|
||||
# and wrote it; this re-asks.
|
||||
ask_required() {
|
||||
local __var="$1" message="$2" default="$3" answer=""
|
||||
while [[ -z "$answer" ]]; do
|
||||
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
[[ -z "$answer" ]] && warn "This one cannot be left blank."
|
||||
done
|
||||
printf -v "$__var" '%s' "$answer"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shell
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# ── One starship config, not two ──
|
||||
#
|
||||
# The platform deploys scripts/setup/starship.toml into every member's home
|
||||
# (os-user-shell.ts), and the comment there calls it "the prompt config the
|
||||
# owner's own install uses — one file, both audiences". That was not true: the
|
||||
# original machine script wrote a DIFFERENT config inline, so the owner got one
|
||||
# prompt and every member got another. This deploys the same file the platform
|
||||
# does, which makes the comment true rather than aspirational.
|
||||
#
|
||||
# It lives one directory up because it is shared with the platform, not owned by
|
||||
# this script.
|
||||
STARSHIP_SRC="${STARSHIP_SRC:-$SCRIPT_DIR/../starship.toml}"
|
||||
|
||||
user_login_shell() { getent passwd "$USERNAME" | cut -d: -f7; }
|
||||
|
||||
oh_my_zsh_installed() { [[ -d "${USER_HOME}/.oh-my-zsh" ]]; }
|
||||
|
||||
install_oh_my_zsh() {
|
||||
# The installer refuses to run unattended over an existing install, so this is
|
||||
# only ever called when there is none.
|
||||
#
|
||||
# ── `|| true` is what makes this non-fatal, NOT the `return 0` below ──
|
||||
#
|
||||
# It used to be `return 0` alone, with a comment claiming the function returned
|
||||
# zero whatever happened. It did not. Under `set -e` a failing command inside a
|
||||
# function aborts the SHELL at that line when the function is called plainly —
|
||||
# `return 0` is never reached. So a machine where this curl or the installer
|
||||
# failed died here, silently, because the output is redirected: the run just
|
||||
# stopped after apt finished installing zsh, with nothing said. Observed on a
|
||||
# fresh Hetzner VPS, 2026-08-14.
|
||||
sudo -H -u "$USERNAME" sh -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
|
||||
true
|
||||
# Belt and braces: `|| true` above already makes the last command succeed, and
|
||||
# this states the contract for anyone adding a line beneath it.
|
||||
return 0
|
||||
}
|
||||
|
||||
# `chsh` is what actually changes the login shell. Asked separately from
|
||||
# installing zsh, because having a shell available and being handed it at every
|
||||
# login are different decisions.
|
||||
# Reports whether chsh worked, rather than swallowing it. The same `set -e` trap as
|
||||
# install_oh_my_zsh applies — a bare `chsh` that fails kills the run at this line —
|
||||
# but here the answer matters: the caller announces the new login shell, and `|| true`
|
||||
# would have it announce one that was never set. So the status comes back and the
|
||||
# CALLER guards the call, which is also what keeps set -e out of it.
|
||||
set_login_shell() {
|
||||
local shell="$1"
|
||||
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
|
||||
chsh -s "$shell" "$USERNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Neovim
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# From the upstream tarball rather than the distribution, which ships Neovim
|
||||
# years behind — Ubuntu 24.04 has 0.9 where upstream is on 0.12, and LazyVim
|
||||
# requires 0.9+ with most plugins wanting newer.
|
||||
#
|
||||
# The asset names are x86_64 and arm64. The original mapped aarch64 to
|
||||
# "aarch64", which is not a name Neovim publishes: on an arm machine it
|
||||
# downloaded a 404 and tar failed on the HTML error page.
|
||||
nvim_asset() {
|
||||
case "$ARCH" in
|
||||
amd64) echo x86_64 ;;
|
||||
arm64) echo arm64 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
nvim_installed_version() { nvim --version 2>/dev/null | awk 'NR == 1 { print $2 }'; }
|
||||
|
||||
nvim_latest_version() {
|
||||
curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest 2>/dev/null |
|
||||
jq -r '.tag_name // empty'
|
||||
}
|
||||
|
||||
# Downloaded to /tmp, not to whatever directory the script was launched from —
|
||||
# the original used `curl -LO`, which drops the tarball beside the script and
|
||||
# leaves it there if tar fails.
|
||||
#
|
||||
# The old install is removed only after the download has succeeded, so a failed
|
||||
# fetch leaves the working copy alone.
|
||||
nvim_install() {
|
||||
local asset tarball dest
|
||||
asset="$(nvim_asset)"
|
||||
tarball="/tmp/nvim-linux-${asset}.tar.gz"
|
||||
dest="/opt/nvim-linux-${asset}"
|
||||
|
||||
curl -fsSL -o "$tarball" \
|
||||
"https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${asset}.tar.gz" || return 1
|
||||
|
||||
# A 404 comes back as an HTML page, and tar's failure on it is unhelpful.
|
||||
# Checking here names the real problem.
|
||||
tar -tzf "$tarball" >/dev/null 2>&1 || {
|
||||
rm -f "$tarball"
|
||||
warn "the download is not a tarball — the release asset may have been renamed"
|
||||
return 1
|
||||
}
|
||||
|
||||
rm -rf "$dest"
|
||||
tar -C /opt -xzf "$tarball"
|
||||
rm -f "$tarball"
|
||||
ln -sf "${dest}/bin/nvim" /usr/local/bin/nvim
|
||||
}
|
||||
|
||||
# Clone a Neovim config into the account's ~/.config/nvim.
|
||||
#
|
||||
# From `cd /` for the same reason git config does: the script's working directory
|
||||
# is usually under the invoking user's home at 0750, which the target account
|
||||
# cannot stat, and git fails there before it does anything useful.
|
||||
nvim_clone_config() {
|
||||
local repo="$1" dest="${USER_HOME}/.config/nvim"
|
||||
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
|
||||
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
|
||||
|
||||
# The starter is a template, not something to track. Left in place for a
|
||||
# config of the user's own, which they will want to keep pulling.
|
||||
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# JavaScript runtimes
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Three of these are not optional, and it is worth being precise about why,
|
||||
# because "we run on Bun" suggests Node could go and it cannot:
|
||||
#
|
||||
# node pm2 is a Node application (#!/usr/bin/env node), and pm2 supervises
|
||||
# every process here. officer-pty imports node-pty, a native addon with
|
||||
# no Linux prebuild — it compiles against the installed Node on every
|
||||
# machine. Either one alone makes Node load-bearing.
|
||||
# bun the platform itself and nineteen of the twenty pm2 apps.
|
||||
# pm2 the process manager the ecosystem files are written for.
|
||||
#
|
||||
# Deno is not. Nothing in the platform imports it — checked across the whole
|
||||
# tree — and it is offered only because it was in the original script and
|
||||
# somebody may still want it.
|
||||
|
||||
# The current LTS major, asked of nodejs.org rather than hardcoded. The original
|
||||
# pinned setup_22.x, which ages into "the version we happened to pick" the moment
|
||||
# a new LTS lands.
|
||||
node_lts_major() {
|
||||
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
|
||||
jq -r '[.[] | select(.lts != false)][0].version // empty' | sed 's/^v//; s/\..*//'
|
||||
}
|
||||
|
||||
node_lts_label() {
|
||||
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
|
||||
jq -r '[.[] | select(.lts != false)][0] | "\(.version) (\(.lts))" // empty'
|
||||
}
|
||||
|
||||
node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
|
||||
|
||||
install_node() {
|
||||
local major="$1"
|
||||
# NodeSource publishes one setup script per major. Checked before it is piped
|
||||
# into a shell, because a 404 page piped to bash is a confusing way to fail.
|
||||
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
|
||||
warn "NodeSource has no setup script for Node ${major}"
|
||||
return 1
|
||||
}
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${major}.x" | bash - >/dev/null 2>&1
|
||||
pkg_install_now nodejs
|
||||
# Global installs land in /usr/local rather than in a path only root can write,
|
||||
# so `npm i -g` works the same for the owner and for root.
|
||||
npm config set prefix /usr/local >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Present anywhere: on PATH for this root shell, or in the account's own
|
||||
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
|
||||
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
|
||||
|
||||
# Asked of whichever copy exists. Before the symlink is made, root's PATH has no
|
||||
# bun at all, so `bun --version` reports nothing on a machine that plainly has it.
|
||||
bun_version() {
|
||||
if command -v bun &>/dev/null; then
|
||||
bun --version
|
||||
elif [[ -x "${USER_HOME}/.bun/bin/bun" ]]; then
|
||||
"${USER_HOME}/.bun/bin/bun" --version
|
||||
fi
|
||||
}
|
||||
|
||||
# The system-wide link, ensured on every run rather than only after an install.
|
||||
#
|
||||
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
|
||||
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
|
||||
# then fails to start on reboot while working perfectly when started by hand. A
|
||||
# machine that already had bun before this script ran would never get the link if
|
||||
# it were only made as part of installing.
|
||||
#
|
||||
# Safe across upgrades: a symlink resolves by path, and `bun upgrade` replaces
|
||||
# the file at $BUN_INSTALL/bin/bun rather than moving it. The link only breaks if
|
||||
# the home directory goes, which breaks bun anyway.
|
||||
ensure_bun_symlink() {
|
||||
local bin="${USER_HOME}/.bun/bin/bun"
|
||||
[[ -x "$bin" ]] || return 1
|
||||
[[ "$(readlink -f /usr/local/bin/bun 2>/dev/null)" == "$(readlink -f "$bin")" ]] && return 1
|
||||
ln -sf "$bin" /usr/local/bin/bun
|
||||
return 0
|
||||
}
|
||||
|
||||
# Installed as the account, then symlinked system-wide. pm2 started at boot by
|
||||
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
|
||||
# symlink every bun-based sidecar fails to start on reboot and works fine when
|
||||
# started by hand, which is a miserable thing to debug.
|
||||
install_bun() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||
}
|
||||
|
||||
pm2_installed() { command -v pm2 &>/dev/null; }
|
||||
install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
|
||||
|
||||
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
|
||||
install_deno() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Agent CLIs
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Claude Code goes in through Anthropic's own installer rather than npm, matching
|
||||
# what the platform does for members (os-user-claude.ts) and chosen there for the
|
||||
# auto-update the npm package does not do.
|
||||
#
|
||||
# Two things that installer insists on, both of which a naive port gets wrong:
|
||||
#
|
||||
# It REFUSES to run under sudo from a regular user's shell — it checks for uid 0
|
||||
# with SUDO_USER set, because everything it installs goes under $HOME and under
|
||||
# sudo that is root's home. So it must run AS the account, not as root.
|
||||
#
|
||||
# It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash.
|
||||
# `| sh` fails on a dash-based /bin/sh, which is Ubuntu's.
|
||||
#
|
||||
# Both are recorded in os-user-claude.ts too, which found them first.
|
||||
|
||||
CLAUDE_INSTALL_URL="https://claude.ai/install.sh"
|
||||
OPENCODE_INSTALL_URL="https://opencode.ai/install"
|
||||
|
||||
# Where each installer actually puts its binary. They disagree, and the platform
|
||||
# depends on the difference:
|
||||
#
|
||||
# claude ~/.local/bin/claude — claude-manager.ts tries Bun.which then
|
||||
# that exact path
|
||||
# opencode ~/.opencode/bin/opencode — sidecar/opencode/index.ts:22 hardcodes
|
||||
# join(homedir(), '.opencode', 'bin', …)
|
||||
#
|
||||
# Looking for opencode in ~/.local/bin, as an earlier version of this did,
|
||||
# reports a perfectly good install as missing and then installs it again.
|
||||
agent_bin() {
|
||||
case "$1" in
|
||||
claude) echo "${USER_HOME}/.local/bin/claude" ;;
|
||||
opencode) echo "${USER_HOME}/.opencode/bin/opencode" ;;
|
||||
*) echo "${USER_HOME}/.local/bin/$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The directories those live in, for the account's PATH.
|
||||
agent_bin_dirs() { echo "${USER_HOME}/.local/bin" "${USER_HOME}/.opencode/bin"; }
|
||||
|
||||
agent_installed() { [[ -x "$(agent_bin "$1")" ]] || command -v "$1" &>/dev/null; }
|
||||
|
||||
# Which copy answers, so the run can say where it came from. Claude installed
|
||||
# from npm sits in /usr/local/lib/node_modules and does NOT auto-update, which is
|
||||
# the whole reason the platform prefers Anthropic's installer.
|
||||
agent_path() {
|
||||
local name="$1" bin
|
||||
bin="$(agent_bin "$name")"
|
||||
[[ -x "$bin" ]] && {
|
||||
echo "$bin"
|
||||
return
|
||||
}
|
||||
command -v "$name" 2>/dev/null || true
|
||||
}
|
||||
|
||||
agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" == */node_modules/* ]]; }
|
||||
|
||||
agent_version() {
|
||||
local bin
|
||||
bin="$(agent_path "$1")"
|
||||
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
|
||||
}
|
||||
|
||||
install_claude_code() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
[[ -x "$(agent_bin claude)" ]]
|
||||
}
|
||||
|
||||
install_opencode() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
[[ -x "$(agent_bin opencode)" ]]
|
||||
}
|
||||
|
||||
install_pi() { npm install -g @mariozechner/pi-coding-agent >/dev/null 2>&1; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Default editor
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# One preference, two mechanisms, and both are needed:
|
||||
#
|
||||
# EDITOR / VISUAL what the account's own shell hands to git, crontab -e,
|
||||
# systemctl edit and anything else that opens an editor
|
||||
# update-alternatives the system-wide `editor` command, which is what root and
|
||||
# `sudoedit` use — an account's shell config cannot reach
|
||||
# those
|
||||
#
|
||||
# This is the setting core.editor was deliberately left out in favour of: set it
|
||||
# here and git follows, along with everything else.
|
||||
|
||||
editor_candidates() {
|
||||
local e
|
||||
for e in nvim vim nano; do command -v "$e" &>/dev/null && echo "$e"; done
|
||||
}
|
||||
|
||||
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
|
||||
# assignment from a function that exits non-zero aborts the run under `set -e`.
|
||||
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
|
||||
|
||||
set_system_editor() {
|
||||
local editor="$1" path
|
||||
path="$(command -v "$editor")" || return 1
|
||||
# Only where the alternatives system is in use. Absent on non-Debian systems,
|
||||
# where there is nothing to set.
|
||||
command -v update-alternatives &>/dev/null || return 0
|
||||
update-alternatives --install /usr/bin/editor editor "$path" 100 >/dev/null 2>&1
|
||||
update-alternatives --set editor "$path" >/dev/null 2>&1
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — Docker
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_DOCKER_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_DOCKER_LOADED=1
|
||||
|
||||
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
|
||||
|
||||
docker_is_installed() { command -v docker &>/dev/null; }
|
||||
|
||||
# The daemon, not just the binary. `docker --version` answers from the client
|
||||
# alone and says nothing about whether there is anything to talk to.
|
||||
docker_daemon_ok() { docker info &>/dev/null; }
|
||||
|
||||
user_in_docker_group() { id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx docker; }
|
||||
|
||||
docker_rootless_installed() { [[ -S "/run/user/$(id -u "$USERNAME" 2>/dev/null)/docker.sock" ]]; }
|
||||
|
||||
# The codename Docker's repository is actually published under.
|
||||
#
|
||||
# `lsb_release -cs` is what the original used, and it is wrong on every
|
||||
# derivative: Mint reports "vanessa", Pop reports its own, and Docker publishes
|
||||
# neither — so `apt update` fails on a repository that does not exist. os-release
|
||||
# carries UBUNTU_CODENAME on exactly those systems for exactly this reason, so it
|
||||
# is preferred and VERSION_CODENAME is the fallback.
|
||||
docker_repo_codename() {
|
||||
local c
|
||||
c="$(os_release UBUNTU_CODENAME || true)"
|
||||
[[ -z "$c" ]] && c="$(os_release VERSION_CODENAME || true)"
|
||||
echo "$c"
|
||||
}
|
||||
|
||||
# Which upstream to point at. A derivative is Ubuntu or Debian as far as Docker
|
||||
# is concerned, and ID_LIKE is how it says which.
|
||||
docker_repo_distro() {
|
||||
case "$OS" in
|
||||
ubuntu | debian) echo "$OS" ;;
|
||||
*)
|
||||
case " $(os_release ID_LIKE || true) " in
|
||||
*" ubuntu "*) echo ubuntu ;;
|
||||
*) echo debian ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_docker_engine() {
|
||||
# Linux only, and never reached on macOS: the Docker step there checks for
|
||||
# Docker Desktop and tells the owner to install it rather than doing it — a GUI
|
||||
# app that wants opening, permissions and a running window is not a shell
|
||||
# script's job, and colima/lima are not worth the evening they cost.
|
||||
|
||||
local distro codename
|
||||
distro="$(docker_repo_distro)"
|
||||
codename="$(docker_repo_codename)"
|
||||
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the Docker repository"
|
||||
return 1
|
||||
}
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL "https://download.docker.com/linux/${distro}/gpg" |
|
||||
gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${distro} ${codename} stable" \
|
||||
>/etc/apt/sources.list.d/docker.list
|
||||
|
||||
pkg_refresh >/dev/null
|
||||
|
||||
# ── The rootless prerequisites go in HERE, not in the rootless branch ──
|
||||
#
|
||||
# They used to be installed only when the owner picked "[2] rootless Docker for
|
||||
# me" in section 22. But the OWNER's choice is not the only one that matters:
|
||||
# every Developer account the platform provisions gets its own rootless daemon,
|
||||
# whatever the owner picked for themselves. So on a machine where the owner chose
|
||||
# the docker group, the host never got these and every member's daemon failed
|
||||
# with `rootless Docker needs these packages on the host: uidmap`.
|
||||
#
|
||||
# `src/servers/os-user-docker.ts` → checkDockerPrerequisites is the authority on
|
||||
# this list, and it wants both:
|
||||
#
|
||||
# uidmap /usr/bin/newuidmap, /usr/bin/newgidmap
|
||||
# docker-ce-rootless-extras /usr/bin/dockerd-rootless-setuptool.sh
|
||||
#
|
||||
# docker-ce only RECOMMENDS rootless-extras. That is installed by default, so it
|
||||
# is usually there by luck — and is not on a host configured with
|
||||
# --no-install-recommends. Named explicitly so it does not depend on that.
|
||||
#
|
||||
# dbus-user-session is what lets a member's systemd --user survive without a
|
||||
# login session, which is how the daemon stays up.
|
||||
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
|
||||
docker-ce-rootless-extras uidmap dbus-user-session
|
||||
}
|
||||
|
||||
# A shared network so containers from different compose files can reach each
|
||||
# other by name. Harmless if it is already there.
|
||||
ensure_docker_network() {
|
||||
docker network inspect "$DOCKER_NETWORK" &>/dev/null && return 0
|
||||
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ── Rootless, for the owner ──
|
||||
#
|
||||
# Works, and does not work with Officer's app store as it stands. Both are true
|
||||
# and the second is the one nobody would find out until a container failed to
|
||||
# provision, so it is stated at the prompt rather than left here.
|
||||
#
|
||||
# The app store spawns `docker` with no environment of its own —
|
||||
# app-store/compose.ts, app-store/preflight.ts, api/system-monitor — so it talks
|
||||
# to whatever socket the `officer` pm2 process's environment points at. That is
|
||||
# /var/run/docker.sock unless DOCKER_HOST says otherwise, and nothing sets
|
||||
# DOCKER_HOST for the owner: os-user-docker.ts sets it only for member commands.
|
||||
#
|
||||
# pm2 started at boot by systemd has no session either, so exporting it in a
|
||||
# shell rc does not reach the process that matters.
|
||||
install_docker_rootless() {
|
||||
local uid
|
||||
uid="$(id -u "$USERNAME")"
|
||||
|
||||
# Without lingering, the user manager stops when the last session ends and
|
||||
# takes the daemon with it. Officer's shells are not login sessions.
|
||||
loginctl enable-linger "$USERNAME" >/dev/null 2>&1
|
||||
|
||||
sudo -u "$USERNAME" \
|
||||
XDG_RUNTIME_DIR="/run/user/${uid}" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
|
||||
PATH="/usr/bin:/usr/sbin:/bin:/sbin" \
|
||||
dockerd-rootless-setuptool.sh install >/dev/null 2>&1 || return 1
|
||||
|
||||
sudo -u "$USERNAME" \
|
||||
XDG_RUNTIME_DIR="/run/user/${uid}" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
|
||||
systemctl --user enable --now docker >/dev/null 2>&1
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — writing files into somebody's home
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
#
|
||||
# ── The rule ──
|
||||
#
|
||||
# A setup script may create a config file. It may not silently replace one the
|
||||
# user wrote. The original did the second: `cp .tmux.conf $USER_HOME/` on every
|
||||
# run, over whatever was there, and five separate `cat >>` into .zshrc with no
|
||||
# guard — so a second pass duplicated the starship init, the nvim PATH, bun, deno
|
||||
# and the aliases.
|
||||
#
|
||||
# Both of those are the same mistake in different shapes: writing without looking
|
||||
# first. The two helpers here are the two safe shapes.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_FILES_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_FILES_LOADED=1
|
||||
|
||||
# Put a config file in place, asking before it replaces one the user has.
|
||||
#
|
||||
# Three outcomes, and the caller can tell them apart by the return code:
|
||||
#
|
||||
# 0 installed — there was nothing there, or the user chose to replace
|
||||
# 1 identical — already exactly this, nothing done
|
||||
# 2 kept — the user chose to keep theirs
|
||||
#
|
||||
# When the file exists and differs, this ASKS rather than deciding. Silently
|
||||
# keeping theirs is safe but unhelpful — they never learn that a newer version
|
||||
# exists — and silently replacing it is how a setup script eats somebody's
|
||||
#configuration. So: keep, replace, or show the difference first, and a replaced file is
|
||||
# always kept beside the new one.
|
||||
#
|
||||
# NOTE for callers: 1 and 2 are outcomes, not failures — but they are still
|
||||
# non-zero, so calling this as a plain command under `set -e` ends the script
|
||||
# before the result can be read. Always capture it:
|
||||
#
|
||||
# install_config "$src" "$dest" "$user" && rc=0 || rc=$?
|
||||
install_config() {
|
||||
local src="$1" dest="$2" owner="$3" answer
|
||||
|
||||
if [[ ! -f "$dest" ]]; then
|
||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||
# Recorded here rather than at the call site: "which files did it write" is
|
||||
# the question a reviewer asks first, and a per-section report would drift
|
||||
# from what this function actually did.
|
||||
declare -F report_changed >/dev/null && report_changed "wrote ${dest} (0644, owner ${owner}) — did not exist"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if cmp -s "$src" "$dest"; then
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} already identical to the shipped version — not touched"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
warn "${dest} already exists here, and differs from the one this script ships."
|
||||
|
||||
# Never replace a file the user has without being told to. An unattended run
|
||||
# answers "keep", because the alternative is destroying configuration nobody
|
||||
# was present to defend.
|
||||
if [[ "${ASSUME_YES:-}" == "1" ]] || [[ ! -t 0 ]]; then
|
||||
echo " keeping yours (nothing was asked, so nothing is replaced)"
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — unattended run, nothing replaced"
|
||||
return 2
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo " [1] keep yours — nothing changes"
|
||||
echo " [2] use ours — yours is kept as ${dest}.before-machine-setup"
|
||||
echo " [3] show me the difference first"
|
||||
echo ""
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " answer; then
|
||||
echo ""
|
||||
echo " keeping yours"
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT — no answer available"
|
||||
return 2
|
||||
fi
|
||||
case "${answer:-1}" in
|
||||
1)
|
||||
echo " keeping yours"
|
||||
declare -F report_kept >/dev/null && report_kept "${dest} differs from ours and was KEPT by choice"
|
||||
return 2
|
||||
;;
|
||||
2)
|
||||
cp -a "$dest" "${dest}.before-machine-setup"
|
||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||
ok "replaced — yours is at ${dest}.before-machine-setup"
|
||||
declare -F report_changed >/dev/null && report_changed "REPLACED ${dest} by choice — previous kept at ${dest}.before-machine-setup"
|
||||
return 0
|
||||
;;
|
||||
3)
|
||||
echo ""
|
||||
# yours on the left, ours on the right: - is what you would lose,
|
||||
# + is what you would gain.
|
||||
diff -u --label "yours: ${dest}" --label "ours: ${src}" "$dest" "$src" | page
|
||||
echo ""
|
||||
;;
|
||||
*) warn "Pick 1, 2 or 3." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Append a block to a file exactly once.
|
||||
#
|
||||
# The block is wrapped in markers naming what it is, so a second run recognises
|
||||
# its own work instead of adding it again — and so a human reading the file can
|
||||
# see which lines came from here and delete them as a unit.
|
||||
#
|
||||
# append_once ~/.zshrc bun <<'EOF'
|
||||
# export PATH="$HOME/.bun/bin:$PATH"
|
||||
# EOF
|
||||
#
|
||||
# Returns 0 if it wrote, 1 if the block was already there.
|
||||
#
|
||||
# One limitation, and it bites the author rather than the user: RENAMING a marker
|
||||
# orphans the block that used the old name. append_once only recognises the name
|
||||
# it is given, so the previous block stays in the file doing whatever it did.
|
||||
# Changing a block's CONTENT has the same shape — the marker is found, so the new
|
||||
# content is never written. Both need the old block removed by hand.
|
||||
append_once() {
|
||||
local file="$1" name="$2"
|
||||
local begin="# >>> machine-setup: ${name} >>>"
|
||||
local end="# <<< machine-setup: ${name} <<<"
|
||||
|
||||
if [[ -f "$file" ]] && grep -qF "$begin" "$file"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "$begin"
|
||||
cat
|
||||
echo "$end"
|
||||
} >>"$file"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Where tmux actually reads its config
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# tmux 3.1 added an XDG location and it takes PRECEDENCE. Verified on 3.4 by
|
||||
# creating both and asking tmux which marker it ended up with:
|
||||
#
|
||||
# both present -> ~/.config/tmux/tmux.conf
|
||||
# only ~/.tmux.conf -> ~/.tmux.conf
|
||||
# only the XDG one -> the XDG one
|
||||
#
|
||||
# So installing to ~/.tmux.conf on a machine that has the XDG file writes a file
|
||||
# tmux will never read, and the script would report success having changed
|
||||
# nothing anybody can see. That is the failure this exists to prevent.
|
||||
#
|
||||
# Rules, in order:
|
||||
# 1. an existing XDG config wins -> that is their real config, target it
|
||||
# 2. an existing ~/.tmux.conf -> target it, since it is what tmux reads
|
||||
# 3. neither -> ~/.tmux.conf, the path every guide names
|
||||
tmux_config_target() {
|
||||
local home="$1"
|
||||
local xdg="${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf"
|
||||
if [[ -f "$xdg" ]]; then
|
||||
echo "$xdg"
|
||||
else
|
||||
echo "$home/.tmux.conf"
|
||||
fi
|
||||
}
|
||||
|
||||
# True when a ~/.tmux.conf would be shadowed by an XDG config that already exists.
|
||||
tmux_dot_conf_is_shadowed() {
|
||||
local home="$1"
|
||||
[[ -f "${XDG_CONFIG_HOME:-$home/.config}/tmux/tmux.conf" && -f "$home/.tmux.conf" ]]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — distro packages
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
|
||||
#
|
||||
# ── The rule: install what is missing, never touch what is there ──
|
||||
#
|
||||
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
|
||||
# the repository has a newer one. On a machine somebody already uses, that can
|
||||
# move a version they chose deliberately, and the setup script is the last thing
|
||||
# that should be doing that behind their back.
|
||||
#
|
||||
# So every install here goes through pkg_install, which queries the package
|
||||
# database first, installs only the subset that is genuinely absent, and prints
|
||||
# both lists before doing it. A package already present is never named on a
|
||||
# command line at all.
|
||||
#
|
||||
# ── Why per-package-manager lists rather than a translation table ──
|
||||
#
|
||||
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
|
||||
# and some packages are not a package elsewhere at all: apt-transport-https,
|
||||
# lsb-release and software-properties-common are apt concepts. A canonical-name
|
||||
# table with per-manager overrides hides both of those behind indirection. A
|
||||
# plain `case $PM` says what each system actually gets, in one place, and matches
|
||||
# the shape scripts/setup-old/setup.sh already used.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_PACKAGES_LOADED=1
|
||||
|
||||
# What the last pkg_install/tools_install actually put on the machine, as opposed
|
||||
# to what it was asked for. Read by the caller to write an honest summary line:
|
||||
# without it every section reports its whole list as installed, including the
|
||||
# packages it deliberately left alone.
|
||||
LAST_INSTALLED=()
|
||||
LAST_KEPT=()
|
||||
LAST_SKIPPED=()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The sections
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Core: what this script itself would break without, plus the command-line tools
|
||||
# that make a machine worth sitting at.
|
||||
#
|
||||
# The first six are load-bearing and each is used by a later step — curl fetches
|
||||
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
|
||||
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
|
||||
# archive, and ca-certificates is what makes any of the fetching work. The rest
|
||||
# are the environment: nothing calls them, they are here because a box you use
|
||||
# should have them.
|
||||
#
|
||||
# Four entries earn a note.
|
||||
#
|
||||
# python3 is not a tool anybody here calls — it is node-gyp's build dependency,
|
||||
# and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for
|
||||
# darwin and win32 ONLY, so on Linux its install script always falls through to
|
||||
# `node-gyp rebuild` and compiles from source. Without python3 that fails, and
|
||||
# the failure surfaces as a broken terminal sidecar rather than as a missing
|
||||
# package. build-essential below is the other half of the same requirement.
|
||||
#
|
||||
# unattended-upgrades installs updates on a timer with nobody watching. apt only:
|
||||
# it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman
|
||||
# has no equivalent at all, so it is not a name to translate. Installing the
|
||||
# package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades
|
||||
# is what the apt-daily timers read, and on this host no package owns that file.
|
||||
# The section makes sure it is there.
|
||||
#
|
||||
# fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu
|
||||
# ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`.
|
||||
# Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the
|
||||
# moment it installs, an address failing to log in five times in ten minutes is
|
||||
# blocked for ten, including yours. That is the point of it and it is worth
|
||||
# having by default, but it is why it belongs in this comment rather than being
|
||||
# thought of as one more binary. An existing install with its own jails is
|
||||
# untouched, because pkg_install never names a package that is already there.
|
||||
#
|
||||
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
|
||||
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
|
||||
# distribution's default alongside it. It stays in core because anything that
|
||||
# compiles a native module needs it, but it is the one to move out first if that
|
||||
# ever bites.
|
||||
pkgs_core() {
|
||||
case "$PM" in
|
||||
apt)
|
||||
# apt-transport-https and lsb-release are not tools — they are what lets a
|
||||
# later step add the Docker repository. They have no counterpart on the
|
||||
# other systems.
|
||||
#
|
||||
# software-properties-common is still here and is no longer needed by
|
||||
# anything: it provides `add-apt-repository`, and the fastfetch PPA was its
|
||||
# only caller until that was removed on 2026-08-14 (Docker writes its own
|
||||
# sources.list.d entry by hand). Left in deliberately rather than dropped
|
||||
# in the same change — it is one small package, and pulling it is a
|
||||
# separate decision from removing the tool that wanted it.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Querying
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Is this package installed right now?
|
||||
#
|
||||
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
|
||||
# package that was removed but left its config behind — that state would be read
|
||||
# as "present" and the package would never be reinstalled.
|
||||
pkg_is_installed() {
|
||||
case "$PM" in
|
||||
apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
|
||||
pacman) pacman -Qi "$1" &>/dev/null ;;
|
||||
dnf) rpm -q "$1" &>/dev/null ;;
|
||||
brew) brew list --formula "$1" &>/dev/null ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Acting
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Refresh the package index.
|
||||
#
|
||||
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
|
||||
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
|
||||
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
|
||||
# rather than at each call site, because forgetting one turns an unattended run
|
||||
# into one that is silently waiting for a keypress.
|
||||
pkg_refresh() {
|
||||
case "$PM" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
|
||||
pacman) pacman -Sy --noconfirm ;;
|
||||
dnf) dnf makecache ;;
|
||||
brew) brew update ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# What an upgrade would actually move, one package name per line.
|
||||
#
|
||||
# Asked before the upgrade runs so the section can name what it is about to
|
||||
# change rather than asking to be trusted. Needs a refreshed index to be
|
||||
# accurate, which is why pkg_refresh runs first.
|
||||
#
|
||||
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
|
||||
# which is the same calculation the real run does — as opposed to
|
||||
# `apt list --upgradable`, which also lists packages that are held back and
|
||||
# would not actually move.
|
||||
pkg_upgradable() {
|
||||
case "$PM" in
|
||||
apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
|
||||
pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
|
||||
dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
|
||||
brew) brew outdated --quiet 2>/dev/null ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Upgrade everything already installed. Separate from pkg_install on purpose:
|
||||
# this one DOES move versions, so it is a deliberate step rather than something
|
||||
# that happens as a side effect of installing a tool.
|
||||
pkg_upgrade_all() {
|
||||
case "$PM" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
|
||||
pacman) pacman -Su --noconfirm ;;
|
||||
dnf) dnf upgrade -y ;;
|
||||
brew) brew upgrade ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The raw install, with no presence check. Use pkg_install instead.
|
||||
pkg_install_now() {
|
||||
case "$PM" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
|
||||
pacman) pacman -S --noconfirm --needed "$@" ;;
|
||||
dnf) dnf install -y "$@" ;;
|
||||
brew) brew install "$@" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Announce a section, then install only what is absent from it.
|
||||
#
|
||||
# pkg_install "Core packages" $(pkgs_core)
|
||||
#
|
||||
# Prints both lists before touching anything, so the run says what it is about to
|
||||
# do to this machine and what it is deliberately leaving alone. Returns 0 when
|
||||
# there was nothing to do.
|
||||
pkg_install() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
local pkg
|
||||
local -a missing=() present=()
|
||||
LAST_SKIPPED=()
|
||||
for pkg in "$@"; do
|
||||
if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
|
||||
done
|
||||
|
||||
LAST_INSTALLED=("${missing[@]}")
|
||||
LAST_KEPT=("${present[@]}")
|
||||
|
||||
announce_plan "$label" present missing || {
|
||||
# Declining is a fact a reviewer wants: it explains a package being absent
|
||||
# later without having to guess whether the script failed or was refused.
|
||||
declare -F report_skipped >/dev/null && report_skipped "${label}: declined — ${#missing[@]} package(s) not installed"
|
||||
return 0
|
||||
}
|
||||
|
||||
if pkg_install_now "${missing[@]}"; then
|
||||
declare -F report_installed >/dev/null && ((${#missing[@]})) && report_installed "${PM}: ${missing[*]}"
|
||||
declare -F report_kept >/dev/null && ((${#present[@]})) && report_kept "already present, untouched: ${present[*]}"
|
||||
else
|
||||
declare -F report_failed >/dev/null && report_failed "${PM} install failed: ${missing[*]}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Print what a section is about to do and ask permission for it.
|
||||
#
|
||||
# Takes the NAMES of the two arrays rather than their contents, because a list
|
||||
# passed by value cannot be told apart from an empty one once it has been through
|
||||
# word splitting.
|
||||
#
|
||||
# Returns non-zero when there is nothing to do, or when the answer was no — in
|
||||
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
|
||||
# refusal so the summary does not claim work that never happened.
|
||||
announce_plan() {
|
||||
local label="$1"
|
||||
local -n _present="$2"
|
||||
local -n _missing="$3"
|
||||
|
||||
echo ""
|
||||
info "${label} — installs what is missing, keeps what you already have"
|
||||
((${#_present[@]})) && echo " already here: ${_present[*]}"
|
||||
|
||||
if ((${#_missing[@]} == 0)); then
|
||||
echo " to install: nothing, all present"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " to install: ${_missing[*]}"
|
||||
if ! confirm "Proceed?"; then
|
||||
warn "skipped by request"
|
||||
LAST_INSTALLED=()
|
||||
LAST_SKIPPED=("${_missing[@]}")
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# One summary line describing what a section actually did, from LAST_INSTALLED
|
||||
# and LAST_KEPT. Call straight after pkg_install or tools_install.
|
||||
summarise_last() {
|
||||
local label="$1"
|
||||
if ((${#LAST_SKIPPED[@]})); then
|
||||
SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
|
||||
elif ((${#LAST_INSTALLED[@]} == 0)); then
|
||||
SUMMARY+=("$label: already present, nothing installed")
|
||||
elif ((${#LAST_KEPT[@]} == 0)); then
|
||||
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
|
||||
else
|
||||
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The Xcode command line tools
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# macOS's build-essential, and not installable as a formula. It matters here for
|
||||
# one specific reason: node-pty ships no prebuilt binary for any platform, so
|
||||
# `bun install` always falls through to node-gyp and needs a working compiler.
|
||||
# Without this the platform install fails deep inside a dependency tree with an
|
||||
# error that names neither Xcode nor node-pty.
|
||||
#
|
||||
# `xcode-select --install` opens a GUI dialogue and returns immediately — it does
|
||||
# not block until the download finishes. So this asks, and then says to come back,
|
||||
# rather than pretending to have waited.
|
||||
|
||||
xcode_clt_installed() { xcode-select -p &>/dev/null; }
|
||||
|
||||
xcode_clt_install() {
|
||||
xcode-select --install 2>/dev/null || true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — system configuration
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files. Locale, and the system-level
|
||||
# settings that follow it.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_SYSTEM_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_SYSTEM_LOADED=1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Locale
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Two separate facts, and the original only handled one of them:
|
||||
#
|
||||
# what a new login shell is told to use — LANG in /etc/default/locale
|
||||
# whether that locale actually exists — whether it has been generated
|
||||
#
|
||||
# Setting LANG to a locale that was never generated is the state that produces
|
||||
# "setlocale: LC_ALL: cannot change locale" on every ssh login and every perl
|
||||
# invocation. Both are checked, so the step can say which one is missing.
|
||||
|
||||
# What a new login shell will be handed, or empty if nothing is configured.
|
||||
locale_current() {
|
||||
if [[ -r /etc/default/locale ]]; then
|
||||
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/default/locale
|
||||
elif [[ -r /etc/locale.conf ]]; then
|
||||
awk -F= '/^LANG=/ { gsub(/"/, "", $2); print $2 }' /etc/locale.conf
|
||||
fi
|
||||
}
|
||||
|
||||
# Has this locale actually been built?
|
||||
#
|
||||
# `locale -a` prints en_US.utf8 where the configuration spells it en_US.UTF-8,
|
||||
# so both sides are folded to lower case with the dashes removed before
|
||||
# comparing. A literal match here would report a perfectly good locale missing.
|
||||
locale_is_generated() {
|
||||
local want="${1,,}"
|
||||
want="${want//-/}"
|
||||
locale -a 2>/dev/null | tr '[:upper:]' '[:lower:]' | tr -d '-' | grep -qx "$want"
|
||||
}
|
||||
|
||||
locale_set() {
|
||||
local want="$1"
|
||||
local escaped="${want//./\\.}"
|
||||
|
||||
case "$PM" in
|
||||
apt)
|
||||
# locale-gen comes from the `locales` package, which minimal images and
|
||||
# most cloud base images do not ship. Without this the step fails with
|
||||
# "locale-gen: command not found" halfway through.
|
||||
if ! pkg_is_installed locales; then
|
||||
info " installing locales, which provides locale-gen"
|
||||
pkg_install_now locales
|
||||
fi
|
||||
|
||||
# Uncomment it if it is there commented out, add it if it is absent.
|
||||
# Editing the file rather than passing the name to locale-gen is what makes
|
||||
# it survive: a locale generated by argument alone is lost the next time
|
||||
# anything regenerates from /etc/locale.gen.
|
||||
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
|
||||
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
|
||||
elif ! grep -qE "^${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
|
||||
# The charset is the part after the dot: en_US.UTF-8 -> UTF-8
|
||||
echo "${want} ${want##*.}" >>/etc/locale.gen
|
||||
fi
|
||||
|
||||
locale-gen
|
||||
update-locale LANG="$want"
|
||||
;;
|
||||
pacman)
|
||||
if grep -qE "^#[[:space:]]*${escaped}[[:space:]]" /etc/locale.gen 2>/dev/null; then
|
||||
sed -i "s/^#[[:space:]]*\(${escaped}[[:space:]]\)/\1/" /etc/locale.gen
|
||||
fi
|
||||
locale-gen
|
||||
echo "LANG=${want}" >/etc/locale.conf
|
||||
;;
|
||||
dnf)
|
||||
# No locale.gen here — the locales come prebuilt in langpack packages.
|
||||
pkg_install_now "glibc-langpack-${want%%_*}"
|
||||
localectl set-locale "LANG=${want}"
|
||||
;;
|
||||
brew)
|
||||
warn "macOS has no system locale to set — it is per-user, from the terminal's settings"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Swap
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
SWAPFILE=/swapfile
|
||||
|
||||
# Rounded to nearest, not floored: a 4 GiB swapfile is 4194300 kB, which floors
|
||||
# to 3 and reads as though a gigabyte went missing. Same for RAM, where 3.7 GiB
|
||||
# reporting as "3G" makes the sizing tiers look wrong.
|
||||
kb_to_gb_rounded() { echo $((($1 + 524288) / 1048576)); }
|
||||
|
||||
# Total active swap in GiB, 0 if there is none.
|
||||
#
|
||||
# From /proc/meminfo rather than by grepping swapon's output for a slash, which
|
||||
# is what the original did to spot a swap FILE — that test reports no swap at all
|
||||
# on a machine using zram or a swap partition, and the step would then add a
|
||||
# swapfile beside perfectly good swap.
|
||||
swap_active_gb() { kb_to_gb_rounded "$(awk '/^SwapTotal:/ { print $2 }' /proc/meminfo)"; }
|
||||
|
||||
ram_gb() { kb_to_gb_rounded "$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo)"; }
|
||||
|
||||
# Free space on the filesystem that would hold the swapfile, in GiB. Floored
|
||||
# rather than rounded, deliberately: this one decides how much to allocate, and
|
||||
# rounding up invents space that is not there.
|
||||
disk_free_gb() { echo $(($(df -Pk "$(dirname "$SWAPFILE")" | awk 'NR == 2 { print $4 }') / 1024 / 1024)); }
|
||||
|
||||
# How much swap this machine should have.
|
||||
#
|
||||
# The tiers are the original's. What is new is that the answer is capped by what
|
||||
# is actually on the disk — the original would try to fallocate 8G on a VPS with
|
||||
# 4G free, fail, and take the run down with it.
|
||||
swap_recommended_gb() {
|
||||
local ram size
|
||||
ram="$(ram_gb)"
|
||||
if ((ram <= 2)); then
|
||||
size=2
|
||||
elif ((ram <= 8)); then
|
||||
size=4
|
||||
else
|
||||
size=8
|
||||
fi
|
||||
|
||||
# Leave a few gigabytes behind. A swapfile that fills the disk is a worse
|
||||
# problem than no swapfile.
|
||||
local room=$(($(disk_free_gb) - 5))
|
||||
((room < size)) && size="$room"
|
||||
((size < 1)) && size=0
|
||||
echo "$size"
|
||||
}
|
||||
|
||||
# How eagerly the kernel swaps, by role.
|
||||
#
|
||||
# 10 on a server: swapping is the emergency valve, not a routine, and the cost of
|
||||
# a page fault on a request path is latency somebody is waiting for. A desktop is
|
||||
# the opposite case — swapping out an application nobody has touched in an hour
|
||||
# is exactly what you want — so dev keeps the kernel default of 60.
|
||||
swappiness_for_role() { if is_server; then echo 10; else echo 60; fi; }
|
||||
|
||||
swap_create() {
|
||||
local gb="$1"
|
||||
|
||||
# fallocate is instant but produces a file some filesystems refuse to swap on
|
||||
# (btrfs without the right attributes, zfs at all). dd is slow and always
|
||||
# works, so it is the fallback rather than the default.
|
||||
if ! fallocate -l "${gb}G" "$SWAPFILE" 2>/dev/null; then
|
||||
info " fallocate is not usable here — writing the file with dd, which is slower"
|
||||
dd if=/dev/zero of="$SWAPFILE" bs=1M count=$((gb * 1024)) status=none
|
||||
fi
|
||||
|
||||
chmod 600 "$SWAPFILE"
|
||||
mkswap "$SWAPFILE" >/dev/null
|
||||
swapon "$SWAPFILE"
|
||||
|
||||
grep -qs "^${SWAPFILE}[[:space:]]" /etc/fstab || echo "${SWAPFILE} none swap sw 0 0" >>/etc/fstab
|
||||
}
|
||||
|
||||
# Written as a drop-in rather than by rewriting /etc/sysctl.conf in place. The
|
||||
# original sed'd that file, which means the setting is tangled up with whatever
|
||||
# else lives there and is invisible to anyone looking for what this script did.
|
||||
swappiness_set() {
|
||||
echo "vm.swappiness=$1" >/etc/sysctl.d/99-machine-setup-swappiness.conf
|
||||
sysctl -q -w "vm.swappiness=$1"
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Emergency disk ballast
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# The same idea as swap, one layer down. Swap is the valve for memory pressure;
|
||||
# this is the valve for disk pressure.
|
||||
#
|
||||
# A junk file holding no data, sized at 10% of free disk. Its only job is to be
|
||||
# deleted when the filesystem is about to fill, buying enough headroom to log in
|
||||
# and clean up properly instead of meeting a wedged box — Docker, journald and
|
||||
# postgres all misbehave badly at 100% full, and some of them do not recover on
|
||||
# their own.
|
||||
#
|
||||
# A one-shot valve: once spent, it has to be recreated.
|
||||
#
|
||||
# ── Moved out of the user's home ──
|
||||
#
|
||||
# The original put the checker in $USER_HOME/.local/bin and ran it from a root
|
||||
# cron. A root cron executing a script inside a directory its owner can write is
|
||||
# a privilege escalation waiting to be noticed — moot on a box where that user
|
||||
# already has passwordless sudo, but wrong, and not something to carry forward.
|
||||
# Both the script and the file now live in root-owned system paths.
|
||||
|
||||
# Where it goes is asked rather than decided. A ballast only protects the
|
||||
# filesystem it is ON — the checker measures its own directory — so the choice is
|
||||
# also a choice of which mount is being protected. Defaults to the user's home,
|
||||
# which on most machines is the same filesystem as / and is the easiest place to
|
||||
# find it again months later.
|
||||
BALLAST_FILE=""
|
||||
BALLAST_CHECKER=/usr/local/sbin/emergency-disk-check
|
||||
BALLAST_CRON=/etc/cron.d/emergency-disk-check
|
||||
BALLAST_THRESHOLD=10
|
||||
BALLAST_NAME=emergency-disk-ballast.bin
|
||||
|
||||
ballast_exists() { [[ -n "$BALLAST_FILE" && -f "$BALLAST_FILE" ]]; }
|
||||
|
||||
ballast_size_human() { du -h "$BALLAST_FILE" 2>/dev/null | cut -f1; }
|
||||
|
||||
# The nearest directory that exists, walking up. A path being chosen for the
|
||||
# ballast does not mean anything has created it yet, and df cannot measure a
|
||||
# directory that is not there.
|
||||
existing_ancestor() {
|
||||
local dir="$1"
|
||||
while [[ ! -d "$dir" && "$dir" != "/" ]]; do dir="$(dirname "$dir")"; done
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# Free space in KiB on whichever filesystem would hold this path.
|
||||
ballast_free_kb() { df -Pk "$(existing_ancestor "$1")" | awk 'NR == 2 { print $4 }'; }
|
||||
|
||||
ballast_create() {
|
||||
local mb="$1"
|
||||
mkdir -p "$(dirname "$BALLAST_FILE")"
|
||||
|
||||
# fallocate reserves real blocks. A sparse file made with truncate would
|
||||
# reserve nothing and free nothing when deleted, which is the entire point.
|
||||
if ! fallocate -l "${mb}M" "$BALLAST_FILE" 2>/dev/null; then
|
||||
info " fallocate is not usable here — writing with dd, which is slower"
|
||||
dd if=/dev/zero of="$BALLAST_FILE" bs=1M count="$mb" status=none
|
||||
fi
|
||||
chmod 600 "$BALLAST_FILE"
|
||||
}
|
||||
|
||||
ballast_install_checker() {
|
||||
mkdir -p "$(dirname "$BALLAST_FILE")"
|
||||
cat >"$BALLAST_CHECKER" <<CHECKER
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Emergency disk ballast checker. Installed by machine-setup.
|
||||
#
|
||||
# Deletes the pre-allocated ballast file when free space falls below the
|
||||
# threshold, buying headroom to log in and clean up. Run with --status to see
|
||||
# where things stand without changing anything.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BALLAST="${BALLAST_FILE}"
|
||||
THRESHOLD=${BALLAST_THRESHOLD}
|
||||
TAG="emergency-disk"
|
||||
|
||||
# Walk up to a directory that exists. The ballast's own directory is gone if
|
||||
# somebody cleaned up after the valve was spent, and df failing under
|
||||
# \`set -e\` would make cron mail an error every ten minutes.
|
||||
MOUNT_DIR="\$(dirname "\$BALLAST")"
|
||||
while [[ ! -d "\$MOUNT_DIR" && "\$MOUNT_DIR" != "/" ]]; do MOUNT_DIR="\$(dirname "\$MOUNT_DIR")"; done
|
||||
USE_PCT="\$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { gsub(/%/, "", \$5); print \$5 }')"
|
||||
FREE_PCT=\$((100 - USE_PCT))
|
||||
|
||||
if [[ "\${1:-}" == "--status" ]]; then
|
||||
echo "Mount: \$(df -P "\$MOUNT_DIR" | awk 'NR == 2 { print \$6 }')"
|
||||
echo "Free: \${FREE_PCT}% (threshold: \${THRESHOLD}%)"
|
||||
if [[ -f "\$BALLAST" ]]; then
|
||||
echo "Ballast: present, \$(du -h "\$BALLAST" | cut -f1) — \$BALLAST"
|
||||
else
|
||||
echo "Ballast: ABSENT (already spent) — \$BALLAST"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Everything urgent goes through here, so there is one place to add a second
|
||||
# channel later. Today it is syslog only, which means the message is in the
|
||||
# journal and nowhere else — nobody finds out until they go looking, which is
|
||||
# exactly the wrong moment. Push, mail or Officer's own notify sidecar hook in
|
||||
# here.
|
||||
notify() {
|
||||
logger -t "\$TAG" -p user.crit "\$1"
|
||||
# A copy on stderr as well, so a human running this by hand sees it.
|
||||
echo "\$1" >&2
|
||||
}
|
||||
|
||||
((FREE_PCT < THRESHOLD)) || exit 0
|
||||
|
||||
if [[ -f "\$BALLAST" ]]; then
|
||||
FREED="\$(du -h "\$BALLAST" | cut -f1)"
|
||||
rm -f "\$BALLAST"
|
||||
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — deleted ballast, reclaimed \${FREED}. CLEAN UP NOW: this valve is spent."
|
||||
else
|
||||
notify "Free space \${FREE_PCT}% below \${THRESHOLD}% — ballast already spent, no headroom left to reclaim."
|
||||
fi
|
||||
CHECKER
|
||||
|
||||
chown root:root "$BALLAST_CHECKER"
|
||||
chmod 755 "$BALLAST_CHECKER"
|
||||
|
||||
cat >"$BALLAST_CRON" <<CRON
|
||||
# Emergency disk ballast — deletes the ballast file if free space drops below ${BALLAST_THRESHOLD}%.
|
||||
# Installed by machine-setup. Check status: ${BALLAST_CHECKER} --status
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
*/10 * * * * root ${BALLAST_CHECKER}
|
||||
CRON
|
||||
chmod 644 "$BALLAST_CRON"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# earlyoom
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# What happens when swap runs out too.
|
||||
#
|
||||
# The kernel's own OOM killer waits until allocation genuinely fails, and by then
|
||||
# the machine has usually spent minutes thrashing — unresponsive, ssh refusing to
|
||||
# connect, nothing to do but reset it. earlyoom watches free memory and kills the
|
||||
# largest consumer while there is still enough left to stay reachable.
|
||||
|
||||
earlyoom_is_active() { systemctl is-active --quiet earlyoom 2>/dev/null; }
|
||||
|
||||
earlyoom_install() {
|
||||
pkg_is_installed earlyoom || pkg_install_now earlyoom
|
||||
systemctl enable --now earlyoom >/dev/null 2>&1
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Resource limits
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# inotify watches: how many files one user can have the kernel watching. The
|
||||
# stock limit is small enough that one file watcher walking
|
||||
# node_modules. Every watcher on the machine draws from the same pool.
|
||||
#
|
||||
# The failure is silent, which is what makes it worth setting in advance: nothing
|
||||
# errors, the watcher simply stops noticing changes. Hot reload goes quiet, a
|
||||
# build stops rebuilding, and the reason is never on screen.
|
||||
#
|
||||
# Mostly a development concern, but not exclusively — anything running `bun
|
||||
# --watch` or serving a file browser is a watcher too.
|
||||
|
||||
INOTIFY_WATCHES=524288
|
||||
INOTIFY_INSTANCES=1024
|
||||
|
||||
inotify_current_watches() { sysctl -n fs.inotify.max_user_watches 2>/dev/null || echo 0; }
|
||||
|
||||
inotify_raise() {
|
||||
cat >/etc/sysctl.d/99-machine-setup-inotify.conf <<EOF
|
||||
# Raised by machine-setup: the 8192 default is exhausted by file watchers, and
|
||||
# the failure is silent — the watcher stops noticing changes without an error.
|
||||
fs.inotify.max_user_watches=${INOTIFY_WATCHES}
|
||||
fs.inotify.max_user_instances=${INOTIFY_INSTANCES}
|
||||
EOF
|
||||
sysctl -q -w "fs.inotify.max_user_watches=${INOTIFY_WATCHES}"
|
||||
sysctl -q -w "fs.inotify.max_user_instances=${INOTIFY_INSTANCES}"
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sleep and suspend
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# A server that suspends is a server that is off. The machine stops answering,
|
||||
# and on a box with no keyboard attached there is nothing to wake it — which is
|
||||
# the whole failure: it looks like a crash, and the only fix is physical.
|
||||
#
|
||||
# Two independent mechanisms, and both have to be dealt with:
|
||||
#
|
||||
# the sleep targets what suspend/hibernate hang off. Masking them means
|
||||
# nothing can trigger a sleep, including a stray
|
||||
# `systemctl suspend`
|
||||
# logind's handlers what closing a lid, pressing the power button or going
|
||||
# idle DO. These are what a desktop image sets, and they
|
||||
# act before anything reaches a target
|
||||
#
|
||||
# Written as a drop-in rather than by editing logind.conf in place, so what this
|
||||
# script set is one file that can be read or deleted on its own.
|
||||
|
||||
SLEEP_TARGETS=(sleep.target suspend.target hibernate.target hybrid-sleep.target)
|
||||
LOGIND_DROPIN=/etc/systemd/logind.conf.d/99-machine-setup.conf
|
||||
|
||||
# The value actually in force for a logind setting, or empty for the default.
|
||||
# Drop-ins override the main file and later ones override earlier, so the last
|
||||
# match wins — reading only logind.conf would miss a setting made by a drop-in
|
||||
# and report the machine as unconfigured when it is not.
|
||||
logind_effective() {
|
||||
local key="$1"
|
||||
{
|
||||
[[ -r /etc/systemd/logind.conf ]] && grep -hE "^${key}=" /etc/systemd/logind.conf
|
||||
for f in /etc/systemd/logind.conf.d/*.conf; do
|
||||
[[ -r "$f" ]] && grep -hE "^${key}=" "$f"
|
||||
done
|
||||
} 2>/dev/null | tail -1 | cut -d= -f2-
|
||||
}
|
||||
|
||||
sleep_targets_masked() {
|
||||
local t
|
||||
for t in "${SLEEP_TARGETS[@]}"; do
|
||||
[[ "$(systemctl is-enabled "$t" 2>/dev/null)" == "masked" ]] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# What the machine should be set to. RuntimeDirectorySize is deliberately NOT
|
||||
# here: the original set it to 10% alongside these, which is both unrelated to
|
||||
# sleeping — it is the size of /run — and systemd's own default, so the line
|
||||
# never did anything.
|
||||
logind_wanted() {
|
||||
cat <<'EOF'
|
||||
HandleLidSwitch=ignore
|
||||
HandleLidSwitchExternalPower=ignore
|
||||
HandleLidSwitchDocked=ignore
|
||||
HandlePowerKey=ignore
|
||||
IdleAction=none
|
||||
EOF
|
||||
}
|
||||
|
||||
# Is every wanted setting already in force?
|
||||
logind_is_configured() {
|
||||
local line key value
|
||||
while IFS= read -r line; do
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
[[ "$(logind_effective "$key")" == "$value" ]] || return 1
|
||||
done < <(logind_wanted)
|
||||
}
|
||||
|
||||
disable_sleep() {
|
||||
systemctl mask "${SLEEP_TARGETS[@]}" >/dev/null 2>&1
|
||||
|
||||
mkdir -p "$(dirname "$LOGIND_DROPIN")"
|
||||
{
|
||||
echo "# Written by machine-setup: this machine is a server and must not sleep."
|
||||
echo "[Login]"
|
||||
logind_wanted
|
||||
} >"$LOGIND_DROPIN"
|
||||
|
||||
# Only restart when something actually changed — a needless restart of logind
|
||||
# disturbs live sessions, and this step runs on every pass.
|
||||
systemctl restart systemd-logind
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Boot hang
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# systemd-networkd-wait-online blocks boot until the network is up. Where
|
||||
# systemd-networkd actually manages the network — a server or cloud image, via
|
||||
# cloud-init and netplan — it does that in milliseconds and is load-bearing.
|
||||
#
|
||||
# Where NetworkManager owns the network instead, systemd-networkd runs nothing,
|
||||
# but the wait-online unit is still enabled and waits for a link that will never
|
||||
# be configured. It gives up after its full timeout, on every boot.
|
||||
#
|
||||
# So the fix is masking one unit, and only on the second stack. Do NOT
|
||||
# `systemctl disable --now systemd-networkd` to achieve the same thing: on a
|
||||
# networkd-managed box that brings it up with no network on the next boot, no
|
||||
# ssh, and nothing but the provider's rescue console.
|
||||
|
||||
WAIT_ONLINE_UNIT=systemd-networkd-wait-online.service
|
||||
|
||||
network_manager_name() {
|
||||
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
|
||||
echo "NetworkManager"
|
||||
elif systemctl is-active --quiet systemd-networkd.service 2>/dev/null; then
|
||||
echo "systemd-networkd"
|
||||
else
|
||||
echo "neither — unclear"
|
||||
fi
|
||||
}
|
||||
|
||||
# Is the wait actually pointless here? NetworkManager in charge and networkd not.
|
||||
# Anything else, including "cannot tell", is left alone.
|
||||
wait_online_is_spurious() {
|
||||
systemctl is-active --quiet NetworkManager.service 2>/dev/null &&
|
||||
! systemctl is-active --quiet systemd-networkd.service 2>/dev/null
|
||||
}
|
||||
|
||||
# What that unit actually cost on this boot, straight from systemd's own
|
||||
# accounting. Worth printing rather than asking somebody whether boot "feels
|
||||
# slow": the answer is either 14ms or two minutes, and there is no arguing with
|
||||
# it. Empty when the unit did not run.
|
||||
wait_online_boot_time() {
|
||||
systemd-analyze blame 2>/dev/null | awk -v u="$WAIT_ONLINE_UNIT" '$NF == u { $NF = ""; sub(/[[:space:]]+$/, ""); print; exit }'
|
||||
}
|
||||
|
||||
# Returns 0 whatever happens — see swappiness_set for why an optional step must
|
||||
# not be able to abort the run.
|
||||
mask_wait_online() {
|
||||
systemctl mask --now "$WAIT_ONLINE_UNIT" >/dev/null 2>&1
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Timezone
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# The shortlist offered at the prompt. Any zone name can be typed instead, so
|
||||
# this is a convenience rather than a limit.
|
||||
TZ_OPTIONS=(UTC Europe/Lisbon Europe/London Europe/Berlin Europe/Stockholm US/Eastern US/Pacific Asia/Tokyo)
|
||||
|
||||
# What the machine is set to now.
|
||||
#
|
||||
# Three sources because they disagree about availability rather than about the
|
||||
# answer: timedatectl is absent without systemd (containers, WSL), /etc/timezone
|
||||
# is Debian-specific, and the /etc/localtime symlink is the one thing that is
|
||||
# always true when any of them are.
|
||||
timezone_current() {
|
||||
if command -v timedatectl &>/dev/null && timedatectl show -p Timezone --value 2>/dev/null | grep -q .; then
|
||||
timedatectl show -p Timezone --value 2>/dev/null
|
||||
elif [[ -r /etc/timezone ]]; then
|
||||
tr -d '[:space:]' </etc/timezone
|
||||
elif [[ -L /etc/localtime ]]; then
|
||||
readlink -f /etc/localtime | sed 's|.*/zoneinfo/||'
|
||||
fi
|
||||
}
|
||||
|
||||
# Checked against the zoneinfo database before it is used. `timedatectl
|
||||
# set-timezone` on a name that does not exist fails, and under `set -e` that
|
||||
# takes the whole run down over a typo.
|
||||
timezone_is_valid() { [[ -f "/usr/share/zoneinfo/$1" ]]; }
|
||||
|
||||
timezone_set() {
|
||||
local tz="$1"
|
||||
case "$PM" in
|
||||
brew) systemsetup -settimezone "$tz" >/dev/null ;;
|
||||
*)
|
||||
# timedatectl where there is a systemd to talk to; the files directly
|
||||
# otherwise, which is the same thing it would have written.
|
||||
if command -v timedatectl &>/dev/null && [[ "$IS_WSL" != true ]]; then
|
||||
timedatectl set-timezone "$tz"
|
||||
else
|
||||
ln -sf "/usr/share/zoneinfo/${tz}" /etc/localtime
|
||||
echo "$tz" >/etc/timezone
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — Tailscale
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
#
|
||||
# ── Why this runs early ──
|
||||
#
|
||||
# It is a second way into the machine. The section that can lock you out is SSH
|
||||
# hardening, and everything after this one can break networking in some smaller
|
||||
# way; having the tailnet up first means a mistake is recoverable rather than a
|
||||
# trip to a rescue console.
|
||||
#
|
||||
# ── Why it matters to Officer specifically ──
|
||||
#
|
||||
# The platform's CLAUDE.md is explicit: the perimeter IS the tailnet. Origin
|
||||
# checking was removed outright on 2026-08-13 because the tailnet stands in its
|
||||
# place, so a valid token plus the tailnet IS the lock — not one layer of two.
|
||||
# An Officer install with no tailnet is missing the half the design assumes.
|
||||
#
|
||||
# ── Why the original hung ──
|
||||
#
|
||||
# It passed --authkey unconditionally, and its prompt accepted an empty answer.
|
||||
# `tailscale up --authkey ""` falls back to interactive login: it prints a URL and
|
||||
# blocks, with no timeout, forever. Nothing here passes an empty key, every call
|
||||
# has a timeout, and the state is read before anything is run.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_TAILSCALE_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_TAILSCALE_LOADED=1
|
||||
|
||||
TS_EXIT_SYSCTL=/etc/sysctl.d/99-tailscale-exit.conf
|
||||
TS_DISPATCHER=/etc/networkd-dispatcher/routable.d/50-tailscale-exit
|
||||
|
||||
# Printed only when asked for. The section leads with the question rather than
|
||||
# with ten lines of explanation: somebody who runs Tailscale already does not need
|
||||
# to be told what it is, and somebody who does not can type ?.
|
||||
tailscale_help() {
|
||||
echo " Tailscale is a private network between your own machines, over"
|
||||
echo " WireGuard. Every device you enrol gets a stable 100.x address and"
|
||||
echo " can reach every other, wherever they are — through NAT, across"
|
||||
echo " providers, without either end having a public address."
|
||||
echo ""
|
||||
echo " Nothing is published to the open internet to make that work: no"
|
||||
echo " port forwarding, no exposed ports, no holes in the firewall."
|
||||
echo ""
|
||||
echo " For Officer it is not a convenience. The platform is built assuming"
|
||||
echo " the tailnet IS the perimeter, and there is no origin checking behind"
|
||||
echo " it — a valid token plus the tailnet is the whole lock. Without the"
|
||||
echo " tailnet you are running with half of it missing."
|
||||
echo ""
|
||||
echo " It is installed at this point in the run, before anything that can"
|
||||
echo " lock you out of the machine, so there is always a second way in."
|
||||
}
|
||||
|
||||
# The menu itself, in a function because it is shown twice — once to ask, and
|
||||
# again after ? has printed the long answer, so the reader is not dropped back at
|
||||
# a bare prompt having forgotten what the options were.
|
||||
tailscale_network_menu() {
|
||||
info "Which network should this machine join?"
|
||||
echo ""
|
||||
echo " [1] set up your own network — offscale"
|
||||
echo " Your own coordination server. The protocol on the wire is"
|
||||
echo " Tailscale's and the encryption is WireGuard's; offscale changes"
|
||||
echo " neither — it runs headscale's open-source code. What changes is"
|
||||
echo " the work: managed from an app rather than a terminal, and"
|
||||
echo " enrolling a device is a link and a tap."
|
||||
echo " offscale — just like headscale, and just like Tailscale's own"
|
||||
echo " service — needs to run on a publicly reachable server of its"
|
||||
echo " own. A small VPS is enough. Not this machine, not behind a home"
|
||||
echo " router: every device that joins has to find it, including phones"
|
||||
echo " on mobile data. The only difference from option 3 is who runs"
|
||||
echo " that server."
|
||||
echo " Follow that setup through first, then come back here with its"
|
||||
echo " address and a key."
|
||||
echo " https://officer.dev/infrastructure/offscale.html#install"
|
||||
echo ""
|
||||
echo " [2] use a network you already run — headscale or offscale"
|
||||
echo " You already have a coordination server somewhere. Point this"
|
||||
echo " machine at it and it joins that network alongside the rest."
|
||||
echo ""
|
||||
echo " [3] the easy route — tailscale.com"
|
||||
echo " Tailscale runs the coordination for you. Nothing to host and"
|
||||
echo " nothing to maintain, free for personal use; the trade is that"
|
||||
echo " the list of your machines lives with them."
|
||||
echo ""
|
||||
echo " [4] no private network at all"
|
||||
echo " This machine is reached over the open internet, or not at all."
|
||||
echo " Everything the tailnet was doing becomes yours to do."
|
||||
echo ""
|
||||
echo " [?] what are tailscale, headscale and offscale?"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# The long answer, printed when somebody types ?. Covers all three names,
|
||||
# because the menu offers all three and two of them are not words anyone outside
|
||||
# this project would know.
|
||||
tailscale_networks_help() {
|
||||
echo " Tailscale, headscale and offscale are three answers to one question:"
|
||||
echo " who keeps the list of your machines and hands out the keys they use"
|
||||
echo " to find each other."
|
||||
echo ""
|
||||
echo " The network itself is the same in all three cases. Machines talk"
|
||||
echo " directly to each other over WireGuard, encrypted end to end. What"
|
||||
echo " differs is only the coordination server — the thing that knows which"
|
||||
echo " machines are yours. It never carries your traffic."
|
||||
echo ""
|
||||
echo " TAILSCALE"
|
||||
echo " The company's own coordination server. Nothing to run, nothing to"
|
||||
echo " maintain, free for personal use. You sign in with an existing"
|
||||
echo " identity and your machines appear in their admin console."
|
||||
echo " The trade is that the list of your machines lives with them."
|
||||
echo ""
|
||||
echo " HEADSCALE"
|
||||
echo " An open-source coordination server you run yourself. The same"
|
||||
echo " Tailscale clients connect to it, so the machines behave identically;"
|
||||
echo " the difference is that nobody else holds the list. The cost is that"
|
||||
echo " it is now a service you host, and it needs to be reachable."
|
||||
echo ""
|
||||
echo " OFFSCALE"
|
||||
echo " Our own distribution of headscale, which is to say: headscale. The"
|
||||
echo " protocol on the wire is Tailscale's and the encryption is"
|
||||
echo " WireGuard's, and offscale changes neither — it runs the same"
|
||||
echo " open-source project. A machine on an offscale network behaves"
|
||||
echo " exactly as it would on either of the other two. There is no offscale"
|
||||
echo " protocol to be locked into, because there is no offscale protocol."
|
||||
echo ""
|
||||
echo " Clients: stock Tailscale on computers. On iPhone, iPad and Android"
|
||||
echo " there is our own app — the Tailscale client, our branding, and one"
|
||||
echo " real difference: it takes an invite from the server directly. That"
|
||||
echo " is the part of running headscale people give up at, because the"
|
||||
echo " official app has to be talked into using a server that is not"
|
||||
echo " Tailscale's. Desktop apps of our own are not there yet; on a"
|
||||
echo " computer you point the official client at your own server."
|
||||
echo ""
|
||||
# Where it runs matters more than how it installs, and is the thing people
|
||||
# get wrong: a coordination server at home is unreachable from exactly the
|
||||
# devices a private network exists to reach.
|
||||
echo " Where it runs: on a publicly reachable server of its own — a small"
|
||||
echo " VPS is enough. Not on this machine, and not behind a home router."
|
||||
echo " Every device that joins has to find it, including phones on mobile"
|
||||
echo " data and laptops in other buildings, so it needs an address that"
|
||||
echo " resolves from anywhere."
|
||||
echo ""
|
||||
echo " This is not something offscale asks for and the others do not. It"
|
||||
echo " is true of headscale, and it is true of Tailscale — their"
|
||||
echo " coordination server is publicly reachable too, they simply run it"
|
||||
echo " for you. That is the whole of the difference between choosing"
|
||||
echo " option 3 and choosing to host it yourself."
|
||||
echo ""
|
||||
echo " What it does that plain headscale does not:"
|
||||
echo " · installs in one command on that server, certificates included"
|
||||
echo " · health, logs, restarts and access policies from the app,"
|
||||
echo " instead of a config file and a CLI"
|
||||
echo " · enrolling a device is a link and a tap — the key is minted"
|
||||
echo " and handed over for you"
|
||||
echo " · several networks at once, and services reachable across them"
|
||||
echo ""
|
||||
echo " https://officer.dev/infrastructure/offscale.html"
|
||||
echo ""
|
||||
echo " FOR OFFICER"
|
||||
echo " Whichever you pick, the tailnet is what Officer treats as its"
|
||||
echo " perimeter, and it is not one layer of two — there is no origin"
|
||||
echo " checking behind it. Installed at this point in the run,"
|
||||
echo " before anything that can lock you out, so there is always a second"
|
||||
echo " way in."
|
||||
echo ""
|
||||
echo " Officer also administers it. Its Headscale app talks to headscale"
|
||||
echo " and offscale servers alike: register as many as you run, see which"
|
||||
echo " are actually up — each is probed, not remembered — and switch"
|
||||
echo " between them. On whichever is active you get the nodes, the users,"
|
||||
echo " the pre-auth keys, the invites and the ACL policy, with an"
|
||||
echo " assistant for writing it, plus a console and diagnostics. So the"
|
||||
echo " server this section sets up is managed from the same place as"
|
||||
echo " everything else on this machine, rather than over ssh and a CLI."
|
||||
}
|
||||
|
||||
tailscale_is_installed() { command -v tailscale &>/dev/null; }
|
||||
|
||||
# NeedsLogin, Running, Stopped, NoState… Read before acting, because the original's
|
||||
# failure was running `up` blindly against a node that was already up.
|
||||
tailscale_state() {
|
||||
tailscale status --json 2>/dev/null | awk -F'"' '/"BackendState"/ { print $4; exit }'
|
||||
}
|
||||
|
||||
tailscale_ip() { tailscale ip -4 2>/dev/null | head -1; }
|
||||
|
||||
# Which control plane this node is talking to. Empty means Tailscale's own.
|
||||
tailscale_control_url() {
|
||||
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
|
||||
}
|
||||
|
||||
# The official install.sh is a Linux package-manager script. macOS gets the same
|
||||
# daemon wrapped in a GUI app, and the cask is the version with a CLI at
|
||||
# /Applications/Tailscale.app/Contents/MacOS/Tailscale — the Mac App Store build
|
||||
# is sandboxed and ships no usable `tailscale` binary, which is the difference
|
||||
# that matters to a script.
|
||||
tailscale_install() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install --cask tailscale
|
||||
return
|
||||
fi
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
}
|
||||
|
||||
# Tailscale's own coordination server, spelled out.
|
||||
#
|
||||
# Passed explicitly even when it is the default, because `tailscale up` with no
|
||||
# --login-server keeps whatever ControlURL is already stored. On a node already
|
||||
# pointed at a self-hosted server, choosing "the easy route" would otherwise
|
||||
# leave it exactly where it was — no error, no message, wrong answer.
|
||||
TS_DEFAULT_CONTROL_URL="https://controlplane.tailscale.com"
|
||||
|
||||
# Moving a node between coordination servers is not something `up` will do while
|
||||
# it is logged in to one. Logging out first is the documented way, and doing it
|
||||
# unasked would be worse than saying so.
|
||||
# What choosing "no private network" actually hands you, said before it is
|
||||
# chosen rather than discovered afterwards.
|
||||
tailscale_none_warning() {
|
||||
echo " Without a tailnet, everything it was doing becomes yours:"
|
||||
echo ""
|
||||
echo " · Anything you want to reach remotely has to be published to the"
|
||||
echo " open internet deliberately, and kept closed otherwise."
|
||||
echo " · TLS certificates are yours to obtain and to keep renewed."
|
||||
echo " · Every exposed service needs its own authentication, because"
|
||||
echo " there is no longer a network boundary in front of it."
|
||||
echo " · This machine will be found. Anything listening on a public"
|
||||
echo " address is scanned within minutes and attacked continuously."
|
||||
echo ""
|
||||
echo " For Officer specifically, this removes a layer that cannot be put"
|
||||
echo " back from a setting:"
|
||||
echo ""
|
||||
echo " There is no origin checking in the platform. It was removed"
|
||||
echo " because the tailnet is the perimeter, so a valid token plus the"
|
||||
echo " tailnet is the entire lock. With no tailnet, the token is the"
|
||||
echo " only thing left. Put an HTTPS reverse proxy in front of the"
|
||||
echo " platform and restrict who can reach it at the network layer."
|
||||
}
|
||||
|
||||
tailscale_needs_logout() {
|
||||
local current="$1" target="$2"
|
||||
[[ -n "$current" && -n "$target" && "$current" != "$target" ]]
|
||||
}
|
||||
|
||||
# Routing has to be on before this machine can forward anyone else's packets,
|
||||
# whether as an exit node or as a subnet router. Written as a drop-in so it is
|
||||
# visible as this script's doing.
|
||||
enable_ip_forwarding() {
|
||||
cat >"$TS_EXIT_SYSCTL" <<'EOF'
|
||||
# Written by machine-setup: required to forward traffic for other tailnet nodes,
|
||||
# as an exit node or as a subnet router.
|
||||
net.ipv4.ip_forward = 1
|
||||
net.ipv6.conf.all.forwarding = 1
|
||||
EOF
|
||||
sysctl --system >/dev/null 2>&1
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# UDP GRO forwarding, which Tailscale documents as roughly doubling throughput on
|
||||
# a node that forwards for others. Applied on every routable event rather than
|
||||
# once, because the settings are per-interface and do not survive the link going
|
||||
# down and back up.
|
||||
install_exit_node_tuning() {
|
||||
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
|
||||
|
||||
mkdir -p "$(dirname "$TS_DISPATCHER")"
|
||||
cat >"$TS_DISPATCHER" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Written by machine-setup. NIC offload settings for a Tailscale exit node or
|
||||
# subnet router — Tailscale's own recommendation for forwarding throughput.
|
||||
set -Eeuo pipefail
|
||||
|
||||
IF="${IFACE:-}"
|
||||
if [[ -z "${IF}" ]]; then
|
||||
IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')"
|
||||
fi
|
||||
|
||||
[[ -n "${IF}" ]] || exit 0
|
||||
command -v ethtool >/dev/null 2>&1 || exit 0
|
||||
|
||||
ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true
|
||||
ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true
|
||||
ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
chmod 755 "$TS_DISPATCHER"
|
||||
systemctl enable --now networkd-dispatcher >/dev/null 2>&1 || true
|
||||
|
||||
# And once now, for the interface that is already up.
|
||||
IFACE="$(default_iface)" bash "$TS_DISPATCHER" >/dev/null 2>&1 || true
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# The LAN this machine sits on, as a CIDR — the useful default for a subnet
|
||||
# router, and the number nobody remembers offhand.
|
||||
lan_cidr() {
|
||||
local iface
|
||||
iface="$(default_iface)"
|
||||
ip -4 route show dev "$iface" 2>/dev/null |
|
||||
awk '$1 ~ /\// && $1 !~ /^default/ { print $1; exit }'
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — command-line tools that do not come from the distribution
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
#
|
||||
# These four were buried inside "System Update & Essentials", after the package
|
||||
# install and with no announcement, so a run appeared to be installing system
|
||||
# packages and then started downloading tarballs and printing a shell tutorial.
|
||||
# They are their own concern: upstream binaries, fetched from upstream, on their
|
||||
# own release cadence.
|
||||
#
|
||||
# Each one is checked before it is fetched. The original re-ran every installer
|
||||
# on every run — which is how a machine that already had starship got it
|
||||
# reinstalled, along with its "add this to your ~/.zshrc" instructions, which we
|
||||
# do not want because this script writes the shell config itself.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_TOOLS_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_TOOLS_LOADED=1
|
||||
|
||||
# The set installed on every machine, in the order they are fetched.
|
||||
#
|
||||
# fastfetch was here until 2026-08-14 and was removed after it stopped a real
|
||||
# install. It is the only one of these with no source but a third-party PPA on
|
||||
# Ubuntu 24.04 and older, and the failure was in the half that was not guarded:
|
||||
# a PPA that ADDS cleanly but carries no package for the running codename gets
|
||||
# past the `|| skip` and dies on the install instead. A neofetch clone is not
|
||||
# worth a branch in a script that has to survive on machines nobody has seen.
|
||||
tools_default() { echo lazydocker lazygit starship; }
|
||||
|
||||
# The command that proves a tool is already here. Same as the tool name for all
|
||||
# three today, but kept as a mapping because that is not a rule — a package and
|
||||
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
|
||||
# indirection.
|
||||
tool_command() {
|
||||
case "$1" in
|
||||
lazydocker) echo lazydocker ;;
|
||||
lazygit) echo lazygit ;;
|
||||
starship) echo starship ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The installers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
tool_install_lazydocker() {
|
||||
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
|
||||
DIR=/usr/local/bin bash
|
||||
}
|
||||
|
||||
# The one that was actually broken on arm64: the asset name was hardcoded to
|
||||
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
|
||||
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
|
||||
# two spellings ARCH uses, hence the mapping.
|
||||
tool_install_lazygit() {
|
||||
local version asset url
|
||||
case "$ARCH" in
|
||||
amd64) asset="x86_64" ;;
|
||||
arm64) asset="arm64" ;;
|
||||
esac
|
||||
|
||||
version="$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest | jq -r '.tag_name')"
|
||||
# Strip only the leading v. The original used `tr -d 'v'`, which deletes every
|
||||
# v in the string and would mangle any tag that had one anywhere else.
|
||||
version="${version#v}"
|
||||
[[ -n "$version" ]] || {
|
||||
warn "could not read the latest lazygit version — skipping"
|
||||
return 0
|
||||
}
|
||||
|
||||
url="https://github.com/jesseduffield/lazygit/releases/download/v${version}/lazygit_${version}_Linux_${asset}.tar.gz"
|
||||
curl -fsSLo /tmp/lazygit.tar.gz "$url"
|
||||
tar -C /usr/local/bin -xzf /tmp/lazygit.tar.gz lazygit
|
||||
rm -f /tmp/lazygit.tar.gz
|
||||
}
|
||||
|
||||
# Quiet on purpose. The installer ends by printing how to add starship to bash,
|
||||
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
|
||||
# already writes the zsh config itself. Errors still come through.
|
||||
tool_install_starship() {
|
||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Acting
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Announce the section, then fetch only what is absent — same contract and same
|
||||
# output shape as pkg_install, so the two read alike in a transcript.
|
||||
tools_install() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
local tool
|
||||
local -a missing=() present=()
|
||||
LAST_SKIPPED=()
|
||||
for tool in "$@"; do
|
||||
if tool_is_installed "$tool"; then present+=("$tool"); else missing+=("$tool"); fi
|
||||
done
|
||||
|
||||
LAST_INSTALLED=("${missing[@]}")
|
||||
LAST_KEPT=("${present[@]}")
|
||||
|
||||
announce_plan "$label" present missing || return 0
|
||||
|
||||
for tool in "${missing[@]}"; do
|
||||
info " installing ${tool}..."
|
||||
"tool_install_${tool}"
|
||||
done
|
||||
}
|
||||
Executable
+2635
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
|
||||
Executable
+965
@@ -0,0 +1,965 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# officer-setup — the platform, on a machine that is already provisioned
|
||||
#
|
||||
# The second half of the install. machine-setup/ brings a blank box up to a
|
||||
# usable machine; this puts Officer on top of it.
|
||||
#
|
||||
# Run as root: sudo scripts/setup/officer-setup.sh
|
||||
# =============================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
|
||||
|
||||
ONLY_STEP=""
|
||||
|
||||
# Kept before the loop consumes them. This script re-executes itself through sudo
|
||||
# further down and was passing `"$@"`, which `shift` had already emptied — so
|
||||
# `officer-setup.sh --only build` run as a normal user silently became a FULL run
|
||||
# the moment it escalated. Nothing said so; the flag just stopped existing.
|
||||
ORIGINAL_ARGS=(${@+"$@"})
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--only)
|
||||
ONLY_STEP="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--only=*)
|
||||
ONLY_STEP="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
# Set before lib/repo.sh is sourced below, which reads it as
|
||||
# `${OFFICER_REPO:-<default>}` — so this wins and an absent flag still defaults.
|
||||
--repo)
|
||||
[[ -n "${2:-}" ]] || {
|
||||
echo "--repo needs a URL" >&2
|
||||
exit 2
|
||||
}
|
||||
OFFICER_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--repo=*)
|
||||
OFFICER_REPO="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--unattended | -y)
|
||||
export UNATTENDED=1 ASSUME_YES=1
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>] [--unattended]"
|
||||
echo ""
|
||||
echo " --only <step> run one step; --list names them"
|
||||
echo " --unattended take the default for every question that has one (-y)"
|
||||
echo " --repo <url> clone from here instead of the default, which is a"
|
||||
echo " private Gitea over SSH and only authenticates on a"
|
||||
echo " machine whose key it already knows. Same as exporting"
|
||||
echo " OFFICER_REPO. Ignored once the repo is checked out."
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
||||
esac
|
||||
done
|
||||
export OFFICER_REPO="${OFFICER_REPO:-}"
|
||||
|
||||
# shellcheck source=officer-setup/lib/base.sh
|
||||
source "$SCRIPT_DIR/report.sh"
|
||||
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
|
||||
# shellcheck source=officer-setup/lib/preflight.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
|
||||
# shellcheck source=officer-setup/lib/repo.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/repo.sh"
|
||||
# shellcheck source=officer-setup/lib/layout.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
|
||||
# shellcheck source=officer-setup/lib/postgres.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
|
||||
# shellcheck source=officer-setup/lib/env.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/env.sh"
|
||||
# shellcheck source=officer-setup/lib/secrets.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/secrets.sh"
|
||||
# shellcheck source=officer-setup/lib/build.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/build.sh"
|
||||
# shellcheck source=officer-setup/lib/services.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/services.sh"
|
||||
# shellcheck source=officer-setup/lib/proxy.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/proxy.sh"
|
||||
|
||||
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
|
||||
|
||||
# =============================================================================
|
||||
# 1. Pre-flight
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}║ Officer Setup ║${NC}"
|
||||
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# ── Privileges: asked for, not demanded ──
|
||||
#
|
||||
# Run this as YOURSELF. It needs root on Linux, so it asks through sudo and
|
||||
# re-executes itself rather than making you type it. Variables are passed to sudo
|
||||
# by name rather than with -E, because `env_reset` is the sudoers default and
|
||||
# strips the environment — which is how DATA_PATH was lost once already.
|
||||
#
|
||||
# macOS never escalates: Homebrew refuses to run as root, and the account running
|
||||
# this IS the owner, so there is nothing to chown and nothing to drop to.
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
if [[ "$EUID" -eq 0 ]]; then
|
||||
fail "Do not run this with sudo on macOS — run it as yourself."
|
||||
fi
|
||||
elif [[ "$EUID" -ne 0 ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
|
||||
echo ""
|
||||
echo " This needs administrator rights. You will be asked for your password."
|
||||
echo ""
|
||||
exec sudo \
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-}" \
|
||||
SETUP_USERNAME="${SETUP_USERNAME:-}" \
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}" \
|
||||
REPORT_FILE="${REPORT_FILE:-}" \
|
||||
UNATTENDED="${UNATTENDED:-}" \
|
||||
ASSUME_YES="${ASSUME_YES:-}" \
|
||||
OFFICER_REPO="${OFFICER_REPO:-}" \
|
||||
bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
|
||||
fi
|
||||
|
||||
trap report_flush EXIT
|
||||
|
||||
# ── what machine-setup already established ──
|
||||
echo ""
|
||||
if load_machine_answers; then
|
||||
info "Read from machine-setup: ${MACHINE_ANSWERS}"
|
||||
else
|
||||
warn "machine-setup has not run on this machine"
|
||||
echo " That is fine if you provisioned it another way — the questions it"
|
||||
echo " would have answered are asked below instead."
|
||||
fi
|
||||
|
||||
# ── the account ──
|
||||
#
|
||||
# A remembered answer can go stale: the account it names may have been renamed or
|
||||
# removed since machine-setup ran. That is a reason to ask again, not a reason to
|
||||
# stop — so the remembered value is checked before it is trusted, and a bad one
|
||||
# is reported and replaced rather than ending the run.
|
||||
if [[ -n "$USERNAME" ]] && ! owner_exists; then
|
||||
warn "the remembered account '${USERNAME}' does not exist on this machine any more"
|
||||
USERNAME=""
|
||||
fi
|
||||
|
||||
while [[ -z "$USERNAME" ]] || ! owner_exists; do
|
||||
echo ""
|
||||
info "Which account owns this Officer install?"
|
||||
echo " Its files, its node_modules and its pm2 process list all belong to"
|
||||
echo " this account rather than to root."
|
||||
echo ""
|
||||
ask_required USERNAME "Username" "${SUDO_USER:-}"
|
||||
owner_exists || warn "There is no account called '${USERNAME}' on this machine."
|
||||
done
|
||||
|
||||
resolve_user_home
|
||||
|
||||
# ── where it goes ──
|
||||
if [[ -z "$OFFICER_ROOT" ]]; then
|
||||
echo ""
|
||||
info "Where should Officer be installed?"
|
||||
echo " One directory holding the app, its data, the item store and any"
|
||||
echo " containers the app store provisions."
|
||||
echo ""
|
||||
ask_required OFFICER_ROOT "Path" "${USER_HOME}/officerdev"
|
||||
fi
|
||||
OFFICER_ROOT="${OFFICER_ROOT/#\~/$USER_HOME}"
|
||||
[[ "$OFFICER_ROOT" == /* ]] || fail "That needs to be an absolute path — got '${OFFICER_ROOT}'"
|
||||
OFFICER_ROOT="${OFFICER_ROOT%/}"
|
||||
|
||||
info "Account: ${USERNAME} (home ${USER_HOME})"
|
||||
info "Officer: ${OFFICER_ROOT}"
|
||||
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
|
||||
|
||||
# ── recover what earlier runs already decided ──
|
||||
#
|
||||
# A skipped section leaves its variables unset, and later sections read them. On
|
||||
# a resume that is every section before the one it stopped at, so Build announced
|
||||
# "PUBLIC_URL <not set — run the Environment section first>" on a machine whose
|
||||
# .env had been written twenty minutes earlier.
|
||||
#
|
||||
# Read back here, once, from the file that already holds the answers, rather than
|
||||
# per-section — three variables cross a section boundary (ENV_PORT and
|
||||
# ENV_PUBLIC_URL from Environment, POSTGRES_URL from Database) and the next one
|
||||
# added would have to remember to do this again.
|
||||
#
|
||||
# Only fills what is EMPTY, so a variable passed in on the command line still
|
||||
# wins, and a section that runs for real still overwrites it with its own answer.
|
||||
if [[ -f "$(env_file)" ]]; then
|
||||
ENV_PORT="${ENV_PORT:-$(env_get PORT)}"
|
||||
ENV_PUBLIC_URL="${ENV_PUBLIC_URL:-$(env_get PUBLIC_URL)}"
|
||||
POSTGRES_URL="${POSTGRES_URL:-$(env_get POSTGRES_URL)}"
|
||||
fi
|
||||
|
||||
# ── is the machine actually ready ──
|
||||
#
|
||||
# Checked and reported together. Finding out about a missing bun three sections
|
||||
# in, after a repository has been cloned and a database started, is a worse way
|
||||
# to learn it.
|
||||
echo ""
|
||||
info "What Officer needs from this machine"
|
||||
|
||||
mapfile -t MISSING < <(missing_tools)
|
||||
mapfile -t MISSING_OPT < <(missing_optional_tools)
|
||||
|
||||
for t in "${REQUIRED_TOOLS[@]}"; do
|
||||
if command -v "$t" &>/dev/null; then
|
||||
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
|
||||
else
|
||||
printf ' %-6s %-10s %s\n' "$t" "MISSING" "$(tool_why "$t")"
|
||||
fi
|
||||
done
|
||||
for t in "${OPTIONAL_TOOLS[@]}"; do
|
||||
if command -v "$t" &>/dev/null; then
|
||||
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
|
||||
else
|
||||
printf ' %-6s %-10s %s\n' "$t" "absent" "$(tool_why "$t") — optional"
|
||||
fi
|
||||
done
|
||||
|
||||
if ((${#MISSING[@]} > 0)); then
|
||||
echo ""
|
||||
fail "Missing: ${MISSING[*]}. Run scripts/setup/machine-setup/machine-setup.sh first, or install them yourself."
|
||||
fi
|
||||
|
||||
if ((${#MISSING_OPT[@]} > 0)); then
|
||||
echo ""
|
||||
warn "No Docker. Postgres will have to be one you already run, and the app"
|
||||
echo " store cannot provision anything until Docker is installed."
|
||||
fi
|
||||
|
||||
if [[ -f "$PROGRESS_FILE" ]]; then
|
||||
echo ""
|
||||
info "Resuming — $(wc -l <"$PROGRESS_FILE") step(s) already done, and they will be skipped"
|
||||
echo " To start over instead: sudo rm ${PROGRESS_FILE}"
|
||||
else
|
||||
echo ""
|
||||
echo " This can be stopped at any point and run again later. Completed"
|
||||
echo " steps are remembered and skipped."
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 2. Layout
|
||||
# =============================================================================
|
||||
#
|
||||
# Before the repository, because the repository is cloned into it.
|
||||
|
||||
report_section "Layout"
|
||||
step "Layout"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Layout — everything Officer owns, under one root"
|
||||
echo " ${OFFICER_ROOT}/"
|
||||
echo " platform/ the app"
|
||||
echo " data/ managed homes, attachments, job logs"
|
||||
echo " dockers/ anything the app store provisions"
|
||||
echo " capabilities/ skills, tools, tasks, processes"
|
||||
echo ""
|
||||
echo " Nothing here is configurable. The original asked separately for the"
|
||||
echo " data directory and the item store, which were two answers that had"
|
||||
echo " to agree with each other. One root now, and the rest follows."
|
||||
echo ""
|
||||
echo " To put data/ on a bigger volume later, symlink it — that is a"
|
||||
echo " decision about storage rather than about how Officer is laid out."
|
||||
|
||||
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
|
||||
if ((${#WRONG_OWNER[@]} > 0)); then
|
||||
echo ""
|
||||
warn "these exist but do not belong to ${USERNAME}:"
|
||||
printf ' %s\n' "${WRONG_OWNER[@]}"
|
||||
echo " Everything that writes into them runs as ${USERNAME} — the platform"
|
||||
echo " under pm2, the app store's compose files, the item store the agent"
|
||||
echo " authors into. Left as they are, those writes fail in a way that"
|
||||
echo " reads as a bug in the platform."
|
||||
if confirm "Give them to ${USERNAME}?"; then
|
||||
for d in "${WRONG_OWNER[@]}"; do chown -R "${USERNAME}:$(user_group)" "$d"; done
|
||||
ok "ownership corrected"
|
||||
SUMMARY+=("Layout: ownership corrected on ${#WRONG_OWNER[@]} directory(ies)")
|
||||
fi
|
||||
fi
|
||||
|
||||
create_layout
|
||||
ok "layout in place under ${OFFICER_ROOT}"
|
||||
SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)")
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3. Repository
|
||||
# =============================================================================
|
||||
|
||||
report_section "Repository"
|
||||
step "Repository"
|
||||
if ! skip; then
|
||||
PLATFORM_DIR="$(platform_dir)"
|
||||
|
||||
echo ""
|
||||
info "Repository — where the platform's code lives"
|
||||
echo " path: ${PLATFORM_DIR}"
|
||||
|
||||
if repo_exists; then
|
||||
echo " remote: $(repo_remote)"
|
||||
echo " branch: $(repo_branch)"
|
||||
echo " working: $(repo_is_dirty && echo 'has uncommitted changes' || echo 'clean')"
|
||||
|
||||
# Reported, never silently corrected. Repointing somebody's remote is a
|
||||
# decision about where their work goes, and this script is not entitled to
|
||||
# make it quietly.
|
||||
if [[ -n "$(repo_remote)" && "$(repo_remote)" != "$OFFICER_REPO" ]]; then
|
||||
echo ""
|
||||
warn "this checkout points somewhere other than ${OFFICER_REPO}"
|
||||
echo " Left alone. To move it:"
|
||||
echo " git -C ${PLATFORM_DIR} remote set-url origin ${OFFICER_REPO}"
|
||||
fi
|
||||
|
||||
if repo_is_dirty; then
|
||||
echo ""
|
||||
echo " not pulling — there are uncommitted changes here, and a pull"
|
||||
echo " would either fail or bury them"
|
||||
SUMMARY+=("Repository: present at ${PLATFORM_DIR}, left alone (uncommitted changes)")
|
||||
elif confirm "Pull the latest changes?"; then
|
||||
if pull_repo; then
|
||||
ok "up to date on $(repo_branch)"
|
||||
SUMMARY+=("Repository: pulled, on $(repo_branch)")
|
||||
else
|
||||
# --ff-only, so this means the branch has diverged rather than that the
|
||||
# network failed. Saying which matters.
|
||||
warn "could not fast-forward — the local branch has diverged from the remote"
|
||||
ERRORS+=("Repository: pull refused, branch diverged")
|
||||
SUMMARY+=("Repository: present, pull refused (diverged)")
|
||||
fi
|
||||
else
|
||||
SUMMARY+=("Repository: present at ${PLATFORM_DIR}")
|
||||
fi
|
||||
|
||||
else
|
||||
echo " nothing there yet"
|
||||
echo ""
|
||||
info "Clone from ${OFFICER_REPO}?"
|
||||
echo " Cloned as ${USERNAME}, not as root — a repository owned by root is"
|
||||
echo " one you cannot pull, commit in, or install into."
|
||||
|
||||
CLONE_URL="$OFFICER_REPO"
|
||||
|
||||
if confirm "Clone it now?"; then
|
||||
if clone_repo "$CLONE_URL"; then
|
||||
ok "cloned to ${PLATFORM_DIR} on $(repo_branch)"
|
||||
SUMMARY+=("Repository: cloned from ${CLONE_URL}")
|
||||
else
|
||||
# GIT_TERMINAL_PROMPT=0 in clone_repo means this is a real failure rather
|
||||
# than a prompt nobody answered.
|
||||
fail "could not clone ${CLONE_URL} — nothing below can run without it."
|
||||
fi
|
||||
else
|
||||
fail "Nothing below can run without the repository."
|
||||
fi
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 4. Dependencies
|
||||
# =============================================================================
|
||||
|
||||
report_section "Dependencies"
|
||||
step "Dependencies"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Dependencies — bun install, as ${USERNAME}"
|
||||
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
|
||||
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
|
||||
echo ""
|
||||
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
|
||||
echo " so a package.json that disagrees with it fails rather than quietly"
|
||||
echo " picking newer versions. That friction is deliberate."
|
||||
echo ""
|
||||
echo " node-pty has no Linux prebuild, so this compiles it from source"
|
||||
echo " every time — which is what build-essential and python3 are for."
|
||||
|
||||
if deps_installed && node_pty_built; then
|
||||
ok "already installed, and node-pty is built"
|
||||
SUMMARY+=("Dependencies: already installed")
|
||||
elif confirm "Install them?"; then
|
||||
if install_deps; then
|
||||
if node_pty_built; then
|
||||
ok "installed, node-pty built"
|
||||
SUMMARY+=("Dependencies: installed")
|
||||
else
|
||||
# The install can succeed while the native module does not get built —
|
||||
# bun skips a dependency's lifecycle scripts unless it trusts the
|
||||
# package. Worth naming, because the symptom is a terminal that never
|
||||
# comes up rather than an install error.
|
||||
warn "installed, but node-pty has no built module at node_modules/node-pty/build/Release/"
|
||||
echo " The terminal sidecar cannot start without it. Try:"
|
||||
echo " cd $(platform_dir) && bun install --force"
|
||||
ERRORS+=("Dependencies: node-pty not built")
|
||||
SUMMARY+=("Dependencies: installed, node-pty NOT built")
|
||||
fi
|
||||
else
|
||||
warn "bun install failed"
|
||||
echo " If it complained about the lockfile, package.json and bun.lock"
|
||||
echo " disagree — that is the frozen lockfile doing its job, and it"
|
||||
echo " wants a human to look at the diff."
|
||||
ERRORS+=("Dependencies: bun install failed")
|
||||
SUMMARY+=("Dependencies: FAILED")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request"
|
||||
SUMMARY+=("Dependencies: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 5. Database
|
||||
# =============================================================================
|
||||
#
|
||||
# POSTGRES_URL is set here and written by the environment section below.
|
||||
|
||||
report_section "Database"
|
||||
step "Database"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Database — Postgres, the only one Officer has"
|
||||
echo " It holds the account, passkeys, settings, dashboards, email"
|
||||
echo " accounts and the job queue. Nothing else in the platform is a"
|
||||
echo " database."
|
||||
echo ""
|
||||
# One network for everything Officer provisions. Created before the compose
|
||||
# file references it, since it is declared external there.
|
||||
if ensure_docker_network; then
|
||||
ok "docker network '${OFFICER_NETWORK}' created"
|
||||
SUMMARY+=("Docker network: ${OFFICER_NETWORK} created")
|
||||
elif docker_network_exists; then
|
||||
echo " network: ${OFFICER_NETWORK} (already there)"
|
||||
fi
|
||||
|
||||
# The client goes on the HOST, before any of the container work, because it is the half
|
||||
# that is not in the container. A member has their own Postgres role and no access to the
|
||||
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
|
||||
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
|
||||
|
||||
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
||||
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
||||
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
||||
|
||||
POSTGRES_URL=""
|
||||
|
||||
# An existing compose file means this ran before. Reuse its password rather
|
||||
# than minting a new one, which would leave the container and the URL
|
||||
# disagreeing about the credential.
|
||||
if pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"; then
|
||||
POSTGRES_URL="$(pg_url "$PG_EXISTING_PASSWORD")"
|
||||
echo ""
|
||||
echo " already provisioned here — reusing the password from $(pg_env_file)"
|
||||
pg_container_running || {
|
||||
info " starting it"
|
||||
pg_compose_up >/dev/null 2>&1 || true
|
||||
}
|
||||
if pg_wait_ready; then
|
||||
ok "postgres answering on 127.0.0.1:${PG_PORT}"
|
||||
SUMMARY+=("Database: existing Postgres at ${PG_CONTAINER}")
|
||||
else
|
||||
warn "the container is not answering — check: docker logs ${PG_CONTAINER}"
|
||||
ERRORS+=("Database: provisioned but not answering")
|
||||
fi
|
||||
|
||||
else
|
||||
echo ""
|
||||
info "Which Postgres should Officer use?"
|
||||
echo ""
|
||||
echo " [1] provision one here"
|
||||
echo " ${PG_IMAGE} in $(pg_service_dir), bound to 127.0.0.1 only."
|
||||
echo " Docker publishes ports by writing iptables rules beneath ufw,"
|
||||
echo " so a database published to every interface is reachable from"
|
||||
echo " the internet whatever the firewall says. Loopback is all the"
|
||||
echo " platform needs — it runs on this machine."
|
||||
echo ""
|
||||
echo " [2] use one you already run"
|
||||
echo " Give the connection URL. Nothing is provisioned."
|
||||
echo ""
|
||||
|
||||
DB_PICK=""
|
||||
while [[ -z "$DB_PICK" ]]; do
|
||||
if ! read -rp " Which one? (1/2) [1]: " DB_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${DB_CHOICE:-1}" in
|
||||
1)
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker is not installed, so there is nothing to provision into."
|
||||
continue
|
||||
fi
|
||||
if pg_port_in_use; then
|
||||
warn "something is already listening on ${PG_PORT} — provisioning here would fail to bind"
|
||||
echo " If that is a Postgres you already run, pick 2 and give its URL."
|
||||
continue
|
||||
fi
|
||||
DB_PICK=provision
|
||||
;;
|
||||
2) DB_PICK=existing ;;
|
||||
*) warn "Pick 1 or 2." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$DB_PICK" == provision ]]; then
|
||||
PG_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
|
||||
write_pg_compose "$PG_PASSWORD"
|
||||
ok "compose written to $(pg_compose_file)"
|
||||
if pg_compose_up && pg_wait_ready; then
|
||||
POSTGRES_URL="$(pg_url "$PG_PASSWORD")"
|
||||
ok "postgres answering on 127.0.0.1:${PG_PORT}, database '${PG_DATABASE}'"
|
||||
SUMMARY+=("Database: provisioned at $(pg_service_dir)")
|
||||
else
|
||||
warn "the container did not come up — check: docker logs ${PG_CONTAINER}"
|
||||
ERRORS+=("Database: container did not start")
|
||||
SUMMARY+=("Database: provisioning FAILED")
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
ask_required POSTGRES_URL "Connection URL" "postgresql://user:password@host:5432/officer"
|
||||
if pg_url_works "$POSTGRES_URL"; then
|
||||
ok "reachable"
|
||||
SUMMARY+=("Database: existing, ${POSTGRES_URL%%:*}://…")
|
||||
else
|
||||
# Not fatal. The URL may be right and the database not started yet, and
|
||||
# refusing to continue over that would be worse than saying so.
|
||||
warn "could not connect with that URL"
|
||||
echo " Kept anyway — check it before running the schema step."
|
||||
ERRORS+=("Database: the given URL did not answer")
|
||||
SUMMARY+=("Database: existing URL kept, did not answer")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 6. Environment
|
||||
# =============================================================================
|
||||
|
||||
report_section "Environment"
|
||||
step "Environment"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Environment — $(env_file)"
|
||||
|
||||
# Read back before anything is asked; existing values become the defaults.
|
||||
ENV_PORT="$(env_get PORT)"
|
||||
ENV_PUBLIC_URL="$(env_get PUBLIC_URL)"
|
||||
|
||||
if env_exists; then
|
||||
echo " exists — its values are the defaults below"
|
||||
else
|
||||
echo " does not exist yet"
|
||||
fi
|
||||
|
||||
# ── what is asked ──
|
||||
echo ""
|
||||
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
|
||||
|
||||
echo ""
|
||||
echo " PUBLIC_URL is where Officer is reached from a browser. It is the one"
|
||||
echo " thing this machine cannot work out for itself, and three things need"
|
||||
echo " it: the OpenGraph tags baked into the page by 'bun gen:index', the"
|
||||
echo " host the task API hands to scripts, and the CalDAV profile an iPhone"
|
||||
echo " installs — that last one requires https."
|
||||
echo ""
|
||||
echo " Defaulting to this machine's tailnet address, not localhost: the"
|
||||
echo " tailnet is where Officer is actually reached from, and localhost"
|
||||
echo " works from here and nowhere else."
|
||||
ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-$(default_public_url "$ENV_PORT")}"
|
||||
|
||||
echo ""
|
||||
echo " to write:"
|
||||
echo " PORT=${ENV_PORT}"
|
||||
echo " PUBLIC_URL=${ENV_PUBLIC_URL}"
|
||||
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
|
||||
echo ""
|
||||
echo " the install root is not written here — the platform derives it as the"
|
||||
echo " parent of the repo, so data/, capabilities/ and dockers/ follow from"
|
||||
echo " ${OFFICER_ROOT} without anything having to agree with anything."
|
||||
echo ""
|
||||
|
||||
if confirm "Write it?"; then
|
||||
write_env
|
||||
ok "written, 0600, owned by ${USERNAME}"
|
||||
report_changed "wrote $(env_file) (0600, owner ${USERNAME}) — PORT, PUBLIC_URL, POSTGRES_URL. No secrets: every key lives in the secret store."
|
||||
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
|
||||
SUMMARY+=("Environment: $(env_file)")
|
||||
else
|
||||
warn "skipped by request"
|
||||
SUMMARY+=("Environment: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 7. Secrets
|
||||
# =============================================================================
|
||||
#
|
||||
# The store creates keys on demand, so this section is not strictly required —
|
||||
# the first `sign()` would mint the jwt key by itself. It runs anyway for two
|
||||
# reasons: the file should exist with the right owner and mode before anything
|
||||
# races to create it, and an install that finishes without ever saying the words
|
||||
# "back this up" is one where nobody learns the file matters until it is gone.
|
||||
|
||||
report_section "Secrets"
|
||||
step "Secrets"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Secret store — $(secret_store_path)"
|
||||
echo " Every encryption and signing key the platform holds, one SQLite file,"
|
||||
echo " one key per purpose. Nothing goes in .env."
|
||||
echo ""
|
||||
echo " bootstrapped now:"
|
||||
echo " jwt signs every session token"
|
||||
echo " headscale encrypts the Headscale admin API key in Postgres"
|
||||
echo ""
|
||||
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
|
||||
echo " service-connections — is created when its plugin is installed. A"
|
||||
echo " plugin cannot read another plugin's key."
|
||||
echo ""
|
||||
|
||||
if confirm "Create it?"; then
|
||||
if bootstrap_secret_store; then
|
||||
ok "created, 0600, owned by ${USERNAME}"
|
||||
report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt, headscale. Generated locally, never transmitted."
|
||||
echo ""
|
||||
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump."
|
||||
echo " Losing it signs everyone out and makes every encrypted column in"
|
||||
echo " Postgres unreadable. For the wallet seed that is unrecoverable:"
|
||||
echo " the passphrase opens the inner envelope, this is the outer one."
|
||||
echo ""
|
||||
echo " Keeping it beside a dump defeats it — the dump is the ciphertext"
|
||||
echo " and this is the key. Separate backups, or it is one theft."
|
||||
SUMMARY+=("Secrets: $(secret_store_path)")
|
||||
else
|
||||
warn "could not create the store — the platform will create it on first use"
|
||||
SUMMARY+=("Secrets: NOT created; the platform will do it on first use")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request — the platform will create it on first use"
|
||||
SUMMARY+=("Secrets: SKIPPED; the platform will create it on first use")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 8. Schema
|
||||
# =============================================================================
|
||||
|
||||
report_section "Schema"
|
||||
step "Schema"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
# Counted from the aggregator rather than hardcoded, so the number is the truth
|
||||
# even when a plugin line is uncommented. It was written as ${SCHEMA_TABLES:-?}
|
||||
# and never assigned, so the section said "? tables" — a placeholder that looked
|
||||
# like the count could not be determined rather than like nobody had set it.
|
||||
SCHEMA_TABLES="$(schema_table_count)"
|
||||
info "Database schema"
|
||||
echo " ${SCHEMA_TABLES:-?} tables, applied with 'bun db:push' — drizzle-kit"
|
||||
echo " diffs the schema code against Postgres and alters it directly. There"
|
||||
echo " are no migration files and no migration table; the code is the source"
|
||||
echo " of truth."
|
||||
echo ""
|
||||
echo " Only the CORE tables. Every plugin's tables are commented out in"
|
||||
echo " src/databases/officer_db/src/schema.ts and get created when the"
|
||||
echo " plugin is installed."
|
||||
echo ""
|
||||
|
||||
if confirm "Push it?"; then
|
||||
if OUT="$(push_schema)"; then
|
||||
ok "schema applied"
|
||||
report_changed "applied ${SCHEMA_TABLES} tables to Postgres with 'bun db:push' (drizzle-kit; no migration files)"
|
||||
SUMMARY+=("Schema: ${SCHEMA_TABLES:-?} tables pushed")
|
||||
else
|
||||
warn "db:push failed"
|
||||
echo "$OUT" | tail -12 | sed 's/^/ /'
|
||||
SUMMARY+=("Schema: FAILED — see the output above")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request — the platform will not start without it"
|
||||
SUMMARY+=("Schema: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 9. Build
|
||||
# =============================================================================
|
||||
|
||||
report_section "Build"
|
||||
step "Build"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "index.gen.html"
|
||||
echo " 'bun gen:index' substitutes your public URL into index.html and"
|
||||
echo " writes index.gen.html, which is the file the server imports. It is"
|
||||
echo " gitignored, so a fresh clone never has one and the server has no page"
|
||||
echo " to serve until this runs."
|
||||
echo ""
|
||||
echo " URL: ${ENV_PUBLIC_URL:-<not set — run the Environment section first>}"
|
||||
echo ""
|
||||
echo " To change it later: bun gen:index https://your.new.url"
|
||||
echo ""
|
||||
|
||||
if [[ -z "$ENV_PUBLIC_URL" ]]; then
|
||||
warn "PUBLIC_URL is not in $(env_file) — run the Environment section, then this one"
|
||||
SUMMARY+=("Build: SKIPPED — no PUBLIC_URL")
|
||||
elif confirm "Generate it?"; then
|
||||
if OUT="$(gen_index)"; then
|
||||
ok "$(gen_index_output)"
|
||||
report_changed "generated $(gen_index_output) from index.html, substituting PUBLIC_URL=${ENV_PUBLIC_URL}"
|
||||
SUMMARY+=("Build: index.gen.html for ${ENV_PUBLIC_URL}")
|
||||
else
|
||||
warn "gen:index failed"
|
||||
echo "$OUT" | tail -8 | sed 's/^/ /'
|
||||
SUMMARY+=("Build: FAILED — see the output above")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request — the server has no page to serve without it"
|
||||
SUMMARY+=("Build: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 10. Services
|
||||
# =============================================================================
|
||||
|
||||
report_section "Services"
|
||||
step "Services"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "pm2 — $(ecosystem_file)"
|
||||
echo " The ecosystem file is GENERATED, not checked in. It describes this"
|
||||
echo " install and nothing else, so nothing in git can drift from it."
|
||||
echo ""
|
||||
echo " six processes:"
|
||||
for entry in "${CORE_PROCESSES[@]}"; do
|
||||
IFS='|' read -r _name _script _args <<<"$entry"
|
||||
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
|
||||
done
|
||||
echo ""
|
||||
echo " Nothing else. Every plugin adds its own entry when it is installed."
|
||||
echo ""
|
||||
|
||||
if confirm "Write it and start them?"; then
|
||||
write_ecosystem
|
||||
ok "written — $(ecosystem_file)"
|
||||
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
|
||||
# Starting against a database that is not answering is not fatal — the server
|
||||
# waits and the agent retries forever — but it makes the Verify section below
|
||||
# report a failure that is really just a race, and that is the kind of noise
|
||||
# that teaches people to ignore a red line.
|
||||
if pg_container_running && ! pg_wait_ready 30; then
|
||||
warn "Postgres is not answering — starting anyway, but Verify may report failures"
|
||||
fi
|
||||
|
||||
if OUT="$(pm2_start)"; then
|
||||
ok "processes started"
|
||||
report_started "pm2 startOrRestart: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
|
||||
pm2_save >/dev/null 2>&1 && ok "process list saved (survives a pm2 restart)"
|
||||
|
||||
echo ""
|
||||
if confirm "Start them on boot too?"; then
|
||||
if pm2_enable_startup; then
|
||||
ok "pm2 will resurrect them at boot"
|
||||
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
|
||||
SUMMARY+=("Services: 6 processes started, enabled at boot")
|
||||
else
|
||||
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
||||
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
|
||||
fi
|
||||
else
|
||||
SUMMARY+=("Services: 6 processes started; not enabled at boot")
|
||||
fi
|
||||
else
|
||||
warn "pm2 did not start cleanly"
|
||||
echo "$OUT" | tail -12 | sed 's/^/ /'
|
||||
SUMMARY+=("Services: FAILED to start — see the output above")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request"
|
||||
SUMMARY+=("Services: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 11. Verify
|
||||
# =============================================================================
|
||||
|
||||
report_section "Verify"
|
||||
step "Verify"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Are the processes actually up?"
|
||||
echo ""
|
||||
|
||||
VERIFY_BAD=0
|
||||
while IFS='|' read -r vname vstatus vrestarts; do
|
||||
[[ -z "$vname" ]] && continue
|
||||
if [[ "$vstatus" == "online" ]]; then
|
||||
if (( vrestarts > 3 )); then
|
||||
warn "$(printf '%-24s online, but restarted %s times — check: pm2 logs %s' "$vname" "$vrestarts" "$vname")"
|
||||
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||
else
|
||||
ok "$(printf '%-24s online' "$vname")"
|
||||
fi
|
||||
else
|
||||
warn "$(printf '%-24s %s — check: pm2 logs %s' "$vname" "$vstatus" "$vname")"
|
||||
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||
fi
|
||||
done < <(pm2_status_lines)
|
||||
|
||||
echo ""
|
||||
# A process can be `online` and still be failing to serve — a restart loop takes
|
||||
# a few seconds to show up in the counter, and the app can be up with a broken
|
||||
# database. So the port is asked directly.
|
||||
if curl -fsS --max-time 5 "http://127.0.0.1:${ENV_PORT:-9000}/api" >/dev/null 2>&1; then
|
||||
ok "the API answers on 127.0.0.1:${ENV_PORT:-9000}"
|
||||
SUMMARY+=("Verify: API answering on port ${ENV_PORT:-9000}")
|
||||
else
|
||||
warn "nothing answered on 127.0.0.1:${ENV_PORT:-9000}/api"
|
||||
echo " pm2 logs officer is where the reason will be."
|
||||
VERIFY_BAD=$((VERIFY_BAD + 1))
|
||||
SUMMARY+=("Verify: the API did NOT answer on port ${ENV_PORT:-9000}")
|
||||
fi
|
||||
|
||||
if (( VERIFY_BAD == 0 )); then
|
||||
echo ""
|
||||
ok "Officer is running. Open ${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT:-9000}} and the"
|
||||
echo " first-run screen will create the owner account."
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 12. Proxy
|
||||
# =============================================================================
|
||||
#
|
||||
# Optional, and last, because it is the only step that needs Officer to be already
|
||||
# running: NPM proxies to it, and the gate below checks the bind address rather than
|
||||
# taking a curl to loopback as proof.
|
||||
#
|
||||
# ── Why this section ignores --unattended ──
|
||||
#
|
||||
# Every other question in this script has a defensible default. None of these do — a
|
||||
# domain name, a DNS provider and that provider's API credentials cannot be guessed —
|
||||
# and the step is opt-in besides. So its prompts read stdin directly instead of going
|
||||
# through confirm()/ask_required(), which honour ASSUME_YES.
|
||||
#
|
||||
# The valve is a TTY check, not the flag: with no terminal there is nobody to ask, so
|
||||
# it skips and prints the manual instructions. That keeps a cron-driven install working
|
||||
# without letting --unattended silently agree to publishing a public hostname.
|
||||
|
||||
report_section "Proxy"
|
||||
step "Proxy"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Reverse proxy — a real hostname and an HTTPS certificate"
|
||||
echo " Optional. Skip it if you already run a proxy elsewhere, or if you"
|
||||
echo " reach this instance over the tailnet and are happy with that."
|
||||
echo ""
|
||||
|
||||
PROXY_PORT="${ENV_PORT:-9000}"
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
warn "no terminal — skipping the proxy, which cannot be answered unattended"
|
||||
proxy_skip_instructions "$PROXY_PORT"
|
||||
SUMMARY+=("Proxy: skipped (no terminal)")
|
||||
elif ! proxy_require_listening "$PROXY_PORT"; then
|
||||
warn "skipping the proxy — Officer is not reachable the way NPM would reach it"
|
||||
echo " Fix the bind address, then: officer-setup.sh --only Proxy"
|
||||
SUMMARY+=("Proxy: skipped (Officer not listening on 0.0.0.0)")
|
||||
elif ! proxy_confirm "Set up Nginx Proxy Manager now?"; then
|
||||
proxy_skip_instructions "$PROXY_PORT"
|
||||
SUMMARY+=("Proxy: skipped by request")
|
||||
else
|
||||
# One failure path for all of it: every function warns and returns non-zero rather
|
||||
# than exiting, so a proxy that does not come up leaves a finished Officer install
|
||||
# behind rather than a failed one. It is the last section for that reason.
|
||||
PROXY_DOMAIN="$(proxy_ask 'Domain for this instance (e.g. officer.example.com)')"
|
||||
if [[ -z "$PROXY_DOMAIN" ]]; then
|
||||
warn "no domain given — skipping"
|
||||
SUMMARY+=("Proxy: skipped (no domain)")
|
||||
elif
|
||||
proxy_detect_target &&
|
||||
proxy_ensure_network &&
|
||||
proxy_order_docker_after_tailscaled &&
|
||||
proxy_write_compose &&
|
||||
proxy_start &&
|
||||
proxy_claim_admin &&
|
||||
proxy_get_token &&
|
||||
{ [[ "$CHALLENGE" != "dns" ]] || proxy_prompt_dns_credentials; } &&
|
||||
proxy_wait_for_dns "$PROXY_DOMAIN" "$TARGET_IP" &&
|
||||
proxy_allow_bridge_to_host "$PROXY_PORT" &&
|
||||
proxy_create_host "$PROXY_DOMAIN" "$PROXY_PORT" &&
|
||||
proxy_issue_certificate "$PROXY_DOMAIN" &&
|
||||
proxy_attach_certificate
|
||||
then
|
||||
proxy_verify "$PROXY_DOMAIN"
|
||||
echo ""
|
||||
ok "Officer is published at https://${PROXY_DOMAIN}"
|
||||
echo " NPM admin: http://127.0.0.1:81$([[ "$CHALLENGE" == "dns" ]] && echo " or http://${TARGET_IP}:81")"
|
||||
SUMMARY+=("Proxy: https://${PROXY_DOMAIN}")
|
||||
else
|
||||
warn "the proxy did not finish — Officer itself is unaffected and still running"
|
||||
echo " Retry just this part with: officer-setup.sh --only Proxy"
|
||||
ERRORS+=("Proxy: did not finish")
|
||||
SUMMARY+=("Proxy: FAILED — retry with --only Proxy")
|
||||
fi
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# ── Who you are when this exits ──
|
||||
#
|
||||
# Root, and that surprises people — reasonably, because everything this script just
|
||||
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
|
||||
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
|
||||
# the installer's privilege, never the platform's.
|
||||
#
|
||||
# Saying so matters for two things that are invisible until they bite:
|
||||
#
|
||||
# - group membership is fixed at login. ${USERNAME} was added to `docker` during
|
||||
# machine setup, and a session that started before that does not have it — so
|
||||
# `docker ps` fails for a reason that has nothing to do with docker.
|
||||
# - the shell config was written into THEIR home. Staying as root means none of it
|
||||
# is loaded, and the machine looks unconfigured.
|
||||
if [[ "$EUID" -eq 0 ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD} One more thing — you are still root.${NC}"
|
||||
echo ""
|
||||
echo " Officer runs as ${USERNAME}, and everything it installed is theirs."
|
||||
echo " Nothing here needs root any more. To carry on as them:"
|
||||
echo ""
|
||||
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
|
||||
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
|
||||
echo ""
|
||||
echo " Either gives a new session, which is what makes their docker group"
|
||||
echo " membership and their shell configuration take effect. Staying as root"
|
||||
echo " means neither does, and the machine will look half-configured."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
report_mark_complete
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — shared foundation
|
||||
# =============================================================================
|
||||
#
|
||||
# Sourced by officer-setup.sh before anything runs. DEFINITIONS ONLY, the same
|
||||
# rule machine-setup/lib holds to: nothing here installs, writes or restarts.
|
||||
#
|
||||
# ── Why this is a separate script from machine-setup ──
|
||||
#
|
||||
# They answer different questions. machine-setup asks what a MACHINE should be —
|
||||
# users, ssh, firewall, runtimes — and is worth running on a box that will never
|
||||
# see Officer. This one puts Officer on a machine that is already ready, and
|
||||
# assumes nothing about how it got that way.
|
||||
#
|
||||
# The split also means the failure modes stay apart: a broken firewall rule and a
|
||||
# failed database migration are not the same kind of problem and should not be
|
||||
# in the same run.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_BASE_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_BASE_LOADED=1
|
||||
|
||||
SUMMARY=()
|
||||
ERRORS=()
|
||||
CURRENT_STEP=""
|
||||
SKIP_STEP=false
|
||||
|
||||
USERNAME="${SETUP_USERNAME:-}"
|
||||
USER_HOME=""
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-}"
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}::${NC} $*"; }
|
||||
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
||||
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
||||
fail() {
|
||||
echo -e " ${RED}FAIL${NC}: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ONLY_STEP="${ONLY_STEP:-}"
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
else
|
||||
SKIP_STEP=true
|
||||
fi
|
||||
return
|
||||
fi
|
||||
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
||||
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
}
|
||||
|
||||
skip() { [[ "$SKIP_STEP" == true ]]; }
|
||||
|
||||
step_ok() {
|
||||
[[ -n "$ONLY_STEP" ]] && return 0
|
||||
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
||||
}
|
||||
|
||||
page() {
|
||||
if [[ -t 1 ]] && command -v more &>/dev/null; then more; else cat; fi
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local message="${1:-Proceed?}" default="${2:-y}" help_fn="${3:-}" answer prompt
|
||||
|
||||
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
|
||||
|
||||
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
|
||||
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
|
||||
|
||||
while true; do
|
||||
if ! read -rp " ${message} ${prompt}: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
||||
fi
|
||||
[[ -z "$answer" ]] && answer="$default"
|
||||
case "$answer" in
|
||||
y | Y | yes | Yes) return 0 ;;
|
||||
n | N | no | No) return 1 ;;
|
||||
"?")
|
||||
if [[ -n "$help_fn" ]]; then
|
||||
echo ""
|
||||
"$help_fn" | page
|
||||
echo ""
|
||||
else
|
||||
warn "Answer y or n."
|
||||
fi
|
||||
;;
|
||||
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
ask_required() {
|
||||
local __var="$1" message="$2" default="$3" answer=""
|
||||
# Unattended takes the default where there IS one. Where there is not — the owning
|
||||
# account on a machine that machine-setup never ran on — it still asks, because
|
||||
# there is nothing to fall back to and a guess would install as the wrong user.
|
||||
if [[ "${UNATTENDED:-}" == "1" && -n "$default" ]]; then
|
||||
printf ' %s [%s] — unattended, taking the default\n' "$message" "$default"
|
||||
printf -v "$__var" '%s' "$default"
|
||||
return 0
|
||||
fi
|
||||
while [[ -z "$answer" ]]; do
|
||||
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
[[ -z "$answer" ]] && warn "This one cannot be left blank."
|
||||
done
|
||||
printf -v "$__var" '%s' "$answer"
|
||||
}
|
||||
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# Run something as the account that owns the install. Officer's files, its
|
||||
# node_modules and its pm2 process list all belong to that account, not to root —
|
||||
# a repository cloned as root is one the owner cannot pull.
|
||||
as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); }
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — schema and build
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# Both run AS the owner, from the repo. Neither is idempotent in the sense of
|
||||
# "does nothing the second time" — both are safe to repeat, which is not the same
|
||||
# thing and is the property that matters for a script people re-run.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_BUILD_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_BUILD_LOADED=1
|
||||
|
||||
# `bun db:push` — drizzle-kit diffs the schema code against the live database.
|
||||
#
|
||||
# No migrations here and no __drizzle_migrations table: the schema code IS the
|
||||
# source of truth (src/databases/CLAUDE.md). On the empty database section 5 just
|
||||
# created there is nothing to drop, so the prompt drizzle-kit shows for a
|
||||
# destructive change cannot appear.
|
||||
#
|
||||
# It can still appear on a RE-RUN against a database with data, and a prompt
|
||||
# nobody sees would hang the script forever — so stdin is closed rather than left
|
||||
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
|
||||
# want at 3am.
|
||||
push_schema() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
|
||||
}
|
||||
|
||||
# What tables the schema will create, read from the aggregator rather than
|
||||
# guessed. This is what makes the section able to say what it is about to do.
|
||||
schema_table_count() {
|
||||
local dir
|
||||
dir="$(platform_dir)/src/databases/officer_db/src"
|
||||
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
|
||||
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
|
||||
done | awk '{s+=$1} END {print s+0}'
|
||||
}
|
||||
|
||||
# `bun gen:index` — substitutes PUBLIC_URL into index.html and writes
|
||||
# index.gen.html, which is what the server actually imports.
|
||||
#
|
||||
# Not optional and not cosmetic: without it the server has no page to serve. It
|
||||
# is gitignored, so a fresh clone never has one.
|
||||
gen_index() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
|
||||
}
|
||||
|
||||
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the environment file
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── No secrets are written here ──
|
||||
#
|
||||
# Every encryption and signing key lives in the secret store — a 0600 SQLite file
|
||||
# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first
|
||||
# use. See docs/secret-store.md and the Secrets section of officer-setup.sh.
|
||||
#
|
||||
# So this file holds no credential except POSTGRES_URL, which is a connection
|
||||
# string to a database bound to loopback.
|
||||
#
|
||||
# ── Derived, not asked ──
|
||||
#
|
||||
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone too, and this time nothing
|
||||
# replaces them. The platform derives the install root as the parent of its own
|
||||
# working directory, so data/, capabilities/ and dockers/ follow from the layout
|
||||
# on disk, and the owner's home comes from the OS. They were three environment
|
||||
# variables that had to agree with each other and with the directory tree.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_ENV_LOADED=1
|
||||
|
||||
env_file() { echo "$(platform_dir)/.env"; }
|
||||
|
||||
env_exists() { [[ -f "$(env_file)" ]]; }
|
||||
|
||||
# One value out of an existing .env, without sourcing it — the file holds
|
||||
# secrets and arbitrary shell would run as root.
|
||||
env_get() {
|
||||
[[ -r "$(env_file)" ]] || return 0
|
||||
awk -F= -v k="$1" '
|
||||
$1 == k {
|
||||
v = substr($0, index($0, "=") + 1)
|
||||
gsub(/^"|"$/, "", v)
|
||||
print v
|
||||
exit
|
||||
}' "$(env_file)"
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local dest
|
||||
dest="$(env_file)"
|
||||
|
||||
[[ -f "$dest" ]] && cp -a "$dest" "${dest}.before-officer-setup"
|
||||
|
||||
# Restrictive from the moment it exists rather than chmod'd afterwards, so the
|
||||
# secrets are never briefly world-readable. Restored straight after: umask is
|
||||
# not scoped to a function, and leaving it at 077 would quietly make every file
|
||||
# a later section creates owner-only.
|
||||
local prior_umask
|
||||
prior_umask="$(umask)"
|
||||
umask 077
|
||||
cat >"$dest" <<ENVF
|
||||
# Written by officer-setup.
|
||||
#
|
||||
# Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it
|
||||
# holds the token-signing secret and the database credential.
|
||||
|
||||
PORT="${ENV_PORT}"
|
||||
|
||||
# Where Officer is reached from a browser. Not derivable — see the section.
|
||||
PUBLIC_URL="${ENV_PUBLIC_URL}"
|
||||
|
||||
POSTGRES_URL="${POSTGRES_URL}"
|
||||
|
||||
ENVF
|
||||
|
||||
umask "$prior_umask"
|
||||
|
||||
chown "${USERNAME}:$(user_group)" "$dest"
|
||||
chmod 600 "$dest"
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# A sensible default for PUBLIC_URL
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# localhost is the wrong default on a machine with a tailnet, and quietly so:
|
||||
# it works from the machine itself and from nowhere else, so the mistake shows up
|
||||
# on the first phone, not during setup.
|
||||
#
|
||||
# The tailnet is where Officer is actually reached — it is the perimeter the
|
||||
# whole security model rests on — so its address is the honest default.
|
||||
#
|
||||
# The SHORT MagicDNS name — `officer-dev`, not `officer-dev.ts.example.dev` and
|
||||
# not the raw 100.x address. All three resolve inside the tailnet; the short one
|
||||
# is the one anybody actually types, and PUBLIC_URL ends up baked into the page's
|
||||
# OpenGraph tags by `bun gen:index`, so it is read by people as well as machines.
|
||||
#
|
||||
# It relies on the tailnet's search domain, which every Tailscale client sets when
|
||||
# MagicDNS is on. A device that has somehow lost it resolves the FQDN and not the
|
||||
# short name — the fix there is to type the longer one, not to default to it.
|
||||
#
|
||||
# Falls back to localhost when there is no tailnet, which is correct rather than
|
||||
# merely tolerable: a machine with no private network has no other address that
|
||||
# is any better a guess.
|
||||
tailnet_hostname() {
|
||||
local dns ip
|
||||
dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)"
|
||||
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
|
||||
dns="${dns%%.*}" # and we want the short name
|
||||
if [[ -n "$dns" ]]; then
|
||||
echo "$dns"
|
||||
return 0
|
||||
fi
|
||||
ip="$(tailscale ip -4 2>/dev/null | head -1)"
|
||||
[[ -n "$ip" ]] && echo "$ip"
|
||||
}
|
||||
|
||||
default_public_url() {
|
||||
local host
|
||||
host="$(tailnet_hostname)"
|
||||
if [[ -n "$host" ]]; then
|
||||
echo "http://${host}:${1}"
|
||||
else
|
||||
echo "http://localhost:${1}"
|
||||
fi
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — Postgres
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── Only Postgres ──
|
||||
#
|
||||
# The original offered five containers. Of those, Redis and SearXNG are not
|
||||
# referenced anywhere in the platform — no import, no environment variable, no
|
||||
# mention — and Nginx Proxy Manager is a deployment choice rather than something
|
||||
# a setup script should pick. Mailhog is a development convenience and is offered
|
||||
# separately.
|
||||
#
|
||||
# Postgres is the only one Officer cannot run without: it is the single database,
|
||||
# holding the account, passkeys, settings, dashboards, email accounts and the
|
||||
# queue.
|
||||
#
|
||||
# ── Where it goes ──
|
||||
#
|
||||
# $OFFICER_ROOT/dockers/postgres/, which is the same convention the app store
|
||||
# uses for anything it provisions: one directory per service, the compose file
|
||||
# inside it, and RELATIVE bind mounts so the data sits beside the compose file
|
||||
# where both a human and the platform can find it.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_POSTGRES_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_POSTGRES_LOADED=1
|
||||
|
||||
# One network for everything Officer provisions, so containers can reach each
|
||||
# other by name. Postgres needs nothing from it today — the platform is a host
|
||||
# process and reaches it over loopback — but a reverse proxy in front of the web
|
||||
# UI, or any app-store service that talks to another, does. Creating it now means
|
||||
# the later ones do not have to migrate onto it.
|
||||
OFFICER_NETWORK="${OFFICER_NETWORK:-officerdev}"
|
||||
|
||||
PG_IMAGE="${PG_IMAGE:-postgres:18-alpine}"
|
||||
PG_DATABASE="${PG_DATABASE:-officer}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
||||
PG_PORT="${PG_PORT:-5432}"
|
||||
|
||||
# ── The CLIENT, on the host, matching the server in the container ──
|
||||
#
|
||||
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
|
||||
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
|
||||
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
|
||||
# owner's Docker socket.
|
||||
#
|
||||
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
|
||||
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
|
||||
# version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the
|
||||
# archive package is not merely old, it is unusable for dumps. That is also why this lives
|
||||
# beside the server definition rather than in machine-setup's package list — one constant,
|
||||
# one place to bump.
|
||||
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
|
||||
|
||||
pg_client_installed() {
|
||||
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
|
||||
}
|
||||
|
||||
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
|
||||
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||
# just a more annoying one to operate.
|
||||
install_pg_client() {
|
||||
local major codename
|
||||
major="$(pg_client_major)"
|
||||
[[ -n "$major" ]] || {
|
||||
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||
return 1
|
||||
}
|
||||
|
||||
if pg_client_installed; then
|
||||
ok "psql ${major} already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||
return 1
|
||||
}
|
||||
|
||||
install -d -m 0755 /usr/share/postgresql-common/pgdg
|
||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
|
||||
warn "could not fetch the PostgreSQL signing key"
|
||||
return 1
|
||||
}
|
||||
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
|
||||
>/etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
|
||||
warn "postgresql-client-${major} did not install"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
|
||||
# matters is whether the binary is there AND is the version we asked for, because apt can
|
||||
# succeed while holding an older client back.
|
||||
pg_client_installed || {
|
||||
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
|
||||
return 1
|
||||
}
|
||||
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
|
||||
}
|
||||
|
||||
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
||||
ensure_docker_network() {
|
||||
docker_network_exists && return 1
|
||||
docker network create "$OFFICER_NETWORK" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
pg_service_dir() { echo "${OFFICER_ROOT}/dockers/postgres"; }
|
||||
pg_compose_file() { echo "$(pg_service_dir)/docker-compose.yaml"; }
|
||||
pg_env_file() { echo "$(pg_service_dir)/.env"; }
|
||||
|
||||
pg_compose_exists() { [[ -f "$(pg_compose_file)" ]]; }
|
||||
pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q .; }
|
||||
|
||||
# Is something already answering on the port? A Postgres the user runs their own
|
||||
# way is a perfectly good answer, and finding out by failing to bind is not.
|
||||
pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; }
|
||||
|
||||
# Bound to loopback, deliberately, and the reason is worth keeping next to the
|
||||
# line it explains.
|
||||
#
|
||||
# Publishing a port makes Docker write its own DNAT and ACCEPT rules into
|
||||
# iptables, and those are evaluated BEFORE ufw sees the packet. So `ports:
|
||||
# "5432:5432"` is reachable from the internet while `ufw status` reports
|
||||
# everything denied. Binding to 127.0.0.1 sidesteps it entirely: the DNAT rule
|
||||
# only matches traffic arriving on loopback.
|
||||
#
|
||||
# Loopback is not the whole story, though, and the password is not decoration.
|
||||
# Every account ON this machine can open 127.0.0.1:5432 — including the per-user
|
||||
# Linux accounts Officer gives its members. What stops them is that they cannot
|
||||
# authenticate. The password is the boundary between the platform and anyone
|
||||
# with a login here, which is why it is random and why both files holding it are
|
||||
# 0600.
|
||||
write_pg_compose() {
|
||||
local password="$1" dir
|
||||
dir="$(pg_service_dir)"
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir"
|
||||
|
||||
cat >"$(pg_compose_file)" <<COMPOSE
|
||||
# Written by officer-setup. Officer's database.
|
||||
#
|
||||
# The port is bound to 127.0.0.1 on purpose. Docker publishes ports by writing
|
||||
# iptables rules beneath ufw, so "5432:5432" would be reachable from the internet
|
||||
# whatever the firewall reports. The platform runs on this machine, so loopback
|
||||
# is all it needs.
|
||||
services:
|
||||
postgres:
|
||||
image: ${PG_IMAGE}
|
||||
container_name: ${PG_CONTAINER}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${PG_PORT}:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${PG_DATABASE}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- ./data:/var/lib/postgresql/data
|
||||
- ./dumps:/dumps
|
||||
networks:
|
||||
- ${OFFICER_NETWORK}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
${OFFICER_NETWORK}:
|
||||
external: true
|
||||
COMPOSE
|
||||
|
||||
# The password lives beside the compose file rather than inside it, so the
|
||||
# compose file can be read, copied or committed without carrying a credential.
|
||||
umask 077
|
||||
cat >"$(pg_env_file)" <<ENVF
|
||||
# Written by officer-setup. Read by docker compose from this directory.
|
||||
POSTGRES_PASSWORD=${password}
|
||||
ENVF
|
||||
chown "${USERNAME}:$(user_group)" "$(pg_compose_file)" "$(pg_env_file)"
|
||||
chmod 600 "$(pg_env_file)"
|
||||
return 0
|
||||
}
|
||||
|
||||
pg_password_from_env_file() {
|
||||
[[ -r "$(pg_env_file)" ]] || return 1
|
||||
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
|
||||
}
|
||||
|
||||
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
|
||||
|
||||
# Wait for it to answer, rather than assuming `up -d` means ready. Postgres
|
||||
# initialises its data directory on first start, which takes several seconds, and
|
||||
# everything after this — db:push especially — fails confusingly against a
|
||||
# database that is still starting.
|
||||
pg_wait_ready() {
|
||||
local tries="${1:-30}"
|
||||
while ((tries-- > 0)); do
|
||||
docker exec "$PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1 && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
pg_url() { echo "postgresql://postgres:${1}@127.0.0.1:${PG_PORT}/${PG_DATABASE}"; }
|
||||
|
||||
# Does this URL actually answer? Asked of any URL, provisioned or given, because
|
||||
# a database nobody can reach is the failure that makes every later section look
|
||||
# broken for its own reasons.
|
||||
pg_url_works() {
|
||||
local url="$1"
|
||||
as_owner "docker run --rm --network host ${PG_IMAGE} psql '${url}' -c 'select 1' >/dev/null 2>&1" /
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — is this machine ready
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── Inherited, not re-asked ──
|
||||
#
|
||||
# machine-setup saves the account, the Officer path and the machine role beside
|
||||
# itself. This reads the same file, so a normal run — machine-setup, then this —
|
||||
# asks nothing at all. It only prompts on a machine where machine-setup never
|
||||
# ran, which is a supported case rather than an error: somebody may have
|
||||
# provisioned the box their own way.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_PREFLIGHT_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_PREFLIGHT_LOADED=1
|
||||
|
||||
# Where machine-setup keeps what it was told. Beside this script, one directory
|
||||
# across.
|
||||
MACHINE_ANSWERS="${MACHINE_ANSWERS:-${SCRIPT_DIR}/machine-setup/.setup-answers}"
|
||||
|
||||
# Read as assignments rather than sourced: the file is read by a root run and
|
||||
# sourcing it would make it executable content.
|
||||
load_machine_answers() {
|
||||
[[ -r "$MACHINE_ANSWERS" ]] || return 1
|
||||
local key value
|
||||
while IFS='=' read -r key value; do
|
||||
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
||||
[[ -n "$value" ]] || continue
|
||||
case "$key" in
|
||||
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
|
||||
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
|
||||
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
|
||||
esac
|
||||
done <"$MACHINE_ANSWERS"
|
||||
return 0
|
||||
}
|
||||
|
||||
# What Officer needs to already be here, and what installs it.
|
||||
#
|
||||
# Checked together and reported together: finding out about a missing bun three
|
||||
# sections in, after the repository has been cloned and a database started, is a
|
||||
# worse way to learn it than being told at the start.
|
||||
REQUIRED_TOOLS=(git node bun pm2)
|
||||
OPTIONAL_TOOLS=(docker)
|
||||
|
||||
missing_tools() {
|
||||
local t
|
||||
for t in "${REQUIRED_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
||||
}
|
||||
|
||||
missing_optional_tools() {
|
||||
local t
|
||||
for t in "${OPTIONAL_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
||||
}
|
||||
|
||||
tool_why() {
|
||||
case "$1" in
|
||||
git) echo "to clone and update the platform" ;;
|
||||
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
||||
bun) echo "the platform itself and nineteen of the twenty processes" ;;
|
||||
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
||||
docker) echo "Postgres, and anything the app store provisions" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The account has to exist before anything is written to its home.
|
||||
owner_exists() { id "$USERNAME" &>/dev/null; }
|
||||
|
||||
resolve_user_home() {
|
||||
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
#!/bin/bash
|
||||
# officer-setup — Nginx Proxy Manager, the optional last step.
|
||||
#
|
||||
# Publishes the running instance on a real hostname with a Let's Encrypt certificate.
|
||||
# Entirely optional: someone with a proxy elsewhere declines and is printed the values
|
||||
# they need instead.
|
||||
#
|
||||
# PRECONDITION: Officer is running and bound to 0.0.0.0. Checked, not assumed — see
|
||||
# proxy_require_listening.
|
||||
#
|
||||
# ── Why this section ignores --unattended ──
|
||||
#
|
||||
# Every other question in this script has a defensible default. None of these do: a
|
||||
# domain name, a DNS provider and that provider's API credentials cannot be guessed,
|
||||
# and the whole step is opt-in besides. So the prompts here read stdin directly rather
|
||||
# than going through confirm()/ask_required(), which honour ASSUME_YES.
|
||||
#
|
||||
# The safety valve is a TTY check rather than the flag: with no terminal there is
|
||||
# nobody to ask, so the section skips itself and prints the manual instructions. That
|
||||
# covers a cron-driven install without making --unattended silently agree to a proxy.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_PROXY_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_PROXY_LOADED=1
|
||||
|
||||
# `${OFFICER_ROOT}/dockers`, matching src/servers/data-path.ts, which derives that
|
||||
# directory from the install root. The draft used $HOME/dockers, which is a different
|
||||
# place on every machine and not the one the app store provisions into.
|
||||
proxy_dir() { echo "${OFFICER_ROOT}/dockers/nginx-proxy-manager"; }
|
||||
|
||||
# The network machine-setup already created. It defaults to `services` there, so a
|
||||
# second name would leave two bridges on the same box with containers unable to see
|
||||
# each other by name.
|
||||
PROXY_NET="${SETUP_DOCKER_NETWORK:-services}"
|
||||
PROXY_API="http://127.0.0.1:81/api"
|
||||
|
||||
# ── prompts that always ask ──
|
||||
#
|
||||
# Deliberately not confirm()/ask_required(): see the header. Named apart so nobody
|
||||
# later "fixes" them into the shared helpers and quietly makes --unattended agree to
|
||||
# provisioning a public hostname.
|
||||
proxy_confirm() {
|
||||
local answer
|
||||
read -rp " $1 [y/N]: " answer || return 1
|
||||
[[ "$answer" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
proxy_ask() {
|
||||
local answer
|
||||
read -rp " $1: " answer || return 1
|
||||
printf '%s' "$answer"
|
||||
}
|
||||
|
||||
# ── 0. is Officer reachable the way NPM will reach it? ──
|
||||
#
|
||||
# `curl 127.0.0.1:$PORT` succeeds even when the process binds loopback ONLY, which is
|
||||
# exactly the case NPM cannot reach: it dials from inside a container, where 127.0.0.1
|
||||
# is the container itself. Passing this gate on a curl check produces a 504 later that
|
||||
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
|
||||
proxy_require_listening() {
|
||||
local port="$1" listen
|
||||
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
|
||||
[[ -n "$listen" ]] || {
|
||||
warn "nothing is listening on port ${port} — start Officer first"
|
||||
return 1
|
||||
}
|
||||
if ! grep -qE '(^|\s)(0\.0\.0\.0|\*):'"$port"'$' <<<"$listen"; then
|
||||
warn "Officer is listening on: ${listen}"
|
||||
info "NPM runs in a container, so 127.0.0.1 there is the container itself."
|
||||
info "A loopback-only listener is invisible to it and yields a 504."
|
||||
return 1
|
||||
fi
|
||||
ok "Officer is listening on 0.0.0.0:${port}"
|
||||
}
|
||||
|
||||
# ── 1. where will the hostname point? ──
|
||||
#
|
||||
# Tailnet DNS-01 is mandatory. Let's Encrypt cannot reach 100.64.0.0/10, so HTTP-01
|
||||
# always fails. The A record is not needed to ISSUE (validation is a TXT
|
||||
# record) but is needed to USE the name.
|
||||
# Public HTTP-01 works with no API keys, but the A record must already resolve here.
|
||||
proxy_detect_target() {
|
||||
local ts=""
|
||||
command -v tailscale >/dev/null 2>&1 && ts="$(tailscale ip -4 2>/dev/null | head -1 || true)"
|
||||
if [[ -n "$ts" ]]; then
|
||||
TARGET_IP="$ts"
|
||||
CHALLENGE="dns"
|
||||
ok "Tailscale detected — ${TARGET_IP}"
|
||||
info "Tailnet addresses are unreachable from Let's Encrypt, so the certificate"
|
||||
info "needs a DNS-01 challenge, which needs your DNS provider's API credentials."
|
||||
else
|
||||
TARGET_IP="$(curl -sf --max-time 10 https://api.ipify.org || true)"
|
||||
[[ -n "$TARGET_IP" ]] || {
|
||||
warn "could not determine this machine's public IP"
|
||||
return 1
|
||||
}
|
||||
CHALLENGE="http"
|
||||
ok "No Tailscale — public IP ${TARGET_IP} (HTTP-01, no API keys needed)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Read by indirect expansion — `${!hint}` where hint is "DNS_HINT_${DNS_PROVIDER}" —
|
||||
# which shellcheck cannot follow, hence the disable rather than a rewrite. Naming them
|
||||
# this way is what lets a provider with no hint simply not have one.
|
||||
# shellcheck disable=SC2034
|
||||
DNS_HINT_godaddy="Create an API key at https://developer.godaddy.com/keys (Production).
|
||||
You need both the Key and the Secret. Scope it to DNS only if offered."
|
||||
# shellcheck disable=SC2034
|
||||
DNS_HINT_cloudflare="Create a token at https://dash.cloudflare.com/profile/api-tokens
|
||||
Use template 'Edit zone DNS'. Permissions: Zone:DNS:Edit for the zone."
|
||||
# shellcheck disable=SC2034
|
||||
DNS_HINT_digitalocean="Create a Personal Access Token with WRITE scope at
|
||||
https://cloud.digitalocean.com/account/api/tokens"
|
||||
|
||||
# The exact credential file format per provider ships INSIDE the NPM image, so it is
|
||||
# read from there rather than hardcoded — that keeps working as certbot plugins change.
|
||||
proxy_prompt_dns_credentials() {
|
||||
echo ""
|
||||
info "Supported providers include: cloudflare, godaddy, digitalocean, route53,"
|
||||
info "namecheap, ovh, linode, vultr, hetzner, gandi, google, azure …"
|
||||
DNS_PROVIDER="$(proxy_ask 'DNS provider')"
|
||||
[[ -n "$DNS_PROVIDER" ]] || {
|
||||
warn "no provider given"
|
||||
return 1
|
||||
}
|
||||
|
||||
local hint="DNS_HINT_${DNS_PROVIDER}"
|
||||
[[ -n "${!hint:-}" ]] && {
|
||||
echo ""
|
||||
info "${!hint}"
|
||||
}
|
||||
|
||||
echo ""
|
||||
info "Credential format this provider expects:"
|
||||
docker exec npm python3 -c \
|
||||
"import json;d=json.load(open('/app/certbot/dns-plugins.json'));print(d['${DNS_PROVIDER}']['credentials'])" \
|
||||
2>/dev/null | sed 's/^/ /' ||
|
||||
warn "could not read the template — check the provider name is spelled correctly"
|
||||
|
||||
echo ""
|
||||
info "Paste the credential lines exactly as shown above (blank line to finish):"
|
||||
DNS_CREDENTIALS=""
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && break
|
||||
DNS_CREDENTIALS+="$line"$'\n'
|
||||
done
|
||||
[[ -n "$DNS_CREDENTIALS" ]] || {
|
||||
warn "no credentials entered"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# ── 2. wait for DNS ──
|
||||
#
|
||||
# `getent hosts` rather than `dig`: dig comes from dnsutils, which this platform does
|
||||
# not install, so the draft's version was command-not-found on a fresh VPS — and since
|
||||
# an empty answer is indistinguishable from "not resolving yet", it waited the full
|
||||
# thirty minutes before failing. getent is in libc and always there.
|
||||
#
|
||||
# The cost is that it reads the system resolver rather than a public one, so a stale
|
||||
# local cache can satisfy it. Worth it against a check that cannot run at all.
|
||||
proxy_wait_for_dns() {
|
||||
local domain="$1" want="$2" got elapsed=0 interval=15 timeout=1800
|
||||
echo ""
|
||||
info "Point this DNS record at the machine now:"
|
||||
echo ""
|
||||
info " ${domain}. A ${want}"
|
||||
echo ""
|
||||
[[ "$CHALLENGE" == "dns" ]] &&
|
||||
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
|
||||
|
||||
while ((elapsed < timeout)); do
|
||||
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
|
||||
if [[ "$got" == "$want" ]]; then
|
||||
ok "${domain} resolves to ${want}"
|
||||
return 0
|
||||
fi
|
||||
printf '\r waiting — %s (%ss) ' "${got:-not resolving yet}" "$elapsed"
|
||||
sleep "$interval"
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
echo ""
|
||||
warn "${domain} still does not resolve to ${want} after $((timeout / 60)) minutes"
|
||||
[[ "$CHALLENGE" == "dns" ]] && proxy_confirm "Continue anyway and issue the certificate?" && return 0
|
||||
warn "cannot issue an HTTP-01 certificate until DNS resolves here"
|
||||
return 1
|
||||
}
|
||||
|
||||
proxy_ensure_network() {
|
||||
docker network inspect "$PROXY_NET" >/dev/null 2>&1 && return 0
|
||||
docker network create "$PROXY_NET" >/dev/null && ok "created docker network ${PROXY_NET}"
|
||||
}
|
||||
|
||||
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
|
||||
# address does not exist yet and the WHOLE container fails to start, not just that port.
|
||||
proxy_order_docker_after_tailscaled() {
|
||||
[[ "$CHALLENGE" == "dns" ]] || return 0
|
||||
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
|
||||
[[ -f "$f" ]] && return 0
|
||||
mkdir -p "$(dirname "$f")"
|
||||
cat >"$f" <<'EOF'
|
||||
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled, that
|
||||
# address does not exist and the container fails to start entirely.
|
||||
[Unit]
|
||||
After=tailscaled.service
|
||||
Wants=tailscaled.service
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
ok "docker ordered after tailscaled"
|
||||
report_changed "$f" "docker ordered after tailscaled so NPM can bind the tailnet IP"
|
||||
}
|
||||
|
||||
# Admin UI (81) is NEVER published on 0.0.0.0. Until it is claimed, anyone who reaches
|
||||
# it can take the instance; afterwards it can issue certificates and re-point every
|
||||
# proxied service on the box. 80/443 are public only when they need to be.
|
||||
proxy_write_compose() {
|
||||
local dir admin_binds public_binds
|
||||
dir="$(proxy_dir)"
|
||||
install -d -o "$USERNAME" -g "$(user_group)" "$dir" "$dir/npm_data" "$dir/letsencrypt"
|
||||
|
||||
admin_binds=" - \"127.0.0.1:81:81\""
|
||||
if [[ "$CHALLENGE" == "dns" ]]; then
|
||||
admin_binds+=$'\n'" - \"${TARGET_IP}:81:81\""
|
||||
public_binds=" - \"${TARGET_IP}:80:80\""$'\n'" - \"${TARGET_IP}:443:443\""
|
||||
else
|
||||
public_binds=" - \"80:80\""$'\n'" - \"443:443\""
|
||||
fi
|
||||
|
||||
cat >"${dir}/docker-compose.yaml" <<EOF
|
||||
# Generated by officer-setup. Reverse proxy for this Officer instance.
|
||||
#
|
||||
# The admin UI (81) is bound to loopback$([[ "$CHALLENGE" == "dns" ]] && echo " and the tailnet") only, never
|
||||
# 0.0.0.0 — it can issue certificates and re-point every proxied service on this box.
|
||||
#
|
||||
# NOTE: ufw does NOT filter docker-published ports. Exposure is decided by the bind
|
||||
# addresses below and by the DOCKER-USER chain in /etc/ufw/after.rules.
|
||||
name: npm
|
||||
services:
|
||||
npm:
|
||||
image: jc21/nginx-proxy-manager:latest
|
||||
container_name: npm
|
||||
restart: always
|
||||
networks: [${PROXY_NET}]
|
||||
ports:
|
||||
${public_binds}
|
||||
${admin_binds}
|
||||
volumes:
|
||||
- ./npm_data:/data
|
||||
- ./letsencrypt:/etc/letsencrypt
|
||||
|
||||
networks:
|
||||
${PROXY_NET}:
|
||||
external: true
|
||||
EOF
|
||||
chown "${USERNAME}:$(user_group)" "${dir}/docker-compose.yaml"
|
||||
ok "wrote ${dir}/docker-compose.yaml"
|
||||
report_changed "${dir}/docker-compose.yaml" "nginx-proxy-manager compose file"
|
||||
}
|
||||
|
||||
proxy_start() {
|
||||
as_owner "docker compose --project-directory '$(proxy_dir)' up -d" / >/dev/null
|
||||
local i
|
||||
for i in $(seq 1 60); do
|
||||
curl -sf "$PROXY_API/" >/dev/null 2>&1 && {
|
||||
ok "NPM answered after ${i}s"
|
||||
report_started "npm" "nginx-proxy-manager container"
|
||||
return 0
|
||||
}
|
||||
sleep 1
|
||||
done
|
||||
warn "NPM did not become ready — check: docker logs npm"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── claim the admin account immediately ──
|
||||
#
|
||||
# NPM 2.15 replaced the fixed default login with a first-run wizard: while the user
|
||||
# count is zero, ANYONE who reaches port 81 can claim admin. Done in the same breath as
|
||||
# starting the container. The bind addresses above already make that window unreachable
|
||||
# from outside, but this does not rely on that alone.
|
||||
#
|
||||
# The re-run path is the half the draft was missing: it returned early on an already
|
||||
# claimed instance WITHOUT setting NPM_EMAIL/NPM_PASSWORD, and the next function
|
||||
# dereferenced both under `set -u`. So the second run of a "re-runnable" script died on
|
||||
# an unbound variable. An existing instance asks for the credentials instead.
|
||||
proxy_claim_admin() {
|
||||
if curl -sf "$PROXY_API/" | grep -q '"setup":true'; then
|
||||
ok "NPM admin is already claimed"
|
||||
echo ""
|
||||
info "This instance already has an admin account. Its credentials are needed to"
|
||||
info "add the proxy host below."
|
||||
NPM_EMAIL="$(proxy_ask 'NPM admin email')"
|
||||
NPM_PASSWORD="$(proxy_ask 'NPM admin password')"
|
||||
[[ -n "$NPM_EMAIL" && -n "$NPM_PASSWORD" ]] || {
|
||||
warn "both are needed to continue"
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Create the NPM admin account."
|
||||
NPM_EMAIL="$(proxy_ask 'Admin email')"
|
||||
[[ -n "$NPM_EMAIL" ]] || {
|
||||
warn "no email given"
|
||||
return 1
|
||||
}
|
||||
NPM_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)"
|
||||
|
||||
curl -sf -X POST "$PROXY_API/users" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg e "$NPM_EMAIL" --arg p "$NPM_PASSWORD" \
|
||||
'{name:"Admin",nickname:"Admin",email:$e,roles:["admin"],is_disabled:false,auth:{type:"password",secret:$p}}')" \
|
||||
>/dev/null || {
|
||||
warn "failed to create the NPM admin user"
|
||||
return 1
|
||||
}
|
||||
|
||||
curl -sf "$PROXY_API/" | grep -q '"setup":true' || {
|
||||
warn "admin creation did not take"
|
||||
return 1
|
||||
}
|
||||
ok "NPM admin claimed: ${NPM_EMAIL}"
|
||||
|
||||
# ── the admin password ──
|
||||
#
|
||||
# Deliberately NOT written to a file. The platform's shape is that
|
||||
# secrets/officer-keys.db holds ENCRYPTION KEYS, one per purpose, and the credential
|
||||
# itself lives encrypted in Postgres. A third plaintext location is the pattern
|
||||
# headscale/schema.ts calls "debt to avoid copying, not a precedent to follow".
|
||||
#
|
||||
# Nothing programmatic needs this after setup — only a human logging into the admin
|
||||
# UI — so not storing it is a legitimate outcome rather than a gap.
|
||||
#
|
||||
# The DNS API credentials are deliberately never handled either: NPM must keep a
|
||||
# plaintext copy in npm_data/database.sqlite for certbot to auto-renew, so copying
|
||||
# them anywhere else adds exposure without adding protection.
|
||||
echo ""
|
||||
warn "This password is shown ONCE and is not stored anywhere:"
|
||||
echo ""
|
||||
echo " ${NPM_EMAIL}"
|
||||
echo " ${NPM_PASSWORD}"
|
||||
echo ""
|
||||
info "Put it in your password manager now."
|
||||
proxy_confirm "Saved it?" || {
|
||||
warn "stopping so the password is not lost — the container is running and claimed"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
proxy_api() {
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' -d "$body"
|
||||
else
|
||||
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN"
|
||||
fi
|
||||
}
|
||||
|
||||
proxy_get_token() {
|
||||
TOKEN="$(curl -sf -X POST "$PROXY_API/tokens" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg i "$NPM_EMAIL" --arg s "$NPM_PASSWORD" '{identity:$i,secret:$s}')" |
|
||||
jq -r '.token')" || {
|
||||
warn "could not authenticate to the NPM API"
|
||||
return 1
|
||||
}
|
||||
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || {
|
||||
warn "NPM rejected those admin credentials"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# ── let the bridge reach the host process ──
|
||||
#
|
||||
# Officer runs on the HOST under pm2, not in a container. Bridge → host traffic DOES
|
||||
# traverse INPUT, so ufw's default-deny drops it — unlike docker-published ports, which
|
||||
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
|
||||
# upstream would need none of this, which is why container upstreams are preferable when
|
||||
# there is a choice.
|
||||
proxy_allow_bridge_to_host() {
|
||||
local port="$1" subnet
|
||||
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
|
||||
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
|
||||
ok "ufw already allows the bridge to reach port ${port}"
|
||||
else
|
||||
ufw allow from "$subnet" to any port "$port" proto tcp >/dev/null
|
||||
ok "ufw: allowed ${subnet} → :${port}"
|
||||
report_changed "ufw" "allowed ${subnet} to reach port ${port} (bridge to host)"
|
||||
fi
|
||||
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
|
||||
}
|
||||
|
||||
# Created WITHOUT ssl first, deliberately. Enabling force-SSL before a certificate
|
||||
# exists gives a host that 301s to https and then fails the handshake — curl reports
|
||||
# 000, which reads like a network fault rather than a config mistake.
|
||||
proxy_create_host() {
|
||||
local domain="$1" port="$2" existing
|
||||
existing="$(proxy_api GET /nginx/proxy-hosts | jq -r --arg d "$domain" \
|
||||
'map(select(.domain_names | index($d))) | .[0].id // empty')"
|
||||
if [[ -n "$existing" ]]; then
|
||||
HOST_ID="$existing"
|
||||
ok "proxy host already exists (id ${HOST_ID})"
|
||||
return 0
|
||||
fi
|
||||
|
||||
HOST_ID="$(proxy_api POST /nginx/proxy-hosts "$(jq -nc \
|
||||
--arg d "$domain" --arg h "$BRIDGE_GATEWAY" --argjson p "$port" \
|
||||
'{domain_names:[$d],forward_scheme:"http",forward_host:$h,forward_port:$p,
|
||||
access_list_id:0,certificate_id:0,block_exploits:true,caching_enabled:false,
|
||||
allow_websocket_upgrade:true,ssl_forced:false,http2_support:false,
|
||||
hsts_enabled:false,hsts_subdomains:false,meta:{},advanced_config:"",locations:[]}')" |
|
||||
jq -r '.id')"
|
||||
[[ -n "$HOST_ID" && "$HOST_ID" != "null" ]] || {
|
||||
warn "could not create the proxy host"
|
||||
return 1
|
||||
}
|
||||
ok "proxy host created (id ${HOST_ID}) → ${BRIDGE_GATEWAY}:${port}"
|
||||
}
|
||||
|
||||
# NPM 2.15 REMOVED letsencrypt_email and letsencrypt_agree from the certificate schema.
|
||||
# Sending them returns: 400 data/meta must NOT have additional properties.
|
||||
proxy_issue_certificate() {
|
||||
local domain="$1" meta
|
||||
CERT_ID="$(proxy_api GET /nginx/certificates | jq -r --arg d "$domain" \
|
||||
'map(select(.domain_names | index($d))) | .[0].id // empty')"
|
||||
[[ -n "$CERT_ID" ]] && {
|
||||
ok "certificate already exists (id ${CERT_ID})"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$CHALLENGE" == "dns" ]]; then
|
||||
meta="$(jq -nc --arg p "$DNS_PROVIDER" --arg c "$DNS_CREDENTIALS" \
|
||||
'{dns_challenge:true,dns_provider:$p,dns_provider_credentials:$c,propagation_seconds:120}')"
|
||||
info "Requesting the certificate via DNS-01 — about two minutes, for the plugin"
|
||||
info "install and DNS propagation."
|
||||
else
|
||||
meta='{"dns_challenge":false}'
|
||||
info "Requesting the certificate via HTTP-01"
|
||||
fi
|
||||
|
||||
CERT_ID="$(proxy_api POST /nginx/certificates "$(jq -nc \
|
||||
--arg d "$domain" --argjson m "$meta" \
|
||||
'{provider:"letsencrypt",nice_name:$d,domain_names:[$d],meta:$m}')" | jq -r '.id')"
|
||||
[[ -n "$CERT_ID" && "$CERT_ID" != "null" ]] || {
|
||||
warn "the certificate request failed — see: docker logs npm"
|
||||
return 1
|
||||
}
|
||||
ok "certificate issued (id ${CERT_ID})"
|
||||
}
|
||||
|
||||
proxy_attach_certificate() {
|
||||
proxy_api PUT "/nginx/proxy-hosts/${HOST_ID}" "$(jq -nc --argjson c "$CERT_ID" \
|
||||
'{certificate_id:$c,ssl_forced:true,http2_support:true,hsts_enabled:false,hsts_subdomains:false}')" \
|
||||
>/dev/null || {
|
||||
warn "could not attach the certificate"
|
||||
return 1
|
||||
}
|
||||
ok "certificate attached, force-SSL and HTTP/2 on"
|
||||
}
|
||||
|
||||
proxy_verify() {
|
||||
local domain="$1" code
|
||||
code="$(curl -so /dev/null -w '%{http_code}' --max-time 20 \
|
||||
--resolve "${domain}:443:${TARGET_IP}" "https://${domain}/" || echo 000)"
|
||||
case "$code" in
|
||||
200 | 30[0-9]) ok "https://${domain} → ${code}" ;;
|
||||
000) warn "TLS handshake failed — certificate not attached, or force-SSL set before it existed" ;;
|
||||
502) warn "502 — nothing listening on the upstream port" ;;
|
||||
504) warn "504 — upstream unreachable: ufw dropping bridge→host, or the wrong forward_host" ;;
|
||||
*) warn "unexpected response: ${code}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
proxy_skip_instructions() {
|
||||
local port="$1" gw
|
||||
gw="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || echo '<bridge-gateway>')"
|
||||
cat <<EOF
|
||||
|
||||
To put Officer behind your own proxy, point it at:
|
||||
|
||||
http://<this-machine>:${port}
|
||||
|
||||
If that proxy runs in a container ON this machine, use ${gw}:${port} — inside a
|
||||
container 127.0.0.1 is the container itself — and let it through ufw:
|
||||
|
||||
ufw allow from <container-subnet> to any port ${port} proto tcp
|
||||
|
||||
Officer must bind 0.0.0.0, not 127.0.0.1, or the proxy cannot reach it.
|
||||
|
||||
EOF
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the repository
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── Cloned as the owner, never as root ──
|
||||
#
|
||||
# A repository cloned by root is one the owner cannot pull, cannot commit in, and
|
||||
# whose node_modules they cannot write. Every git operation here runs as the
|
||||
# account, from a directory that account can stat.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_REPO_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_REPO_LOADED=1
|
||||
|
||||
# Public HTTPS, which is what this needed all along.
|
||||
#
|
||||
# It was ssh://git@gitea.pastilhas.dev:2222/... until 2026-08-14, and the reason was
|
||||
# that the repository was private: an HTTPS clone of a private repo prompts for a
|
||||
# username, and under sudo with no interactive terminal that hangs or dies with
|
||||
# "could not read Username". The note here said "back to HTTPS when the repository is
|
||||
# public", and it now is — verified with an anonymous `git ls-remote`.
|
||||
#
|
||||
# The change matters more than a URL swap. An SSH default cannot clone on a genuinely
|
||||
# fresh machine: the key machine-setup generates there is brand new and Gitea has
|
||||
# never seen it, so `--repo` was effectively mandatory on a first install. HTTPS needs
|
||||
# no key and no agent, so the default now works on a blank box.
|
||||
#
|
||||
# If this ever goes private again, SSH is the answer and the constraint above is the
|
||||
# reason — plus one more: the clone runs as the OWNER, and sudo drops SSH_AUTH_SOCK,
|
||||
# so a passphrase-protected key has no agent to answer it.
|
||||
OFFICER_REPO="${OFFICER_REPO:-https://gitea.officer.dev/officerdev/platform.git}"
|
||||
|
||||
platform_dir() { echo "${OFFICER_ROOT}/platform"; }
|
||||
|
||||
repo_exists() { [[ -d "$(platform_dir)/.git" ]]; }
|
||||
|
||||
repo_remote() { (cd "$(platform_dir)" 2>/dev/null && git remote get-url origin 2>/dev/null) || true; }
|
||||
repo_branch() { (cd "$(platform_dir)" 2>/dev/null && git branch --show-current 2>/dev/null) || true; }
|
||||
repo_is_dirty() { [[ -n "$(cd "$(platform_dir)" 2>/dev/null && git status --porcelain 2>/dev/null)" ]]; }
|
||||
|
||||
# Split an ssh:// URL into host and port, for the reachability check below.
|
||||
repo_ssh_host() { sed -E 's|^ssh://[^@]*@([^:/]+).*|\1|' <<<"$1"; }
|
||||
repo_ssh_port() { sed -nE 's|^ssh://[^@]*@[^:]+:([0-9]+)/.*|\1|p' <<<"$1"; }
|
||||
|
||||
# Can this account actually clone it?
|
||||
#
|
||||
# `git ls-remote` is the real question — not "does the host answer" but "can this
|
||||
# account read this repository". Both prompts are disabled, because neither fails
|
||||
# cleanly on its own: over https git asks for a username nobody is there to type,
|
||||
# and over ssh it asks for a password or stops on host-key verification. With
|
||||
# both off, an unreachable or unreadable repository is an immediate non-zero
|
||||
# instead of a hang.
|
||||
repo_reachable() {
|
||||
as_owner "GIT_TERMINAL_PROMPT=0 \
|
||||
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8' \
|
||||
timeout 20 git ls-remote '$1' >/dev/null 2>&1" /
|
||||
}
|
||||
|
||||
# The https form of the same repository, for a machine with no key.
|
||||
repo_https_url() {
|
||||
sed -E 's|^ssh://[^@]*@([^:/]+)(:[0-9]+)?/|https://\1/|' <<<"$1"
|
||||
}
|
||||
|
||||
clone_repo() {
|
||||
local url="$1" dest
|
||||
dest="$(platform_dir)"
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$OFFICER_ROOT"
|
||||
as_owner "GIT_TERMINAL_PROMPT=0 git clone '${url}' '${dest}'" /
|
||||
}
|
||||
|
||||
pull_repo() { as_owner "git -C '$(platform_dir)' pull --ff-only" /; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# ── The lockfile is frozen, and that is the point ──
|
||||
#
|
||||
# bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from
|
||||
# bun.lock and nothing else. A package.json that disagrees with the lockfile is a
|
||||
# hard failure rather than a quiet resolution — which is deliberate: the friction
|
||||
# exists so that an unexplained lockfile change shows up in a diff. See the
|
||||
# supply-chain note in CLAUDE.md.
|
||||
#
|
||||
# So a failure here is usually one of two things, and they need different
|
||||
# answers: the lockfile genuinely disagrees with package.json, or node-pty failed
|
||||
# to build. Both are reported as such rather than as "install failed".
|
||||
|
||||
deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
|
||||
|
||||
# node-pty has no Linux prebuild, so `bun install` compiles it every time. This is
|
||||
# the artefact that proves it worked, and its absence is why the terminal sidecar
|
||||
# would not start.
|
||||
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
|
||||
|
||||
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the secret store
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# The store is $OFFICER_ROOT/secrets/officer-keys.db, deliberately a sibling of
|
||||
# the repo and NOT under data/ — that directory holds the managed homes and
|
||||
# attachments people back up, and a key store travelling in the same tarball as a
|
||||
# database dump rebuilds the exact problem it exists to avoid.
|
||||
#
|
||||
# Bootstrapping runs the platform's own module rather than reimplementing the
|
||||
# schema in bash. There is exactly one writer of this file's format, and a second
|
||||
# one in shell would drift the first time a column is added.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_SECRETS_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_SECRETS_LOADED=1
|
||||
|
||||
secret_store_dir() { echo "${OFFICER_ROOT}/secrets"; }
|
||||
secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; }
|
||||
|
||||
# Create the store and the two purposes a core install needs.
|
||||
#
|
||||
# Run AS the owner, not as root: the platform runs as them, and a store root
|
||||
# created would be a store they cannot write. `install -d -o` sets the owner in
|
||||
# one step rather than mkdir-then-chown, so it is never briefly root's.
|
||||
bootstrap_secret_store() {
|
||||
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(secret_store_dir)" || return 1
|
||||
|
||||
# From the repo, because the module derives the install root as the parent of
|
||||
# the working directory — the same rule as src/servers/data-path.ts.
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
|
||||
const { getKey } = await import('officerdb/secret-store');
|
||||
getKey('jwt');
|
||||
getKey('headscale');
|
||||
\"" >/dev/null 2>&1 || return 1
|
||||
|
||||
[[ -f "$(secret_store_path)" ]]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the pm2 ecosystem file, and starting the processes
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── The ecosystem file is GENERATED, and is not in git ──
|
||||
#
|
||||
# There used to be four of them — ecosystem.config.cjs, .light., .mac.light. and
|
||||
# a .profile. that the others derived from. A profile deriving from a full list
|
||||
# means the full list has to exist, which means every plugin's process is
|
||||
# described in the repository whether or not anybody installed it, and a test had
|
||||
# to assert that the two files still agreed with each other.
|
||||
#
|
||||
# One generated file removes all of that. It describes exactly the processes this
|
||||
# install runs, it is written once at setup, and nothing in git can drift from
|
||||
# it. A plugin adds its own entry when it is installed.
|
||||
#
|
||||
# ── Why .cjs and not .js ──
|
||||
#
|
||||
# PM2's own convention is ecosystem.config.js, and it would be wrong here:
|
||||
# package.json declares "type": "module", so a .js file in this directory is ESM
|
||||
# and `module.exports` throws "module is not defined in ES module scope". PM2
|
||||
# require()s the config, so the extension has to say CommonJS out loud.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_SERVICES_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_SERVICES_LOADED=1
|
||||
|
||||
ecosystem_file() { echo "$(platform_dir)/ecosystem.config.cjs"; }
|
||||
|
||||
# The processes a core install runs. Everything else is a plugin.
|
||||
#
|
||||
# `officer-pty` is node rather than bun, and that is not an oversight: it loads
|
||||
# node-pty, a native module built against Node's ABI. Everything else is bun.
|
||||
#
|
||||
# `officer-claude-code` was `officer-agent` until 2026-08-13. The old name said
|
||||
# nothing about what it runs, and it sits beside officer-anthropic-proxy — which
|
||||
# is a different process doing a different job — so "the agent" was ambiguous
|
||||
# exactly where it mattered. It spawns `claude`; the name says so now.
|
||||
CORE_PROCESSES=(
|
||||
"officer|bun|start"
|
||||
"officer-anthropic-proxy|bun|run src/servers/sidecar/claude/index.ts"
|
||||
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
||||
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
||||
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
||||
)
|
||||
|
||||
write_ecosystem() {
|
||||
local dest entry name script args
|
||||
dest="$(ecosystem_file)"
|
||||
|
||||
{
|
||||
cat <<'HEADER'
|
||||
// Generated by officer-setup. Not in git, and not meant to be — it describes THIS
|
||||
// install, and the next machine generates its own.
|
||||
//
|
||||
// `cwd` is pinned on every app for two reasons. Bun auto-loads .env from the
|
||||
// working directory (and the pty sidecar does `import 'dotenv/config'`), so
|
||||
// without it a process started from anywhere else comes up with no POSTGRES_URL.
|
||||
// And src/servers/data-path.ts derives the install root as the PARENT of the
|
||||
// working directory, so a wrong cwd does not fail — it relocates data/,
|
||||
// capabilities/ and dockers/ somewhere else entirely. `assertInstallLayout`
|
||||
// refuses to boot when that happens.
|
||||
//
|
||||
// To add a plugin later, add its entry here. Nothing derives this file from
|
||||
// anything, so there is no second list to keep it agreeing with.
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
HEADER
|
||||
for entry in "${CORE_PROCESSES[@]}"; do
|
||||
IFS='|' read -r name script args <<<"$entry"
|
||||
printf " { name: '%s', script: '%s', args: '%s', cwd: '%s', watch: false },\n" \
|
||||
"$name" "$script" "$args" "$(platform_dir)"
|
||||
done
|
||||
cat <<'FOOTER'
|
||||
],
|
||||
};
|
||||
FOOTER
|
||||
} >"$dest"
|
||||
|
||||
chown "${USERNAME}:$(user_group)" "$dest"
|
||||
return 0
|
||||
}
|
||||
|
||||
pm2_start() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
|
||||
}
|
||||
|
||||
pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; }
|
||||
|
||||
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
|
||||
# doing it — so this runs what it prints, which is the whole point of already
|
||||
# being root here.
|
||||
pm2_enable_startup() {
|
||||
local cmd
|
||||
cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
|
||||
[[ -z "$cmd" ]] && return 1
|
||||
eval "${cmd#sudo }"
|
||||
}
|
||||
|
||||
# One line per process: name, status, restarts.
|
||||
pm2_status_lines() {
|
||||
sudo -u "$USERNAME" pm2 jlist 2>/dev/null |
|
||||
node -e '
|
||||
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
||||
let apps = []; try { apps = JSON.parse(s); } catch { }
|
||||
for (const a of apps) {
|
||||
const st = a.pm2_env?.status ?? "?";
|
||||
console.log(`${a.name}|${st}|${a.pm2_env?.restart_time ?? 0}`);
|
||||
}
|
||||
});'
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# The install report
|
||||
# =============================================================================
|
||||
#
|
||||
# Every run writes a timestamped markdown file recording what it installed, what
|
||||
# it changed, what it left alone, and what it ran as root.
|
||||
#
|
||||
# ── Who it is for ──
|
||||
#
|
||||
# Not us. It exists so the person who just ran a setup script off the internet
|
||||
# can hand the result to an agent of THEIR choosing and ask "did this do anything
|
||||
# it should not have". That is an adversarial read by someone who does not trust
|
||||
# us, which decides almost every choice below:
|
||||
#
|
||||
# Facts, not narration. "installed docker-ce" is checkable. "set up Docker" is
|
||||
# a claim. Every entry names the thing precisely enough to verify against the
|
||||
# machine afterwards.
|
||||
#
|
||||
# Recorded by the HELPERS, not by the sections. A section that has to remember
|
||||
# to report is a section that will forget, and an incomplete report is worse
|
||||
# than none — it reads as a full account. `pkg_install` and `install_config`
|
||||
# record themselves, so anything installed or written through them appears
|
||||
# whether or not the section author thought about it.
|
||||
#
|
||||
# Kept and skipped are recorded too. "Left your .zshrc alone" is the claim a
|
||||
# reviewer most wants substantiated, and it is invisible unless stated.
|
||||
#
|
||||
# NO SECRETS. The whole point is that this file gets shared. Passwords, keys
|
||||
# and connection strings are redacted at the moment of recording rather than
|
||||
# filtered later — see `report_redact`.
|
||||
#
|
||||
# ── Shape ──
|
||||
#
|
||||
# Facts accumulate in an array during the run and the file is rendered at the
|
||||
# end, so a crash halfway leaves no half-written report claiming to be complete.
|
||||
# `report_flush` is called by the exit trap, which marks it INCOMPLETE and says
|
||||
# where it stopped.
|
||||
|
||||
[[ -n "${OFFICER_REPORT_LOADED:-}" ]] && return 0
|
||||
OFFICER_REPORT_LOADED=1
|
||||
|
||||
REPORT_FACTS=()
|
||||
REPORT_SECTION="(start)"
|
||||
REPORT_STARTED="$(date '+%Y-%m-%d %H:%M:%S %Z')"
|
||||
REPORT_COMPLETE=false
|
||||
|
||||
# Where it goes. install.sh exports REPORT_FILE so both halves land in ONE file;
|
||||
# a half run on its own makes its own.
|
||||
report_path() {
|
||||
if [[ -n "${REPORT_FILE:-}" ]]; then
|
||||
echo "$REPORT_FILE"
|
||||
return
|
||||
fi
|
||||
local base="${OFFICER_ROOT:-${USER_HOME:-$HOME}}"
|
||||
[[ -d "$base" ]] || base="${USER_HOME:-$HOME}"
|
||||
echo "${base}/install-report-$(date '+%Y%m%d-%H%M%S').md"
|
||||
}
|
||||
|
||||
# Redact anything that looks like a credential.
|
||||
#
|
||||
# Applied when the fact is RECORDED, not when it is rendered, so a secret never
|
||||
# sits in memory formatted for printing and cannot be leaked by a future change
|
||||
# to the renderer. Deliberately blunt: a password that survives is a leak, a URL
|
||||
# over-redacted is an inconvenience.
|
||||
report_redact() {
|
||||
sed -E \
|
||||
-e 's#(://[^:/@[:space:]]+):[^@[:space:]]+@#\1:REDACTED@#g' \
|
||||
-e 's#((password|passwd|secret|token|key|apikey|api_key)[[:space:]]*[=:][[:space:]]*)[^[:space:]]+#\1REDACTED#gI'
|
||||
}
|
||||
|
||||
report_section() { REPORT_SECTION="$1"; }
|
||||
|
||||
# One fact. `kind` is what a reviewer scans for: installed, kept, changed,
|
||||
# skipped, ran, started, failed.
|
||||
report_fact() {
|
||||
local kind="$1" text="$2"
|
||||
REPORT_FACTS+=("${REPORT_SECTION}|${kind}|$(printf '%s' "$text" | report_redact | tr '\n' ' ')")
|
||||
}
|
||||
|
||||
report_installed() { report_fact installed "$1"; }
|
||||
report_kept() { report_fact kept "$1"; }
|
||||
report_changed() { report_fact changed "$1"; }
|
||||
report_skipped() { report_fact skipped "$1"; }
|
||||
report_started() { report_fact started "$1"; }
|
||||
report_failed() { report_fact failed "$1"; }
|
||||
|
||||
# A command run with privilege. The reviewer's first question is "what did it run
|
||||
# as root", and the honest answer is a list rather than a promise.
|
||||
report_ran() { report_fact ran "$1"; }
|
||||
|
||||
report_mark_complete() { REPORT_COMPLETE=true; }
|
||||
|
||||
# Render. Safe to call twice; the trap and a normal finish both reach it.
|
||||
report_flush() {
|
||||
local dest kinds k
|
||||
dest="$(report_path)"
|
||||
[[ -n "${REPORT_WRITTEN:-}" ]] && return 0
|
||||
REPORT_WRITTEN=1
|
||||
|
||||
{
|
||||
echo "# Officer install report"
|
||||
echo ""
|
||||
if $REPORT_COMPLETE; then
|
||||
echo "**Status:** finished."
|
||||
else
|
||||
echo "**Status: INCOMPLETE — the run stopped during \`${REPORT_SECTION}\`.**"
|
||||
echo "Everything below still happened; what comes after it did not."
|
||||
fi
|
||||
echo ""
|
||||
echo "| | |"
|
||||
echo "| --- | --- |"
|
||||
echo "| started | ${REPORT_STARTED} |"
|
||||
echo "| finished | $(date '+%Y-%m-%d %H:%M:%S %Z') |"
|
||||
echo "| host | $(hostname 2>/dev/null || echo unknown) |"
|
||||
echo "| system | $(uname -srm) |"
|
||||
echo "| account | ${USERNAME:-$(id -un)} |"
|
||||
echo "| script commit | $(git -C "${SCRIPT_DIR:-.}" rev-parse --short HEAD 2>/dev/null || echo 'not a git checkout') |"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## How to review this"
|
||||
echo ""
|
||||
echo "This file exists so you can hand it to someone — or something — that does"
|
||||
echo "not trust the script that wrote it. It is a list of facts, each meant to be"
|
||||
echo "checkable against the machine rather than taken on faith."
|
||||
echo ""
|
||||
echo "Worth asking of it:"
|
||||
echo ""
|
||||
echo "- Does anything under **installed** come from somewhere other than your"
|
||||
echo " distribution's repositories, Homebrew, or a vendor's documented installer?"
|
||||
echo "- Does anything under **changed** touch a file outside this install, your"
|
||||
echo " home directory, or the system configuration a setup script would be"
|
||||
echo " expected to touch?"
|
||||
echo "- Does anything under **ran** do more than the section it sits under claims?"
|
||||
echo "- Is anything **started** that you did not ask for?"
|
||||
echo ""
|
||||
echo "Credentials are redacted where they were recorded. If you find one that is"
|
||||
echo "not, that is a bug worth reporting — this file is meant to be shareable."
|
||||
echo ""
|
||||
echo "What this report does NOT cover: anything a package's own post-install"
|
||||
echo "script did. Reviewing \`docker-ce\` itself is a different exercise from"
|
||||
echo "reviewing the script that installed it."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
if ((${#REPORT_FACTS[@]} == 0)); then
|
||||
echo "_Nothing was recorded — no section made a change._"
|
||||
else
|
||||
local last=""
|
||||
local line section kind text
|
||||
for line in "${REPORT_FACTS[@]}"; do
|
||||
section="${line%%|*}"
|
||||
kind="${line#*|}"; kind="${kind%%|*}"
|
||||
text="${line#*|*|}"
|
||||
if [[ "$section" != "$last" ]]; then
|
||||
[[ -n "$last" ]] && echo ""
|
||||
echo "## ${section}"
|
||||
echo ""
|
||||
last="$section"
|
||||
fi
|
||||
printf -- '- **%s** — %s\n' "$kind" "$text"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "## Summary by kind"
|
||||
echo ""
|
||||
for k in installed changed kept skipped started ran failed; do
|
||||
local n
|
||||
n="$(printf '%s\n' "${REPORT_FACTS[@]}" | grep -c "|${k}|" || true)"
|
||||
printf -- '- %-10s %s\n' "$k" "$n"
|
||||
done
|
||||
} >"$dest" 2>/dev/null
|
||||
|
||||
[[ -n "${USERNAME:-}" ]] && chown "${USERNAME}:$(id -gn "$USERNAME" 2>/dev/null || echo "$USERNAME")" "$dest" 2>/dev/null || true
|
||||
chmod 0644 "$dest" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo " Install report: ${dest}"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
########## 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
|
||||
|
||||
# Meta keys are ESC-prefixed on the wire, and tmux waits `escape-time` to decide whether an incoming ESC is
|
||||
# a lone Escape or the start of one. The default is 500ms, so every Alt-chord below — and every Escape in
|
||||
# vim — pays half a second before anything happens. 10ms is enough to disambiguate a sequence that arrives
|
||||
# in one TCP frame, which over a websocket relay it always does.
|
||||
set -sg escape-time 10
|
||||
|
||||
# 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@'
|
||||
@@ -0,0 +1,46 @@
|
||||
# Officer — the owner's shell configuration.
|
||||
#
|
||||
# EMPTY ON PURPOSE, for now. Created 2026-08-13 so there is somewhere to put the
|
||||
# things the owner actually wants, and it is not wired into the Shell section yet.
|
||||
#
|
||||
# ── What this replaces, and the decision still to make ──
|
||||
#
|
||||
# The Shell section does not install a .zshrc today. It APPENDS four
|
||||
# marker-wrapped blocks to whatever is already there — `starship`, `agent`,
|
||||
# `aliases` and `editor` — via `append_once`, which recognises its own work so a
|
||||
# second run does not duplicate it. That was the right call for a machine whose
|
||||
# .zshrc already belongs to somebody.
|
||||
#
|
||||
# Installing a whole file is a different promise, and the two do not compose: a
|
||||
# template that gets installed AND appended to ends up with the same lines twice,
|
||||
# once from the file and once from a block. So when this is wired in, the four
|
||||
# append_once blocks either move INTO this file or stay out of it — not both.
|
||||
#
|
||||
# `install_config` already handles the careful half: it writes only when the
|
||||
# destination is missing or still byte-for-byte the template, and offers a diff
|
||||
# otherwise, so an owner's own edits are never overwritten.
|
||||
#
|
||||
# ── Where the shell templates live ──
|
||||
#
|
||||
# scripts/setup/{starship.toml, tmux.conf, zshrc}, together. starship.toml has to
|
||||
# be here rather than inside machine-setup/, because the PLATFORM reads it too —
|
||||
# os-user-shell.ts:34 deploys it to every member's Linux account — so it is not
|
||||
# machine-setup's private file. The other two joined it so there is one answer to
|
||||
# "where do the dotfile templates live".
|
||||
#
|
||||
# No leading dot on any of them: templates in a repository, not dotfiles in a
|
||||
# home directory. src/servers/shell-skel/zshrc has been spelled that way all
|
||||
# along.
|
||||
#
|
||||
# `[open]` TOMORROW. There are now two zshrc templates — this one for the owner
|
||||
# and shell-skel/zshrc for members — while starship.toml is deliberately ONE file
|
||||
# for both audiences. Either the owner genuinely needs different shell config
|
||||
# from a member, or these should be the same file the way starship is. The tmux
|
||||
# config has the same question waiting, since it is going into provisioning too.
|
||||
#
|
||||
# ── The one thing worth keeping when this is filled in ──
|
||||
#
|
||||
# shell-skel/zshrc depends on nothing but zsh: starship, eza, nvim and bun are
|
||||
# each used only if present, so the same file works on a minimal VPS and on a
|
||||
# fully equipped workstation. Worth holding to here, since this file will be read
|
||||
# on machines that have had none of the optional sections run.
|
||||
@@ -5,6 +5,10 @@ import { useAuth } from 'hooks/useAuth';
|
||||
import { useServerSettings } from 'state/useServerSettings';
|
||||
import { useServerEnvironment } from 'state/useServerEnvironment';
|
||||
import { useInitialData } from '@/state/useInitialData';
|
||||
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
|
||||
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
|
||||
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
|
||||
import { plugins as installedPlugins } from './Plugins.gen';
|
||||
|
||||
export function App() {
|
||||
const { isLoading, isAuthenticated } = useAuth();
|
||||
@@ -53,17 +57,33 @@ export function App() {
|
||||
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
|
||||
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||
{/* Splat, because the folder is the URL now: `/files/Tests/test folder`. The bare `/files`
|
||||
still matches with an empty splat and means home, so every existing link and the dock
|
||||
entry keep working. Only DIRECTORIES live here — an open file stays `?view=`, since it is
|
||||
what is on top of you rather than where you are. */}
|
||||
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
|
||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
<Route path="/music" element={<Dashboard.MusicScreen />} />
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||
{installedPlugins.flatMap((plugin) => [
|
||||
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
// The section pair, exactly as the core screens do it (`/headscale/:section`): the plugin's
|
||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||
// passed between them.
|
||||
<Route
|
||||
key={`${plugin.appName}-section`}
|
||||
path={`${plugin.route}/:section`}
|
||||
element={<Dashboard.PluginScreen {...plugin} />}
|
||||
/>,
|
||||
])}
|
||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
@@ -91,8 +111,6 @@ export function App() {
|
||||
<Route path="/tasks/:dirName" element={<Dashboard.Tasks />} />
|
||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||
<Route path="/processes/:dirName" element={<Dashboard.Processes />} />
|
||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/task-logs/:id" element={<Dashboard.TaskLogs />} />
|
||||
<Route path="/jobs" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ResetPassword() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
||||
<Card className={cn('flex flex-col gap-6', hideform && 'hidden')}>
|
||||
<div className="text-center">
|
||||
<div className="text-duck-dark text-2xl font-bold">Reset Password</div>
|
||||
<div className="text-duck-dark/60">Enter your new password</div>
|
||||
|
||||
@@ -105,4 +105,3 @@ const validateForm = (state: Partial<LoginFormState>) => {
|
||||
if (!email || !password) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ export function AuthenticationLayout({ children }: AuthenticationLayoutProps) {
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
<Background />
|
||||
<div className="absolute inset-0 z-20">
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">
|
||||
{children}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-8 md:pb-12">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user