Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aaea6bfcc | ||
|
|
b9fce2aeb6 | ||
|
|
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 |
+38
-17
@@ -1,18 +1,39 @@
|
||||
# What officer-setup writes. Everything below this block is optional, or is on its way out.
|
||||
PORT=9000
|
||||
JWT_SECRET="<generate with: openssl rand -base64 32>"
|
||||
BROWSER_RELAY_PORT=18792
|
||||
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
||||
MAIL_TRANSPORT="smtp://localhost:1025"
|
||||
PUBLIC_URL=http://localhost:9000
|
||||
|
||||
# ── Moving to the secret store ─────────────────────────────────────────────────────────────────
|
||||
# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without
|
||||
# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key
|
||||
# store (docs/secret-store.md), which is designed and not yet built, so an install made by the
|
||||
# current script will not boot until it is. That is deliberate sequencing, not an oversight.
|
||||
JWT_SECRET="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key,
|
||||
# encrypting every secret column in Postgres: Headscale admin API keys, app-store service
|
||||
# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the
|
||||
# owner passphrase that seals it.
|
||||
#
|
||||
# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the
|
||||
# passphrase opens the inner envelope and this is the outer one.
|
||||
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
||||
# Where Officer is reached from a browser. Read by origin validation, the task API host check, and
|
||||
# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https.
|
||||
# PUBLIC_URL=https://officer.example.com
|
||||
|
||||
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
||||
# "dev" or "development". Leave it unset or set it to "production" for a real deployment; only set
|
||||
# it to "dev" on a local machine you trust, since that disables all three.
|
||||
PUBLIC_BUILD_ENV=production
|
||||
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
|
||||
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
|
||||
# only loads this file, so `bun dev` against a production .env runs fully hardened.
|
||||
# PUBLIC_BUILD_ENV=dev
|
||||
|
||||
DATA_PATH=/path/to/data
|
||||
OFFICER_ITEMS_DIR=/path/to/officer-items
|
||||
HOME_DIR=/home/user
|
||||
BROWSER_RELAY_PORT=18792
|
||||
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR were here until 2026-08-12 and are no longer read.
|
||||
# The install root is derived as the parent of the working directory (src/servers/data-path.ts), so
|
||||
# data/, capabilities/ and dockers/ follow from it; the owner's home comes from the OS. Three values
|
||||
# that had to agree with each other and with the disk became one that cannot disagree.
|
||||
|
||||
# ── Sidecars ────────────────────────────────────────────────────────────────────────────────────
|
||||
# Each sidecar owns its upstream's credentials; the platform API is only a thin auth+forward proxy
|
||||
@@ -31,14 +52,14 @@ BROWSER_RELAY_PORT=18792
|
||||
# daemon URL and its API key live encrypted in `service_connections`; the sidecar injects the key as
|
||||
# X-API-Key on every forwarded request.
|
||||
|
||||
# Vaultwarden (officer-vault). VAULT_STORE_KEY encrypts stored secrets at rest — any strong secret
|
||||
# of 16+ chars works, and CHANGING IT MAKES EXISTING STORED SECRETS UNREADABLE.
|
||||
VAULTWARDEN_URL=http://127.0.0.1:8222
|
||||
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
||||
# Vaultwarden (officer-vault). VAULT_STORE_KEY is at the top of this file — it is the platform's
|
||||
# key, not Vaultwarden's, however much the name and its old position here suggested otherwise.
|
||||
# VAULTWARDEN_URL=http://127.0.0.1:8222
|
||||
|
||||
# Anthropic proxy (officer-anthropic-proxy). Defaults to 5051; it holds the API credential, which
|
||||
# lives in the host env rather than here.
|
||||
# ANTHROPIC_PROXY_PORT=5051
|
||||
# The Anthropic proxy (officer-anthropic-proxy) binds PORT + 1, derived rather than configured — see
|
||||
# src/servers/officer-url.mjs. There is nothing to set. It holds no credential from this file either:
|
||||
# the upstream token is the OAuth one `claude` writes to ~/.claude/.credentials.json, and the
|
||||
# ANTHROPIC_API_KEY the agent presents to it is the proxy's own generated secret.
|
||||
|
||||
# ReClip — the self-hosted yt-dlp service the download-media capability talks to. Defaults to
|
||||
# http://localhost:8899.
|
||||
|
||||
@@ -51,3 +51,10 @@ src/apps/officer-web/index.gen.html
|
||||
|
||||
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
|
||||
public/plugins/
|
||||
|
||||
# Written by machine-setup.sh to record completed steps; per-machine, never shared.
|
||||
.setup-progress
|
||||
.setup-answers
|
||||
|
||||
# Written by officer-setup.sh; per-machine.
|
||||
scripts/setup/officer-setup/.setup-progress
|
||||
|
||||
@@ -72,7 +72,7 @@ src/
|
||||
│ └── landing/ # marketing landing page
|
||||
├── servers/
|
||||
│ ├── hono.ts # router composition; everything under /api
|
||||
│ ├── _middlewares/ # auth, body parsing, origin validation, rate limiting
|
||||
│ ├── _middlewares/ # auth, body parsing, the capability gate, rate limiting
|
||||
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||
│ ├── queue/ # background job engine
|
||||
@@ -92,7 +92,15 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Bun (Node 22 is enforced by a `preinstall` check)
|
||||
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
|
||||
|
||||
That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build
|
||||
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
|
||||
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
|
||||
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
|
||||
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
|
||||
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
||||
first. The source build also needs `build-essential` and `python3`.
|
||||
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
||||
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
||||
- **Backend**: Hono
|
||||
@@ -134,14 +142,14 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
|
||||
## Security Model
|
||||
|
||||
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
||||
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
|
||||
limiting and password rules all key off it — they fail closed.
|
||||
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
|
||||
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
|
||||
- **`ALLOW_ANY_ORIGIN` defaults to ON** — origin checking is off unless the var is explicitly `false`.
|
||||
A deliberate inversion of the usual rule, safe only because the perimeter is the tailnet and a valid
|
||||
token is still required on every protected route. It is defence in depth that is currently switched
|
||||
off, not the lock.
|
||||
`dev`/`development`. Everything else, including unset, is hardened. Rate limiting and password
|
||||
rules key off it — they fail closed.
|
||||
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
|
||||
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
|
||||
what came out was documented defence in depth that was already switched off. Origin was never
|
||||
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
|
||||
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
|
||||
is a valid token on every protected route plus the capability gate below.
|
||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||
@@ -151,7 +159,7 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
|
||||
### Capabilities — read this before mounting a router
|
||||
|
||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||
valid"). It is `originScopeMiddleware` → `capabilities/authorize.ts`, mounted globally in `hono.ts`
|
||||
valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`, mounted globally in `hono.ts`
|
||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||
`userMiddleware`.
|
||||
|
||||
@@ -379,6 +387,9 @@ explaining why it was safe.
|
||||
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
||||
persistence key families. Read before building a panel app. Its defect list is
|
||||
`docs/workspace-panel-todo.md`.
|
||||
- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env`
|
||||
into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin
|
||||
split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins
|
||||
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
|
||||
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
|
||||
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
|
||||
|
||||
@@ -14,24 +14,38 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Multi-user
|
||||
|
||||
- [ ] **`deprovisionOsAccount` does not exist, and deleting a member leaves their whole Linux side.**
|
||||
`deleteUserHandler` removes the row and cascades the database; `userdel` never runs. Observed on the
|
||||
production host on 2026-08-12: a member deleted through the UI kept a working login shell, a running
|
||||
Postgres container and 454M of data, and their uid was free for the next `useradd` to reissue. Spec in
|
||||
`docs/deprovision-os-account.md`. The ordering that matters: reap processes explicitly (`terminate-user`
|
||||
does **not** reap a stale shell, and `userdel` fails while one lives), then `chown -R` to the service
|
||||
user, then `userdel` — sever before release, and abort if the `chown` fails.
|
||||
- [ ] **`deprovisionOsAccount` is written but has never run against a real account.** Landed 2026-08-12 in
|
||||
`os-user-deprovision.ts` and wired into `deleteUserHandler`, which now refuses to delete the row when
|
||||
the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards
|
||||
(`guardDeletable`, `guardMemberTree`, `parseSubUidEntry`) have tests; the reap loop, the `chown -R`
|
||||
sever and `userdel` have been exercised by nobody. `docs/deprovision-os-account.md` → "What is still
|
||||
unproven" has the five-step validation, and it has to happen on the production host with a throwaway
|
||||
account that has **a shell left open** and **a container writing as a non-root user** — those are the
|
||||
two cases the quiet path passes vacuously.
|
||||
|
||||
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
|
||||
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
|
||||
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
|
||||
`appendBuffer`, so a replay reproduces output and never re-issues requests.
|
||||
|
||||
- [ ] **The web terminal renders a long URL as unreadable fragments.** Claude Code's first-run login prints
|
||||
a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing
|
||||
selectable. Half worked around by `2a8f004` (OSC 52, so "press c to copy" reaches the clipboard) — the
|
||||
rendering itself is undiagnosed. This is every new member's first five minutes.
|
||||
`docs/open-threads-after-per-user-claude.md` §1 has what is known and where to start.
|
||||
|
||||
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
|
||||
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
|
||||
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
|
||||
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
|
||||
missing property.
|
||||
missing property. `docs/open-threads-after-per-user-claude.md` §2.
|
||||
|
||||
- [ ] **`ProcessTransport is not ready for writing` — survivable since `8c4f150`, still unexplained.** A
|
||||
floating rejection inside the SDK's own input pump, with no frames from our code, so no `await` of ours
|
||||
can catch it. It crashed `officer-agent` four times on 2026-08-11, once truncating a turn mid-sentence;
|
||||
the `unhandledRejection` backstop has caught it once since. Best hypothesis is the `claude` CLI exiting
|
||||
while `streamInput` is still pumping. It needs looking at after the next occurrence, not catching in the
|
||||
act — markers to grep in `docs/open-threads-after-per-user-claude.md` §3.
|
||||
|
||||
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
|
||||
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# Waits: how an agent waits for something without burning context
|
||||
|
||||
**Status:** draft, 2026-08-13. One mechanism proven (git remote polling, run twice); everything else here is
|
||||
specification and reasoning. Claims are marked **measured** or **reasoned** — do not let that slip.
|
||||
|
||||
`docs/two-agent-field-report-2026-08-12.md` describes this for one purpose: one agent waiting on another's
|
||||
push. That was where it was discovered, not where it belongs. This file is about the primitive itself,
|
||||
because the same shape answers "wait for CI", "wait for the job to finish", "wait for the container to go
|
||||
healthy", "wait for a reply", and a dozen other things Officer already needs.
|
||||
|
||||
---
|
||||
|
||||
## The primitive
|
||||
|
||||
> A **wait** is a harness-owned process that blocks until a condition holds, then exits — and whose exit
|
||||
> re-invokes the agent.
|
||||
|
||||
Three properties. Drop any one and it breaks in a way that is not visible from watching it run:
|
||||
|
||||
1. **The waiting happens below the model.** No inference per tick. The agent is suspended.
|
||||
2. **The harness owns the process**, so its exit is an event the harness delivers. A process the harness is
|
||||
not tracking can finish perfectly and tell nobody.
|
||||
3. **It exits when it has something to say.** The exit *is* the notification. A wait that detects and keeps
|
||||
running has informed no one.
|
||||
|
||||
Everything below follows from those three.
|
||||
|
||||
---
|
||||
|
||||
## The cost model, which decides everything else
|
||||
|
||||
This is the part that is easy to get half-right, and half-right is what leads people to build the expensive
|
||||
version.
|
||||
|
||||
| | cost |
|
||||
|---|---|
|
||||
| a tick while waiting | **nothing** — no model runs |
|
||||
| a thousand ticks | **nothing** |
|
||||
| **each wake** | a full context read, uncached |
|
||||
|
||||
**Measured** (field report, 2026-08-12): an idle watcher produced 85 bytes over seven minutes with zero
|
||||
inference. **Measured** tonight: two fires, each costing exactly one wake.
|
||||
|
||||
**Reasoned, and the part usually missed:** a wake re-reads the entire conversation, and conversations only
|
||||
grow. So the cost of a wait is not `duration` — it is `fires × context-at-the-time`. Idle is free forever;
|
||||
the tenth notification in a long session costs several times the first.
|
||||
|
||||
Worse, waits are the exact workload the prompt cache cannot help. The TTL is about five minutes; anything
|
||||
worth waiting for takes longer than that. **Every wake is an uncached read, by construction.**
|
||||
|
||||
Two consequences that should drive design:
|
||||
|
||||
- **Say less on wake.** The output that survives to the wake enters the context permanently. One line per
|
||||
tick over 24h is 2,880 lines that land at once and then stay.
|
||||
- **Prefer many short sessions to one long one.** A wait in a fresh session costs a constant amount per
|
||||
event. The same wait in an immortal session costs monotonically more. This is the single strongest
|
||||
argument for event-driven agents over resident ones.
|
||||
|
||||
---
|
||||
|
||||
## Prefer blocking over polling. Prefer events over both.
|
||||
|
||||
The git watcher polls because a git remote can only be *asked*. Most things Officer waits on are not like
|
||||
that, and a poll is the worst of the three options that usually exist.
|
||||
|
||||
**Tier 1 — block on the kernel.** Zero syscalls while waiting, and detection is immediate rather than
|
||||
average-half-an-interval late.
|
||||
|
||||
| waiting for | how to block |
|
||||
|---|---|
|
||||
| a file appearing or changing | `inotifywait -q -e close_write,create,moved_to <path>` |
|
||||
| a process to exit | `tail --pid=<pid> -f /dev/null` |
|
||||
| a lock to release | `flock <file> true` |
|
||||
| a line on a pipe or log | `read -r line < <fifo>` |
|
||||
| an inbound HTTP callback | a listener that blocks on `accept()` |
|
||||
| whichever of several finishes first | `wait -n` over background pids |
|
||||
|
||||
**Tier 2 — block on the service.** Some services will hold a connection open and tell you.
|
||||
|
||||
| waiting for | how |
|
||||
|---|---|
|
||||
| a row to change | Postgres `LISTEN` / `NOTIFY` — the connection blocks, the database pushes |
|
||||
| new mail | IMAP `IDLE` |
|
||||
| a container to change state | `docker events --filter …` (streams, blocks) |
|
||||
| a systemd unit | `systemctl --wait` / journal follow |
|
||||
|
||||
Officer keeps almost everything in one Postgres. `LISTEN`/`NOTIFY` is therefore the highest-leverage
|
||||
unbuilt piece here: job completion, a new chat message, a status flip, all become blocking waits with no
|
||||
polling anywhere.
|
||||
|
||||
**Tier 3 — poll, because the source can only be asked.** A git remote, a third-party HTTP API, a health
|
||||
endpoint. Then the rules are: read-only calls (`git ls-remote`, never `git fetch` — a fetch mutates refs
|
||||
under a working tree that may be mid-edit), a `timeout` on every call so a hung network call cannot leave
|
||||
the wait alive and blind, and an interval matched to how fast the thing actually changes.
|
||||
|
||||
**Never poll in the model.** A scheduled wake-up, a `/loop 30s`, a "check every minute" — these are the same
|
||||
shape wearing the same clothes and they pay a full uncached context read *per tick* to learn nothing. This
|
||||
is the intuitive design and its expense is invisible, which is why it needs saying first.
|
||||
|
||||
---
|
||||
|
||||
## Make firing mean something
|
||||
|
||||
The rest of this file is about how to wait cheaply. This section is about the other half, and it is the one
|
||||
that decides whether a fleet of these is affordable.
|
||||
|
||||
**Most waits find nothing, almost always.** A daily release check answers "no" 360 days a year. A branch
|
||||
watcher wakes on every push, including everyone else's. So the number that matters is not the cost of a
|
||||
useful wake — it is the cost of a useless one, multiplied by how many there will be.
|
||||
|
||||
The fix is not a cheaper wake. It is to **push the relevance test into the wait condition**, so that firing
|
||||
already implies relevance:
|
||||
|
||||
- **Do not** wait on "a push", then wake and check whether it carries a `COMMS/<branch>/NN-*.md`. Wait on a
|
||||
push *that contains one* — a filename test the shell can do with no model at all.
|
||||
- **Do not** wait on "the releases page changed", then wake and read it. Wait on "the version string differs
|
||||
from my cursor" — a string compare.
|
||||
|
||||
Three tiers, and almost everything should die at the first:
|
||||
|
||||
| tier | cost | for |
|
||||
|---|---|---|
|
||||
| **shell condition** | zero | anything expressible as a filename, a diff, a version, a status |
|
||||
| **fresh minimal agent** | one small cold read | relevance genuinely needs judgement, but not history |
|
||||
| **escalate with real context** | a full read of a long session | the event has to be interpreted against what came before |
|
||||
|
||||
A session fork that inherits context but returns nothing to it (Claude Code's `/btw`) is tier two done well.
|
||||
It is still a context read, so it is the fallback when a shell test cannot express relevance — not the
|
||||
default.
|
||||
|
||||
**Corollary for the platform:** a wait's condition should be part of its declaration, not something the agent
|
||||
evaluates after waking. `wait for: push to <branch> touching COMMS/**` is a cheaper and more honest thing to
|
||||
build than `wait for: push` plus an agent that decides.
|
||||
|
||||
## The contract a wait must honour
|
||||
|
||||
Specification. None of this is built yet.
|
||||
|
||||
**Exit codes are the vocabulary.**
|
||||
|
||||
```
|
||||
0 fired — the condition holds; payload on stdout
|
||||
1 timed out — the bounded lifetime elapsed, nothing happened
|
||||
2 broke — the wait itself failed and is no longer trustworthy
|
||||
```
|
||||
|
||||
`1` and `2` must be distinguishable. "Nothing happened" and "I stopped being able to tell" are opposite
|
||||
facts and a wait that conflates them is worse than no wait, because absence reads as reassurance.
|
||||
|
||||
**Output is a payload, not a log.** One line on arm so there is a record of what was watched; silence while
|
||||
waiting; a minimal structured payload on fire. Everything printed is permanent context.
|
||||
|
||||
**A cursor, persisted.** The wait is armed at a position — a SHA, a byte offset, a row id, a timestamp — and
|
||||
that position belongs on disk, not only in the process. Then a re-arm after a restart neither misses events
|
||||
nor re-reports old ones. The git watcher currently holds its base only in memory, which is why a session
|
||||
restart loses the thread.
|
||||
|
||||
**Bounded lifetime, and the bound is not "forever".** `seq 1 2880` is a runaway backstop, not a policy. A
|
||||
wait that times out should re-arm from its cursor rather than die silently.
|
||||
|
||||
**Liveness must be externally checkable.** A dead wait and a quiet one are indistinguishable, and that
|
||||
ambiguity has already cost two missed pushes. Cheapest fix: touch a heartbeat file each tick, so `mtime`
|
||||
answers "is it alive" without asking the process. In a UI that shows running processes — as Officer's chat
|
||||
does — the chip itself is the signal, which is a real advantage and should be kept.
|
||||
|
||||
**Idempotent re-arm, and self-trip protection.** An agent that acts and then wakes on its own action is a
|
||||
loop. Re-arm from the position *after* your own change, and never run two waits on the same condition.
|
||||
|
||||
---
|
||||
|
||||
## Where this applies in Officer
|
||||
|
||||
The reason to generalise. Each of these is a place something currently either blocks a turn, gets polled by
|
||||
a human, or is discovered late.
|
||||
|
||||
| wait | tier | notes |
|
||||
|---|---|---|
|
||||
| a pipeline/script job finishes | 1 or 2 | `data/jobs/<id>.log` is a file — inotify. Or `NOTIFY` on the row |
|
||||
| a download completes | 1 | same, and the progress sentinel already exists |
|
||||
| a container becomes healthy | 2 | `docker events` |
|
||||
| a member logs into `claude` for the first time | 1 | `~/.claude/.credentials.json` appearing — currently polled by `/agent-status` |
|
||||
| new mail arrives | 2 | IMAP IDLE, in the email sidecar |
|
||||
| CI, a deploy, a remote build | 3 | poll, with a timeout |
|
||||
| a push to any repo | 3 today, **event tomorrow** | Gitea is ours: a webhook removes the wait entirely |
|
||||
| a long `db:push` or migration finishes | 1 | process wait |
|
||||
| disk crosses a threshold | 3 | slow-moving; poll infrequently |
|
||||
| **a human replies** | 1 | an approval gate: the agent arms a wait and stops costing anything until answered |
|
||||
|
||||
That last row is the one worth dwelling on. An agent that needs a decision currently either blocks a session
|
||||
or asks and forgets. A wait makes "stopped, pending your answer" cost nothing while it lasts.
|
||||
|
||||
---
|
||||
|
||||
## Choosing a lifetime
|
||||
|
||||
| shape | when | cost |
|
||||
|---|---|---|
|
||||
| **wait inside a live session** | the agent holds context the event needs interpreting against | free while idle, growing per fire |
|
||||
| **wait, then hand off** | context matters up to the fire, not after | one growing session, then reset |
|
||||
| **no wait — event spawns a fresh agent** | the event carries everything needed (a SHA, a job id) | constant per event, forever |
|
||||
|
||||
The third is the destination for anything recurring. The first is right for tonight's watcher, where the
|
||||
value is that I already know what the commits mean.
|
||||
|
||||
The rule: **if the payload plus the repo is enough to act on, do not keep a session alive to receive it.**
|
||||
|
||||
---
|
||||
|
||||
## Failure modes
|
||||
|
||||
| pattern | what it looks like |
|
||||
|---|---|
|
||||
| **launched outside the harness** | `nohup … &` — runs, detects, exits, and no one is told. Looks perfect |
|
||||
| **model-driven poll** | correct behaviour, full context read per tick |
|
||||
| **detects but does not exit** | prints "found it" into a file nobody reads |
|
||||
| **chatty** | per-tick output, deferred, all landing at once on wake |
|
||||
| **silent death** | session restarts, wait dies, quiet branch and dead watcher look identical |
|
||||
| **self-trip** | agent's own push wakes it, usually because an old wait was never stopped |
|
||||
| **timeout mistaken for quiet** | exit 1 treated as "nothing happened" when it means "I stopped looking" |
|
||||
| **mutating poll** | `git fetch` in a loop, moving refs under a working tree |
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Is a wait a platform feature or an agent habit?** Officer has a job runner, a Gitea instance and a
|
||||
sidecar pattern. `POST /waits {condition, payload}` returning when it fires is a plausible platform
|
||||
primitive — and would make waits available to capabilities, not only to agents.
|
||||
2. **What arms a wait for an agent that is not running?** The webhook shape needs the platform to spawn the
|
||||
agent, which is `send-claude-code` plus a trigger. Most of that exists.
|
||||
3. **Should waits be declarative?** `wait for: file:<path>` / `pg:notify:<channel>` / `git:<remote>/<branch>`
|
||||
— a small vocabulary compiled to the right tier, so nobody hand-writes a poll for something inotify could
|
||||
have blocked on.
|
||||
4. **How does a wait survive a session restart** without either missing its event or re-firing on an old
|
||||
one? The cursor answers half of it; the other half is who re-arms.
|
||||
5. **What is the right granularity of notification?** One wake per push, or one wake per batch after a quiet
|
||||
period? Batching trades latency for context, and context is the scarce thing.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
The mechanism, the three properties and the four wrong ways to launch it come from
|
||||
`docs/two-agent-field-report-2026-08-12.md`, which recorded them after they were learned the hard way. What
|
||||
this file adds is the cost model stated as a formula rather than an anecdote, the block-over-poll hierarchy,
|
||||
the exit-code contract, and the argument that the destination is event-spawned short-lived agents rather
|
||||
than resident ones.
|
||||
|
||||
Nothing in "the contract" or "where this applies" has been implemented. The only thing running today is a
|
||||
tier-3 git poll, which is the good version of the wrong shape.
|
||||
@@ -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.
|
||||
|
||||
@@ -213,7 +213,8 @@ both, so the endpoint cannot be used to discover whether an id exists.
|
||||
|
||||
## Things that will surprise you
|
||||
|
||||
- **No `Origin` header is needed today.** `ALLOW_ANY_ORIGIN` defaults to on, so origin checking is off
|
||||
- **No `Origin` header is needed.** Origin checking was removed entirely on 2026-08-13; before that it
|
||||
was off by default
|
||||
and the apps work sending none — which is what they do. Nothing here changes that. If it is ever
|
||||
switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that
|
||||
is a separate conversation, not part of this work.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Open threads after per-user Claude
|
||||
|
||||
Three things found on 2026-08-11/12 that are understood but not finished. They were written up in the
|
||||
`COMMS/sidecar-app-store` channel, which was deleted when the feature merged — this file is what survives.
|
||||
None of them blocks per-user Claude; all three were found while proving it worked.
|
||||
|
||||
---
|
||||
|
||||
## 1. The web terminal renders a long URL unreadably
|
||||
|
||||
**Half fixed.** `2a8f0049` added an OSC 52 handler, so a program's "press `c` to copy" now reaches the
|
||||
browser clipboard. That is the path a user is meant to take, and it works.
|
||||
|
||||
**Not fixed:** the URL itself renders as fragments. Claude Code's first-run login prints an OAuth URL of
|
||||
~400 characters; in the web terminal it appeared as scattered characters with large gaps (`h : l`), with
|
||||
nothing selectable or readable. On a normal terminal the same output wraps and reads fine.
|
||||
|
||||
Why it matters: first-run login is every new member's first five minutes, and the workaround was running
|
||||
`claude` under `tmux` on the server, capturing the pane, and reassembling the URL by hand across three wrapped
|
||||
lines. That is not something a member can be asked to do, and without OSC 52 there was no other way out.
|
||||
|
||||
Not diagnosed. What is known:
|
||||
|
||||
- the frontend loads `FitAddon`, `Unicode11Addon` and `WebLinksAddon`, and `allowProposedApi` is on
|
||||
- `cols`/`rows` are sent on connect (`Terminal.tsx`) and on resize, so it is not obviously a sizing problem
|
||||
- the pty gives `cols: Number(...) || 0` (`sidecar/pty/server.mjs:62`), so a client that omits them yields 0
|
||||
|
||||
Where I would start: capture the raw bytes the pty emits for that line and compare against what xterm renders.
|
||||
Either the TUI is positioning with escapes xterm handles differently, or the width the program believes it has
|
||||
disagrees with the width the terminal has.
|
||||
|
||||
## 2. Agent sessions do not survive a restart — one property behind three symptoms
|
||||
|
||||
Worth fixing as one thing, because it currently presents as three and invites three separate fixes:
|
||||
|
||||
- **Blast radius.** An unhandled rejection used to kill the agent sidecar and every live session with it.
|
||||
`8c4f150c` made that survivable, but any *real* restart still loses every session.
|
||||
- **The restart sweep must skip.** `endTurnIfAgentIsGone` asks the agent whether a session is really still
|
||||
generating. Scoped by `userId` since `d59adbf1`, so a session with no recorded `userId` has no safe identity
|
||||
to ask as and is skipped — correct, and it leaves that session marked generating.
|
||||
- **Stuck "generating".** The user-visible face of the above. A spinner that never resolves after an agent
|
||||
restart is this, not the UI.
|
||||
|
||||
The missing property is that a session does not survive a restart with its identity intact. Given that, the
|
||||
sweep would not need to skip, a restart would be an inconvenience rather than a loss, and the spinner would
|
||||
resolve itself.
|
||||
|
||||
## 3. `ProcessTransport is not ready for writing` — survivable, still unexplained
|
||||
|
||||
```
|
||||
error: ProcessTransport is not ready for writing
|
||||
at write (…/claude-agent-sdk/sdk.mjs)
|
||||
at streamInput (…/claude-agent-sdk/sdk.mjs)
|
||||
```
|
||||
|
||||
Four fatal crashes on 2026-08-11, one of which truncated a turn mid-sentence. There are **no frames from our
|
||||
code** — it is a floating rejection inside the SDK's own input pump, so no `await` of ours can catch it. With
|
||||
no handler registered it reached the top level and Bun exited, taking every session on the machine.
|
||||
|
||||
`8c4f150c` registered an `unhandledRejection` handler in `sidecar/claude/user-instance.ts`, which is the
|
||||
process `ecosystem.config.cjs` starts as `officer-agent`. Verified on Bun 1.3.9: the handler fires and the
|
||||
process survives. It has fired once in production since.
|
||||
|
||||
**The cause is still unknown.** Best hypothesis: the `claude` CLI exits while `streamInput` is still pumping,
|
||||
so the transport's `ready` flips false mid-write. Unconfirmed.
|
||||
|
||||
It no longer needs to be caught in the act — it needs someone to look after it happens. The next occurrence
|
||||
logs a full rejection in a *live* process with every other session still attached, which is a much better
|
||||
vantage point than a corpse.
|
||||
|
||||
Markers in `~/.pm2/logs/officer-agent-error.log`:
|
||||
|
||||
```
|
||||
grep -c 'Bun v1.3' → fatal exits. Was 4. A fifth means the backstop stopped working.
|
||||
grep -c 'UNHANDLED REJECTION' → caught and survived. Was 1.
|
||||
```
|
||||
|
||||
`uncaughtException` is deliberately not handled the same way: a rejection leaves the process's state intact,
|
||||
whereas a synchronous throw that unwound to the top supports no such claim, and continuing on a possibly
|
||||
corrupted heap is worse than restarting. That asymmetry is an argument for §2 rather than against itself.
|
||||
@@ -146,7 +146,7 @@ test (`os-user.test.ts` → "does not pass the platform environment through").
|
||||
sudoers *policy* — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
||||
shell" must not depend on a config file someone may have edited.
|
||||
|
||||
Root is available: `scripts/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
|
||||
Root is available: `scripts/setup/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
|
||||
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
|
||||
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
|
||||
rule anyway.
|
||||
@@ -279,7 +279,12 @@ Three bugs surfaced only by running it:
|
||||
also *unreachable*.
|
||||
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
|
||||
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
|
||||
start with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable.
|
||||
start while any `.env` in the project root is group- or world-readable.
|
||||
|
||||
That check was itself conditional on `OFFICER_OS_USERS` until 2026-08-12, which meant the guarantee was
|
||||
opt-in. The flag is gone and the check is unconditional: a security prerequisite that only holds when
|
||||
somebody remembers to set a variable is not a prerequisite. Per-user Linux accounts are now simply what
|
||||
the platform does, so there is nothing to enable and nothing to forget.
|
||||
|
||||
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
|
||||
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# The secret store
|
||||
|
||||
**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the
|
||||
current code was checked against the tree on that date; the file:line references are live.
|
||||
|
||||
A small SQLite database, created during setup, holding every encryption and signing key the platform
|
||||
uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses
|
||||
instead of inventing its own.
|
||||
|
||||
---
|
||||
|
||||
## What is wrong with today
|
||||
|
||||
Nothing is insecure. The separation is already right — the thing worth keeping is stated first so it is
|
||||
not lost in a refactor:
|
||||
|
||||
> **Secrets live in Postgres. The key that opens them does not.**
|
||||
|
||||
That is why `officer_db/src/crypto.ts` reads `VAULT_STORE_KEY` from the environment, and it is what
|
||||
makes `keys.ts:26` true: *"a stolen database dump is useless without .env, a stolen .env is useless
|
||||
without the passphrase"*.
|
||||
|
||||
What is wrong is narrower, and it is about **blast radius across processes**.
|
||||
|
||||
`.env` sits in the repository root, and Bun auto-loads it. `ecosystem.config.cjs` says so in as many
|
||||
words — it is the reason the Anthropic credential was moved out of the main process. So today
|
||||
`VAULT_STORE_KEY` is present in the environment of **all twenty pm2 processes**. `officer-music` holds
|
||||
the key that decrypts wallet seed envelopes. Anything that can read `/proc/<pid>/environ` for those
|
||||
processes has it, and nineteen of them have no reason to.
|
||||
|
||||
The second problem is that changing the key is currently unrecoverable rather than an operation. See
|
||||
[Rotation](#rotation).
|
||||
|
||||
---
|
||||
|
||||
## What the key actually protects
|
||||
|
||||
Worth listing, because it is wider than the name suggests. Everything below is AES-256-GCM ciphertext in
|
||||
Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`:
|
||||
|
||||
| column | what it is |
|
||||
| --- | --- |
|
||||
| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" |
|
||||
| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission |
|
||||
| `jellyfin_servers.access_token` | Jellyfin session token |
|
||||
| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority |
|
||||
| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key |
|
||||
|
||||
`decryptSecret` throws when the key does not verify, so a wrong key is not a degraded mode — it is every
|
||||
one of those becoming unreadable at once.
|
||||
|
||||
The seed envelope is the only one protected by a second, independent secret (the owner passphrase, never
|
||||
persisted). Everything else in that table has exactly one lock.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. The store is SQLite, in the install, outside Postgres
|
||||
|
||||
Keys cannot live in the database they unlock. A dump would then contain both the ciphertext and the
|
||||
thing that opens it, and the property quoted at the top stops being true. Encrypting the key with a
|
||||
second key only moves the question — eventually exactly one secret has to be readable without any other
|
||||
secret, and the only real decision is *where it lives*.
|
||||
|
||||
SQLite rather than a flat file, for one reason that is not secrecy: **rotation needs key versions.** A
|
||||
rotation has to decrypt with the old key and re-encrypt with the new, and an interrupted rotation needs
|
||||
both to still exist. That is a table with `id, purpose, key, created_at, retired_at`, and it is awkward
|
||||
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
|
||||
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
|
||||
|
||||
### 2. It is NOT encrypted at rest, for now
|
||||
|
||||
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
|
||||
|
||||
```
|
||||
$ bun --eval 'db.exec("PRAGMA key = \"supersecret\""); … insert …'
|
||||
read without key: THE-SECRET-VALUE
|
||||
strings enc.db | grep THE-SECRET-VALUE -> found
|
||||
```
|
||||
|
||||
Stock SQLite **silently ignores unknown pragmas**, so `PRAGMA key` succeeds, encrypts nothing, and the
|
||||
value sits in the file in plaintext. `bun:sqlite` ships stock SQLite 3.53.0, not SQLCipher.
|
||||
|
||||
Whole-file encryption therefore needs SQLCipher, which means a native module — and this project already
|
||||
knows what one of those costs, since node-pty has no Linux prebuild and compiles from source on every
|
||||
machine.
|
||||
|
||||
So the store holds **encrypted values in an unencrypted file**, the same shape as the Postgres columns.
|
||||
What leaks is metadata: which purposes have keys, and when they were rotated. That is an acceptable
|
||||
trade and it is written down here so nobody later assumes the file is opaque.
|
||||
|
||||
`[open]` SQLCipher, if the native-dependency cost ever becomes worth paying.
|
||||
|
||||
### 3. Where the file goes
|
||||
|
||||
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
|
||||
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
|
||||
problem this design exists to avoid.
|
||||
|
||||
`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere
|
||||
documented loudly enough that a backup script excludes it deliberately.
|
||||
|
||||
### 4. One secret remains outside
|
||||
|
||||
The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the
|
||||
whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the
|
||||
two processes that need it.**
|
||||
|
||||
`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly
|
||||
one value — or comes from a file read on demand.
|
||||
|
||||
### 5. What moves in
|
||||
|
||||
- `VAULT_STORE_KEY` — the at-rest key for everything in the table above.
|
||||
- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must
|
||||
survive restarts, must never be regenerated silently, and benefits from versioning during a rotation.
|
||||
Leaving one in a store and one in `.env` would be the scattering this is meant to end.
|
||||
- **The anthropic proxy secret**, purpose `anthropic-proxy`. Agreed 2026-08-12. Neither an encryption
|
||||
key nor a signing key — a bearer credential, generated once by `ensureProxySecret` and presented by
|
||||
`officer-agent` to `officer-anthropic-proxy` on `127.0.0.1`. It qualifies on the same three
|
||||
properties: generated once, shared between two processes, fatal to regenerate silently.
|
||||
|
||||
It is in the store for a sharper reason than the other two, though. It is not in `.env` today — it
|
||||
is in `$DATA_PATH/sidecar/claude-state.json`, mixed in with session records. That is the one
|
||||
location [decision 3](#3-where-the-file-goes) rules out by name: `DATA_PATH` is what people back up,
|
||||
so the secret already travels in the same tarball as the data it protects.
|
||||
|
||||
**Naming.** It is called `ANTHROPIC_API_KEY` in `ensureAnthropicEnv`, and that name is wrong in both
|
||||
halves — it is not Anthropic's and it is not an API key. Anthropic's real credential is the OAuth
|
||||
token in `~/.claude/.credentials.json`, which the proxy swaps this one for on the way out. Our name
|
||||
for it is **anthropic-proxy-secret** everywhere we control.
|
||||
|
||||
The exception is the last line before the spawn. `claude` reads the variable `ANTHROPIC_API_KEY` and
|
||||
format-checks the `sk-ant-api03-` prefix, so both are the CLI's contract rather than ours and both
|
||||
stay. That one assignment keeps the CLI's name, with a comment saying why.
|
||||
|
||||
---
|
||||
|
||||
## Core, and plugins
|
||||
|
||||
The store is core infrastructure, created at first boot. It is **not** a side effect of installing any
|
||||
one sidecar — it exists on a machine that installs nothing, so that a plugin installed in six months
|
||||
finds it already there.
|
||||
|
||||
The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`,
|
||||
`officer-agent`, `officer-opencode`, `officer-pty` — **plus `officer-headscale`**.
|
||||
|
||||
Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet *is* the
|
||||
perimeter — origin checking was removed on 2026-08-13 precisely because the tailnet is what stands in
|
||||
its place, so the tailnet is now load-bearing rather than one layer of two. A security model
|
||||
that rests on the tailnet cannot treat administering the tailnet as an optional extra. Vaultwarden and
|
||||
the wallet are not load-bearing that way — nothing else stops working without them — so they become
|
||||
plugins.
|
||||
|
||||
Moving headscale into the light profile also removes it from the app store automatically:
|
||||
`catalogue.test.ts` asserts the catalogue equals `full − light`, so the test fails until the entry is
|
||||
deleted. That derivation is doing its job and should not be worked around.
|
||||
|
||||
Headscale is then the store's **first user**, not its creator — `headscale_servers.api_key` is the first
|
||||
core credential needing a key.
|
||||
|
||||
---
|
||||
|
||||
## The contract
|
||||
|
||||
What a plugin gets, and is bound by. To be written properly when the first one uses it; the shape is:
|
||||
|
||||
- **Ask for a key by purpose**, not by name. `getKey('vault')` returns the active key for that purpose,
|
||||
creating one on first use.
|
||||
- **Never hold it.** Read it at the point of use. A key cached in a long-lived process is the
|
||||
process-environment problem in a different container.
|
||||
- **Never write to another plugin's purpose.** Same rule `service_connections` already has for rows.
|
||||
- **Tolerate rotation.** A key may change between two calls. Anything that decrypts must be prepared to
|
||||
be handed the retired key for data written before a rotation.
|
||||
|
||||
---
|
||||
|
||||
## Rotation
|
||||
|
||||
The feature that makes the store worth building, and the reason versions exist.
|
||||
|
||||
Today, changing `VAULT_STORE_KEY` is not an operation — it is data loss. Every column above becomes
|
||||
unreadable, and for `wallets.seed_envelope` that is unrecoverable: the owner passphrase does not help,
|
||||
because it opens the inner envelope and the outer one is gone. Unless the mnemonic was written down
|
||||
offline, the coins are gone with it.
|
||||
|
||||
Rotation turns that into a supported action:
|
||||
|
||||
1. Mint a new key for the purpose, leaving the old one in the store as retired.
|
||||
2. For every ciphertext column belonging to that purpose: decrypt with the retired key, re-encrypt with
|
||||
the new one.
|
||||
3. Retire the old key only when every row has moved.
|
||||
|
||||
Two properties it must have, both learned from the failure it replaces:
|
||||
|
||||
- **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can
|
||||
tell which rows are which.
|
||||
- **Verify before writing.** Every row must decrypt with the retired key *before* anything is written.
|
||||
A key that is already wrong should fail loudly on row one rather than produce a second layer of
|
||||
unreadable data.
|
||||
|
||||
`[open]` Whether rotation is a UI action, a CLI command, or both. It is a long operation on a large
|
||||
wallet table and it cannot be interrupted safely, which argues for something that reports progress.
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
- Secrets stay in Postgres. This moves the **keys**, not the data.
|
||||
- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from
|
||||
changes, so no caller is touched.
|
||||
- The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent
|
||||
secrets is the property that makes a stolen `.env` insufficient, and it survives this design.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Where the file lives, given it must not be swept up by a backup of `data/`.
|
||||
2. Whether the store's own key stays in `.env` or moves to a file read on demand.
|
||||
3. Whether rotation is UI, CLI, or both — and how it reports progress on a table that takes minutes.
|
||||
4. SQLCipher, and whether whole-file encryption is ever worth a second native dependency.
|
||||
5. What happens to a plugin's keys when it is uninstalled. The app store already decided that
|
||||
uninstalling never deletes data; the same answer probably applies, but "probably" is not a decision.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# Two agents on one branch: a field report
|
||||
|
||||
**What this is:** an account of 2026-08-11/12, when two agents worked the same branch for roughly ten hours
|
||||
with the owner arbitrating, and shipped per-user Claude end to end. It is evidence rather than proposal.
|
||||
|
||||
`docs/agent-coordination.md` states the objective — several agents on one body of work, *"coordinating with
|
||||
each other rather than through the human"*. That was written in theory on 2026-08-07. This is what happened
|
||||
when it ran, and the ways the theory was wrong.
|
||||
|
||||
Read it as a record of what to build, not as a design. Where something worked it says so; where it broke it
|
||||
says how, because the failures are more useful than the successes and there were more of them.
|
||||
|
||||
---
|
||||
|
||||
## The shape that emerged
|
||||
|
||||
Nobody designed this. It settled into place in the first hour and held.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Agent A** (dev machine) | wrote the platform code |
|
||||
| **Agent B** (production host) | verified against a real machine, never wrote the feature |
|
||||
| **The owner** | arbitrated, held every irreversible decision, and pushed for real tests |
|
||||
|
||||
The split was not "two reviewers are better than one". It was **the author and the verifier being different
|
||||
people**, and the mechanism is narrower than it sounds:
|
||||
|
||||
> The person who writes the sentence explaining why something is safe is the worst-placed person to notice
|
||||
> that the code disagrees with it.
|
||||
|
||||
That is not a claim about carelessness. Agent A wrote *"a wrong answer here is the one thing that must not
|
||||
happen by accident"* and shipped exactly that accident in the same commit. Agent B wrote a verification script
|
||||
that could not fail on Agent A's machine. Neither was sloppy. Each was reading their own reasoning back and
|
||||
finding it agreed with itself.
|
||||
|
||||
Re-reading your own diff does not reach this. You read the comment, agree, and move on.
|
||||
|
||||
## What each half was actually good for
|
||||
|
||||
**A machine is not a code review.** The defects split cleanly into two kinds, and the split is the most
|
||||
useful thing in this report.
|
||||
|
||||
*Found by reading, almost always by the non-author:* two environment guards that could never fire; a binary
|
||||
check comparing paths in a way that would have thrown on every turn; a credential resolver that answered "I
|
||||
don't know whose turn this is" with the owner's identity; a function whose parameter changed meaning from an
|
||||
email to a filesystem path while three callers kept passing emails, invisible to the compiler because both
|
||||
are `string`.
|
||||
|
||||
*Found only by running, and invisible to any amount of reading:* an installer piped into `sh` when it needs
|
||||
`bash`; a parent directory created `root:root` as a side effect of `install -d`; an ACL mask silently clamped
|
||||
so the file browser could not read a member's home; a chat working directory the member could not enter; ACL
|
||||
entries surviving a `chown` and granting a freed uid access to everything.
|
||||
|
||||
Every "found by running" defect appeared on a **first execution**. Provisioning a real account found three in
|
||||
twenty minutes. The first real chat turn found the cwd. The first teardown was the only thing that could have
|
||||
proved the process reaper.
|
||||
|
||||
The owner drove this repeatedly — *"I'm anxious to see this work"* — against both agents' instinct to keep
|
||||
building. That instinct was wrong every time.
|
||||
|
||||
---
|
||||
|
||||
## The communications paradigm
|
||||
|
||||
Agents coordinated through `COMMS/<branch>/` — markdown files committed to the repo, alongside the code they
|
||||
discuss, deleted when the feature merged.
|
||||
|
||||
**Why a directory in the repo and not chat.** It survives a context window. Both agents' reasoning outlived
|
||||
the sessions that produced it, a third party could read the argument rather than a summary of it, and it
|
||||
travels with the branch. Chat has none of those properties, and the owner relaying findings by hand between
|
||||
two agents at the end of long sessions is the failure this replaces.
|
||||
|
||||
### The rules, as they ended up
|
||||
|
||||
**Location and lifetime.** `COMMS/<branch-name>/`. It is a *channel*, not documentation: when the feature
|
||||
merges, the directory is deleted. Anything that will still be true in a month must be moved to `docs/` or next
|
||||
to the code **before** the merge, or it is lost. We nearly lost three findings this way and only caught it
|
||||
because someone checked.
|
||||
|
||||
**Numbered, alternating, parity is the author.** One agent takes odd numbers, the other even. `01`, `02`,
|
||||
`03`… Never "your doc" / "his doc", which inverts depending on who is reading. The parity *is* the
|
||||
attribution, and given that git could not attribute anything (see below), it was the only attribution that
|
||||
worked.
|
||||
|
||||
**Numbers are ordered, not necessarily consecutive.** An agent needing two in a row takes `03` and `05` and
|
||||
leaves `04` unused, rather than forcing a reply out of the other side to keep the count. A gap is legal and
|
||||
means "no turn was taken".
|
||||
|
||||
**The slug is the content.** `02-verify-results.md`, `24-resolvememberrun-fails-open.md`. Not `02-reply.md`.
|
||||
The filename is the index; a reader should know whether to open it without opening it.
|
||||
|
||||
**Reply in a new file. Never edit someone else's.** An edited handoff loses what was believed at the moment a
|
||||
decision was made, which is usually the thing that explains the decision.
|
||||
|
||||
**Editing your own is allowed if it has not been read** — and say so in the commit. Better than a prediction
|
||||
standing next to its own correction in two documents.
|
||||
|
||||
**Refer to commits by SHA, never by branch name.** Three remotes were in play with the same branch names on
|
||||
each; one agent's `origin` was the other's `pertento`. A SHA is the only unambiguous reference, and this cost
|
||||
real time before it was noticed.
|
||||
|
||||
### What a handoff must contain
|
||||
|
||||
This is the part that carried the most weight, and it is one rule:
|
||||
|
||||
> **State what you verified and what you assumed, separately and explicitly.**
|
||||
|
||||
A handoff that reads as confident about something untested is *worse than no handoff*, because the reader
|
||||
builds on it. Every serious mistake of the night traces back to something asserted with more confidence than
|
||||
it had been earned.
|
||||
|
||||
In practice, each document ended up with:
|
||||
|
||||
- **What changed** — with `file:line` throughout. Costs nothing to write, saves the reader a search, and
|
||||
makes a claim checkable rather than believable.
|
||||
- **VERIFIED** — what was actually run, on what, with the output.
|
||||
- **NOT VERIFIED** — stated as prominently as the verified part. `provisionClaudeCli` carried "never executed
|
||||
anywhere" through four documents, and that label is what eventually made someone run it.
|
||||
- **What I am least sure of** — the author's own suspicions. One agent listed three; the second was a real
|
||||
defect, found because it had been pointed at.
|
||||
- **What I did not do** — so nobody assumes it. "I did not restart anything", "I did not touch the gates".
|
||||
- **Open items with an owner** — see termination, below.
|
||||
|
||||
### Termination: the rule that took four attempts
|
||||
|
||||
This broke more times than anything else, so the failures are worth listing in order:
|
||||
|
||||
1. **Terminate by guess.** `NO REPLY NEEDED unless the test fails` — a prediction about content the sender had
|
||||
not seen. It ended an exchange with items open.
|
||||
2. **Terminate by politeness.** The fix — always reply, even with nothing to say — has no exit. "Nothing to
|
||||
report" obligates another "nothing to report", indefinitely, at real cost.
|
||||
3. **Terminate when the list is empty.** Too strong: the list is never empty and will not be for days.
|
||||
4. **What actually works:** *the exchange pauses when no open item is actionable by a participant.*
|
||||
|
||||
That last one is checkable rather than felt. Everything remaining is either the human's, or deferred with a
|
||||
stated reason, and either side reopens it by adding an item that is theirs.
|
||||
|
||||
**Three states, not two.** An item is `open` / `done` / **`deferred with a reason`**. Three times the honest
|
||||
answer was "mine, and not now" — and only the *reason* distinguishes that from neglect. A protocol with two
|
||||
states forces an agent to lie in one direction or the other.
|
||||
|
||||
**A stall must be detectable.** Silence and completion look identical from outside. Open items plus no
|
||||
document for N minutes is a condition a machine can watch for; silence is not. The human noticed both stalls
|
||||
before either agent did, which is the wrong way round.
|
||||
|
||||
### Ownership, which we did not have and needed
|
||||
|
||||
Late on, both agents independently wrote the *same document* — same filename, same three sections — because
|
||||
one had read the other's notes before deleting them. Pure waste, caught only by diffing the two files.
|
||||
|
||||
Nothing in the protocol said who owned a piece of work. Adding it is cheap: an open item names its owner, and
|
||||
an agent picking up an unowned item claims it in a document before starting.
|
||||
|
||||
### A skeleton to copy
|
||||
|
||||
```markdown
|
||||
# NN — <what this is about in one line>
|
||||
|
||||
Commits read: <sha>..<sha>. Answering `<NN-1>`.
|
||||
|
||||
**Verdict / what changed** — one paragraph, file:line.
|
||||
|
||||
## VERIFIED
|
||||
<what was actually run, on what, with output>
|
||||
|
||||
## NOT VERIFIED
|
||||
<stated as prominently as the above>
|
||||
|
||||
## What I am least sure of
|
||||
<your own suspicions, numbered>
|
||||
|
||||
## What I did not do
|
||||
<so nobody assumes it>
|
||||
|
||||
## Open items
|
||||
| item | owner | state |
|
||||
|---|---|---|
|
||||
| … | me / you / the human | open / deferred (reason) |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The review discipline
|
||||
|
||||
"Verify" turned out to mean something more specific than reading a diff. What actually caught defects:
|
||||
|
||||
**Check the enforcement, not the description.** A document says a check is scoped by user; go read the line
|
||||
that compares. Twice the description was right and the code did something else — and the author had read
|
||||
their own description and agreed with it.
|
||||
|
||||
**Run it against a real machine.** Every defect that mattered was found this way, on a first execution. The
|
||||
categories at the top of this report are not a coincidence.
|
||||
|
||||
**A check that has never been seen failing is not evidence.** A verification script was run against a live,
|
||||
fully-provisioned account specifically to watch it fail; it reported 8 of 9 failures, which is what made the
|
||||
later clean result meaningful. Related: a *skipped* test must announce itself, or an unconfigured run reads as
|
||||
a pass.
|
||||
|
||||
**Distrust vacuous passes.** Three separate times something passed because it had not actually looked:
|
||||
a subuid scan on a tree with no subuid-owned files; a search root that did not exist, where every check
|
||||
reports "ok" on finding nothing; and a range scan handed a non-numeric argument. **Any checker whose checks
|
||||
are "look for X, report ok if absent" must refuse to run when its inputs are wrong**, rather than pass.
|
||||
|
||||
**Expect stacked bugs.** Fixing the visible failure reveals the next one underneath. A container failed on a
|
||||
mount-point guard; fixing that revealed an ACL traversal denial. An installer failed on the wrong shell;
|
||||
fixing that would have revealed a root-owned parent directory. Never report "fixed" from a diff — only from a
|
||||
run.
|
||||
|
||||
**Distrust "it is inert today".** Several things were safe only because a gate was up. That is a statement
|
||||
about the present, and the entire purpose of the work was to remove the gate. Review inert code as if it were
|
||||
live, because the commit that makes it live will be reviewed as if it were already correct.
|
||||
|
||||
**Fail closed, and check which way "unknown" resolves.** The most dangerous defect of the night was a resolver
|
||||
that answered "I could not determine whose turn this is" with *the owner's identity*. Any place where an
|
||||
unknown collapses into a privileged default is worth a specific look.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes to expect
|
||||
|
||||
Collected from the night, phrased so an agent can pattern-match against them:
|
||||
|
||||
| pattern | what it looked like here |
|
||||
|---|---|
|
||||
| **Author reviews own sentence** | "a wrong answer here must not happen by accident" shipped with that accident |
|
||||
| **Vacuous pass** | checker with a missing search root printing CLEAN |
|
||||
| **Stacked bugs** | PG18 mount guard hiding an ACL traversal denial |
|
||||
| **Inert-today reasoning** | unreachable code reviewed less carefully than reachable code |
|
||||
| **Unknown resolves to privileged** | failed lookup → run as the owner |
|
||||
| **Compiler cannot help** | a parameter changing meaning from email to path, both `string` |
|
||||
| **Guard that cannot fire** | a denylist tested against an object built from an allowlist |
|
||||
| **Side-effect creation** | `install -d` making a parent `root:root` |
|
||||
| **Mode bits vs ACLs** | `chown` severing ownership and leaving access |
|
||||
| **Tail-of-session work** | three of the night's bugs written after hour eight |
|
||||
|
||||
---
|
||||
|
||||
## Git hygiene for two agents on one branch
|
||||
|
||||
Small, and it bit us repeatedly:
|
||||
|
||||
- **Pull before you push, and expect a race.** Both agents pushed within the same minute more than once; one
|
||||
rebase was needed mid-review.
|
||||
- **Merge, verify, *then* delete.** A branch was deleted after an aborted fast-forward — master had moved —
|
||||
and the commits survived only because git had not yet garbage-collected them. Verify the merge landed before
|
||||
removing the only ref to it.
|
||||
- **A doc-only commit still deserves a real message.** These commit messages are the durable record once
|
||||
`COMMS/` is deleted; several findings in this repo now exist *only* in a commit body.
|
||||
- **Say which remote.** See the SHA rule above.
|
||||
|
||||
## The background watcher — launch it exactly this way
|
||||
|
||||
This is the part that was hardest to convey to the second agent, who ended up launching it differently and
|
||||
got something that looked identical and did not work. The mechanism matters more than the script.
|
||||
|
||||
### The requirement, stated so it survives a different harness
|
||||
|
||||
> A **shell process, detached, owned by the agent's harness, that exits when it has something to say** — and
|
||||
> whose exit **re-invokes the agent**.
|
||||
|
||||
Three properties, and dropping any one breaks it in a way that is not obvious from watching it run:
|
||||
|
||||
1. **The waiting happens in the shell, not in the model.** No inference per tick.
|
||||
2. **The harness owns the process**, so its exit is an event the harness delivers to the agent.
|
||||
3. **It exits on detection.** A watcher that notices a change and keeps running has told nobody.
|
||||
|
||||
### The launch
|
||||
|
||||
In Claude Code this is the Bash tool with `run_in_background: true`. Whatever the harness, it must be *that
|
||||
harness's* background mechanism — the one that notifies on completion — and not a shell backgrounding
|
||||
operator.
|
||||
|
||||
```bash
|
||||
cd /path/to/repo || exit 1
|
||||
BASE=$(git rev-parse HEAD)
|
||||
echo "watching origin/<branch> from base=$BASE"
|
||||
for i in $(seq 1 2880); do
|
||||
NEW=$(timeout 30 git ls-remote origin <branch> 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$NEW" ] && [ "$NEW" != "$BASE" ]; then
|
||||
echo "PUSH_DETECTED"; echo "base=$BASE"; echo "new=$NEW"; exit 0
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
echo "WATCHER_TIMEOUT no push in ~24h base=$BASE"
|
||||
exit 1
|
||||
```
|
||||
|
||||
Every line of that is load-bearing:
|
||||
|
||||
| choice | why | what you get instead |
|
||||
|---|---|---|
|
||||
| `git ls-remote` | reads the remote, mutates nothing | `git fetch` moves refs under a working tree that may be mid-edit |
|
||||
| `timeout 30` on the call | a hung network call would freeze the loop silently | a watcher that is alive and blind |
|
||||
| one `echo` at start, then silence | the output enters the agent's context on wake | one line per tick = 2,880 lines to swallow |
|
||||
| `exit 0` on detection | the exit **is** the notification | it notices and nobody hears |
|
||||
| `seq 1 2880` | runaway backstop | a process nobody remembers, polling forever |
|
||||
| `sleep 30` | free, because no model runs | see below |
|
||||
|
||||
### Why 30 seconds is free here and ruinous in the model
|
||||
|
||||
An idle watcher costs **nothing**. Measured: 85 bytes of output over seven minutes, no model inference at
|
||||
all. The agent is suspended between turns; the loop is just a process.
|
||||
|
||||
Cost appears in exactly two places — when the accumulated output enters the context, and the single
|
||||
re-invocation when the process exits. Both happen **once**, on the event.
|
||||
|
||||
A model-driven poll is a different thing wearing the same clothes. There the model wakes each tick and
|
||||
re-reads the entire conversation to decide "nothing yet". At 30-second granularity that is enormous, and
|
||||
there is a second trap: the prompt cache has roughly a five-minute TTL, so any model-side wake spaced beyond
|
||||
that reads the whole context uncached and pays full price. Pushing the waiting *below* the model turns an
|
||||
unaffordable poll into a free one.
|
||||
|
||||
### The four ways to launch it that look right and are not
|
||||
|
||||
**1. `nohup … &` or any shell backgrounding.** The process runs, polls correctly, detects the push, and exits —
|
||||
and **the agent is never told**, because the harness is not tracking it. I did this myself and only noticed
|
||||
because I re-read my own command. It fails silently and looks perfect: a running process, a correct script,
|
||||
and an agent that sits there forever.
|
||||
|
||||
**2. A model-driven interval** — `/loop 30s`, a scheduler, a wake-up timer. Functionally correct, and it pays
|
||||
a full context read per tick to learn nothing. This is the one to warn a new agent about first, because it is
|
||||
the intuitive design and the expense is invisible.
|
||||
|
||||
**3. A loop that does not exit on detection** — printing "found it" and continuing. There is no mechanism by
|
||||
which that reaches the agent. The output file grows and no one reads it.
|
||||
|
||||
**4. Chatty output.** Any per-tick logging is deferred cost: silent while it accumulates, then all of it
|
||||
lands in the context at once on wake.
|
||||
|
||||
### Two operational failures worth pre-empting
|
||||
|
||||
**Self-tripping.** An agent that pushes while its own watcher is live wakes itself. The real cause is
|
||||
starting a new watcher without stopping the old one, so two run concurrently and the stale one fires on your
|
||||
own commit. **Stop the previous watcher before starting the next**, and re-base the new one on the head you
|
||||
just pushed.
|
||||
|
||||
**Silent death.** If the session restarts, the watcher dies, and a dead watcher is indistinguishable from a
|
||||
quiet branch. Twice, pushes landed unnoticed and were found by a manual `git log`. Anything long-running
|
||||
needs a liveness signal of its own, or the eventual replacement of polling with a webhook — the repo is a
|
||||
Gitea instance the platform already runs, and an event delivered is one that cannot be missed by a process
|
||||
that stopped existing.
|
||||
|
||||
## Identity: the gap that made the record unreliable
|
||||
|
||||
Both agents committed from machines configured with the owner's git identity. **Every commit on the branch,
|
||||
by either agent, reads `Author: <the owner>` with a `Co-Authored-By: Claude Opus 5` trailer.**
|
||||
|
||||
The consequence surfaced at the end and was genuinely disorienting: the owner asked which commit an agent had
|
||||
written, and *neither the log nor the agent could answer from the repository*. The only reason one agent knew
|
||||
its own commits was that it had read the SHAs back from its own `git push` output during the session — which
|
||||
does not survive the session.
|
||||
|
||||
`docs/agent-git-identity.md` describes this and is marked *"idea, not implemented"*. It stopped being an idea
|
||||
tonight. Of everything here it is the cheapest to fix and the most corrosive to leave: an audit trail that
|
||||
cannot attribute a line is not an audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Session economics, which shape all of the above
|
||||
|
||||
**Idle is free; waking is not.** The watcher costs nothing while it waits. Every wake re-reads the entire
|
||||
conversation, so a late wake in a long session costs far more than an early one, and the cost grows
|
||||
monotonically with the session.
|
||||
|
||||
**This argues against one immortal session.** The durable shape is a *short-lived session per event* — the
|
||||
platform detects a push, spawns an agent with the base SHA and the instruction, it reviews, reports, exits.
|
||||
State lives in the repo, not in an ever-growing transcript. A ten-hour session is possible and was useful, but
|
||||
its last hour cost several times its first.
|
||||
|
||||
**Compaction is the real horizon, not session death.** Where sessions persist, the limit is that the earliest
|
||||
context — usually the most expensive reasoning — degrades to summary first. Anything that must survive belongs
|
||||
in the repo the moment it is understood, not at the end.
|
||||
|
||||
---
|
||||
|
||||
## Turning this into a convention
|
||||
|
||||
In order, cheapest and most load-bearing first.
|
||||
|
||||
**1. Per-agent git identity.** Both agents commit from machines configured as the owner, so every commit reads
|
||||
`Author: <owner>` with a `Co-Authored-By` trailer, for both of them. The owner asked which commit an agent had
|
||||
written and *neither the log nor the agent could answer from the repository*. An audit trail that cannot
|
||||
attribute a line is not an audit trail, and everything else here assumes attribution works.
|
||||
`docs/agent-git-identity.md` describes the fix and has been marked "idea, not implemented" since 2026-08-10.
|
||||
|
||||
**2. `COMMS/` as a checked convention, not a habit.** The numbering, the parity, the verified/assumed split
|
||||
and the open-item table are all mechanically checkable. A pre-commit hook or a small script that refuses a
|
||||
malformed handoff would have caught the duplicate document and both stalls.
|
||||
|
||||
**3. State-based termination and stall detection.** Open items with owners, in a machine-readable block; the
|
||||
exchange pauses when none is actionable by a participant; a watcher notices open items with no document for N
|
||||
minutes. This is the single biggest quality-of-life gain and it is not hard.
|
||||
|
||||
**4. Event delivery instead of polling.** The repo is a Gitea instance the platform already runs. A webhook
|
||||
removes the watcher entirely — with its self-trips, its bounded lifetime and its silent death — and replaces
|
||||
"did I miss a push" with an event that cannot be missed by a process that stopped existing.
|
||||
|
||||
**5. Ownership on work items**, so two agents cannot independently write the same file.
|
||||
|
||||
**6. A durable-notes rule.** `COMMS/` is deleted at merge. Anything still true afterwards moves to `docs/`
|
||||
*before* the merge, and the merge should refuse if the channel contains unresolved open items.
|
||||
|
||||
## What not to automate
|
||||
|
||||
**The human's arbitration.** Every irreversible decision was the owner's — lifting the chat gates, deleting an
|
||||
account, choosing between two designs, deciding a directory should stop existing. Each was a judgement neither
|
||||
agent should have made alone, and in at least two cases an agent talked the other out of a bad idea using an
|
||||
argument *the human had originally made*.
|
||||
|
||||
`agent-coordination.md` sets the objective as agents coordinating rather than routing through the human. This
|
||||
night supports that for **execution** and contradicts it for **authority**. The human was not a bottleneck in
|
||||
the work — they were the only participant who could say "that is not yours to decide", and the only one who
|
||||
consistently pushed for a real test over more building.
|
||||
|
||||
The distinction worth encoding: agents may coordinate freely on *what is true* and must not decide *what is
|
||||
permitted*.
|
||||
|
||||
## Postscript: the one that worked first time
|
||||
|
||||
Everything above was found by something failing. One thing did not.
|
||||
|
||||
`deprovisionOsAccount` — the function whose failure hands one member another member's home, keys and
|
||||
credentials — ran correctly the first time it ever ran, against a live account with a systemd session, a
|
||||
running Docker stack and a shell parented outside the session cgroup. Ten checks, clean, on the first
|
||||
execution.
|
||||
|
||||
It is also the only piece of work all night that was **specified before it was written, implemented by
|
||||
someone who had not written the spec, and verified by a tool built before the implementation existed**.
|
||||
|
||||
That is the strongest single argument in this document, and it is one data point. Treat it accordingly.
|
||||
@@ -1,7 +1,7 @@
|
||||
// Linux light profile — the platform without the self-hosted estate around it.
|
||||
//
|
||||
// For a machine that should run the file browser, the terminal and Claude/opencode chat, and nothing
|
||||
// else. Paired with `OFFICER_PROFILE=light bash scripts/setup.sh`, which installs only what these
|
||||
// else. Paired with `OFFICER_PROFILE=light bash scripts/setup/setup.sh`, which installs only what these
|
||||
// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
|
||||
//
|
||||
// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
|
||||
@@ -1,6 +1,6 @@
|
||||
// macOS light profile — the same process set as the Linux light profile, on a laptop.
|
||||
//
|
||||
// Paired with scripts/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
|
||||
// Paired with scripts/setup/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
|
||||
// chat; nothing else.
|
||||
//
|
||||
// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this
|
||||
@@ -24,6 +24,27 @@
|
||||
* @param {string[]} spec.include app names to run, in start order
|
||||
* @param {Record<string,string>} spec.excluded app name → why it is not in this profile
|
||||
*/
|
||||
// The directory holding the platform's package.json, found by walking up from this file. Independent of
|
||||
// where in the tree this config is kept, and of where pm2 was invoked from.
|
||||
function repoRoot() {
|
||||
const { existsSync, readFileSync } = require('node:fs');
|
||||
const { dirname, join } = require('node:path');
|
||||
let dir = __dirname;
|
||||
for (;;) {
|
||||
const manifest = join(dir, 'package.json');
|
||||
if (existsSync(manifest)) {
|
||||
try {
|
||||
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir;
|
||||
} catch {
|
||||
// Unparseable is not ours; keep walking.
|
||||
}
|
||||
}
|
||||
const up = dirname(dir);
|
||||
if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname);
|
||||
dir = up;
|
||||
}
|
||||
}
|
||||
|
||||
function defineProfile({ file, include, excluded }) {
|
||||
const full = require('./ecosystem.config.cjs');
|
||||
const byName = new Map(full.apps.map((app) => [app.name, app]));
|
||||
@@ -48,9 +69,17 @@ function defineProfile({ file, include, excluded }) {
|
||||
|
||||
// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does
|
||||
// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls
|
||||
// back to PORT=5000 with no POSTGRES_URL. __dirname is the repo root — this file sits beside
|
||||
// ecosystem.config.cjs.
|
||||
return { apps: include.map((name) => ({ ...byName.get(name), cwd: __dirname })) };
|
||||
// back to the default PORT with no POSTGRES_URL.
|
||||
//
|
||||
// It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of
|
||||
// the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not
|
||||
// fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it.
|
||||
//
|
||||
// This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside
|
||||
// ecosystem.config.cjs". That stopped being true the moment these files were moved into
|
||||
// ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is
|
||||
// true wherever this file ends up living.
|
||||
return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) };
|
||||
}
|
||||
|
||||
module.exports = { defineProfile };
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
"src/workspaces/*"
|
||||
],
|
||||
"scripts": {
|
||||
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22 || v > 22) { console.error('Node 22 required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
||||
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
|
||||
"gen:index": "bun run ./scripts/gen-index.ts",
|
||||
"predev": "bun run ./scripts/gen-index.ts",
|
||||
"dev": "bun --env-file=.env --watch src/server.tsx",
|
||||
@@ -25,7 +25,7 @@
|
||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
|
||||
"setup": "bash scripts/setup.sh"
|
||||
"setup": "bash scripts/setup/officer-setup.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,100 @@
|
||||
########## TPM AUTO-INSTALL + SESSION PERSISTENCE ##########
|
||||
|
||||
# Auto-install TPM if missing
|
||||
if-shell '[ ! -d ~/.tmux/plugins/tpm ]' \
|
||||
'run-shell "git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm"'
|
||||
|
||||
# Plugin list
|
||||
set -g @plugin 'tmux-plugins/tpm'
|
||||
|
||||
# remap prefix from 'C-b' to 'C-a'
|
||||
unbind C-b
|
||||
set-option -g prefix C-a
|
||||
bind-key C-a send-prefix
|
||||
|
||||
set -g base-index 1
|
||||
|
||||
# split panes using | and -
|
||||
unbind '"'
|
||||
unbind %
|
||||
bind | split-window -h
|
||||
bind - split-window -v
|
||||
|
||||
# reload config file (change file location to your the tmux.conf you want to use)
|
||||
unbind r
|
||||
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||
|
||||
# switch panes using Alt-arrow without prefix
|
||||
bind -n M-Left select-pane -L
|
||||
bind -n M-Right select-pane -R
|
||||
bind -n M-Up select-pane -U
|
||||
bind -n M-Down select-pane -D
|
||||
# switch panes using Alt-HJKL without prefix
|
||||
bind -n M-h select-pane -L
|
||||
bind -n M-l select-pane -R
|
||||
bind -n M-k select-pane -U
|
||||
bind -n M-j select-pane -D
|
||||
|
||||
# Enable mouse control (clickable windows, panes, resizable panes)
|
||||
|
||||
# don't rename windows automatically
|
||||
set-option -g allow-rename off
|
||||
|
||||
######################
|
||||
### DESIGN CHANGES ###
|
||||
######################
|
||||
|
||||
# loud or quiet?
|
||||
set -g visual-activity off
|
||||
set -g visual-bell off
|
||||
set -g visual-silence off
|
||||
setw -g monitor-activity off
|
||||
set -g bell-action none
|
||||
|
||||
# modes
|
||||
setw -g clock-mode-colour colour12
|
||||
setw -g mode-style 'fg=colour1 bg=colour18 bold'
|
||||
|
||||
# panes
|
||||
set -g pane-border-style 'fg=colour19 bg=colour0'
|
||||
set -g pane-active-border-style 'bg=colour0 fg=colour9'
|
||||
|
||||
# statusbar
|
||||
set -g status-position bottom
|
||||
set -g status-justify left
|
||||
set -g status-style 'bg=colour2 fg=colour23'
|
||||
# set -g status-left '#[fg=white,bg=black,bold] pastilhas #[default]'
|
||||
set -g status-left '#[fg=#ffffff,bg=#000000,bold] #{USER}@#H #[default]'
|
||||
# set -g status-left-length 20
|
||||
set -g status-right '#[fg=#ffffff,bg=colour1] %d/%m #[fg=#ffffff,bg=colour8] %H:%M:%S '
|
||||
set -g status-right-length 50
|
||||
set -g status-left-length 20
|
||||
|
||||
setw -g window-status-current-style 'fg=colour1 bg=colour19 bold'
|
||||
setw -g window-status-current-format ' #I#[fg=colour249]:#[fg=colour255]#W#[fg=colour249]#F '
|
||||
|
||||
setw -g window-status-style 'fg=colour9 bg=colour18'
|
||||
setw -g window-status-format ' #I#[fg=colour237]:#[fg=colour250]#W#[fg=colour244]#F '
|
||||
|
||||
setw -g window-status-bell-style 'fg=colour255 bg=colour1 bold'
|
||||
# ...existing code...
|
||||
# messages
|
||||
set -g message-style 'fg=#ffffff bg=red bold'
|
||||
# Change the font color for the exit pane confirmation message
|
||||
set -g message-command-style 'fg=#ffffff bg=red bold'
|
||||
|
||||
# ...existing code...
|
||||
|
||||
# messages
|
||||
# set -g message-style 'fg=colour232 bg=colour16 bold'
|
||||
|
||||
##########################
|
||||
### END DESIGN CHANGES ###
|
||||
##########################
|
||||
|
||||
##########################
|
||||
### EASY MOUSE SCROLL ###
|
||||
##########################
|
||||
|
||||
set -g mouse on
|
||||
set -ga terminal-overrides ',*256color*:smcup@:rmcup@'
|
||||
@@ -0,0 +1,621 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — shared foundation
|
||||
# =============================================================================
|
||||
#
|
||||
# Sourced by machine-setup.sh before anything runs. DEFINITIONS ONLY: this file
|
||||
# declares state and functions and must never install, write or restart
|
||||
# anything. Sourcing it has to be safe at any point, including from a step that
|
||||
# is only being read for its variables.
|
||||
#
|
||||
# The one thing it expects from its caller, because they are facts about the
|
||||
# entry point rather than about this library:
|
||||
#
|
||||
# SCRIPT_DIR directory of the script being run
|
||||
# PROGRESS_FILE where completed step names are recorded
|
||||
#
|
||||
# Everything else below is owned here.
|
||||
|
||||
# Guard against being sourced twice — steps will eventually source this
|
||||
# directly so they can be run on their own, and re-running it would reset
|
||||
# SUMMARY and lose everything recorded so far.
|
||||
[[ -n "${MACHINE_SETUP_BASE_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_BASE_LOADED=1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared state
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
SUMMARY=() # what was done, printed at the end
|
||||
ERRORS=() # non-fatal failures, printed at the end
|
||||
CURRENT_STEP=""
|
||||
SKIP_STEP=false
|
||||
|
||||
# What machine this is. Filled in by detect_os() before any step runs; every step
|
||||
# after that branches on these rather than assuming apt on x86_64.
|
||||
OS="" # os-release ID: ubuntu | debian | arch | fedora | macos | …
|
||||
OS_NAME="" # pretty name, for the banner
|
||||
OS_VERSION="" # version id; empty on rolling releases
|
||||
PM="" # apt | pacman | dnf | brew
|
||||
ARCH="" # amd64 | arm64, normalised — upstream tarballs disagree on spelling
|
||||
IS_WSL=false
|
||||
|
||||
# What this box is FOR. Asked once in pre-flight and consulted by the steps
|
||||
# afterwards, because several of them have a different right answer per role and
|
||||
# no way to work it out on their own:
|
||||
#
|
||||
# homelab a machine you physically control on a network you own
|
||||
# vps rented, public IP, someone else's DHCP and console
|
||||
# dev a laptop or desktop you sit at
|
||||
#
|
||||
# Set MACHINE_ROLE in the environment to answer it ahead of time — hence the
|
||||
# :- default rather than a plain assignment, which would wipe what the caller
|
||||
# passed in before ask_machine_role ever looked at it.
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Output
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}::${NC} $*"; }
|
||||
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
||||
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
||||
fail() {
|
||||
echo -e " ${RED}FAIL${NC}: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Steps and resume
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# A step announces itself, and is skipped when its name is already in the
|
||||
# progress file. step_ok records it. The pattern at each call site is:
|
||||
#
|
||||
# step "Name"
|
||||
# if ! skip; then
|
||||
# …
|
||||
# step_ok
|
||||
# fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Remembering the answers
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Pre-flight asks four things — role, account, where Officer goes — and every
|
||||
# section needs them. Asking again on every run made a resumed run re-answer
|
||||
# questions it had already been told, and made --only unusable: four questions to
|
||||
# reach one section.
|
||||
#
|
||||
# Saved beside the progress file, and loaded before anything is asked. The
|
||||
# environment still wins, so SETUP_USERNAME=x on the command line overrides what
|
||||
# was saved.
|
||||
|
||||
ANSWERS_FILE="${ANSWERS_FILE:-}"
|
||||
|
||||
save_answers() {
|
||||
[[ -n "$ANSWERS_FILE" ]] || return 0
|
||||
cat >"$ANSWERS_FILE" <<EOF
|
||||
# Written by machine-setup. Delete this to be asked again.
|
||||
MACHINE_ROLE=${MACHINE_ROLE}
|
||||
SETUP_USERNAME=${USERNAME}
|
||||
OFFICER_ROOT=${OFFICER_ROOT}
|
||||
EOF
|
||||
chmod 600 "$ANSWERS_FILE"
|
||||
}
|
||||
|
||||
# Loaded as assignments, not sourced as a script: this file sits beside the
|
||||
# script and is read by a root run, so it should not be able to execute anything.
|
||||
load_answers() {
|
||||
[[ -n "$ANSWERS_FILE" && -r "$ANSWERS_FILE" ]] || return 0
|
||||
local key value
|
||||
while IFS='=' read -r key value; do
|
||||
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
||||
[[ -n "$value" ]] || continue
|
||||
# The environment wins over what was saved.
|
||||
#
|
||||
# Written as if/then rather than `[[ … ]] && assign`.
|
||||
#
|
||||
# That form returns non-zero when the test is false. Harmless on its own —
|
||||
# `set -e` exempts the left side of an && list — but here it is the last
|
||||
# thing the case runs, the case is the last thing the loop body runs, and the
|
||||
# loop is the last thing THE FUNCTION runs. So load_answers returned
|
||||
# non-zero, and calling a function that returns non-zero is a plain command
|
||||
# failure, which does end the script.
|
||||
#
|
||||
# It needed the answers file to exist AND the variables to be set already, so
|
||||
# it only appeared when running with env overrides. The trap reported "Step:
|
||||
# unknown" at a line inside this library, before pre-flight had run.
|
||||
#
|
||||
# The general rule this is an instance of: a function whose last statement
|
||||
# can return non-zero fails when it is called, however innocuous the
|
||||
# statement looks.
|
||||
case "$key" in
|
||||
MACHINE_ROLE) if [[ -z "${MACHINE_ROLE:-}" ]]; then MACHINE_ROLE="$value"; fi ;;
|
||||
SETUP_USERNAME) if [[ -z "${SETUP_USERNAME:-}" ]]; then SETUP_USERNAME="$value"; fi ;;
|
||||
OFFICER_ROOT) if [[ -z "${OFFICER_ROOT:-}" ]]; then OFFICER_ROOT="$value"; fi ;;
|
||||
esac
|
||||
done <"$ANSWERS_FILE"
|
||||
}
|
||||
|
||||
# Set by --only. When it is set, every step whose name does not match is passed
|
||||
# over in silence, and the one that does matches runs regardless of the progress
|
||||
# file — the point of asking for a single step is to run that step.
|
||||
ONLY_STEP="${ONLY_STEP:-}"
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
else
|
||||
SKIP_STEP=true
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
||||
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
}
|
||||
|
||||
skip() { [[ "$SKIP_STEP" == true ]]; }
|
||||
|
||||
step_ok() {
|
||||
# A single step run on its own is not progress through the script, and
|
||||
# recording it would make the next full run skip it.
|
||||
[[ -n "$ONLY_STEP" ]] && return 0
|
||||
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
||||
}
|
||||
|
||||
# Try a command, log error but don't exit
|
||||
try() {
|
||||
local label="$1"
|
||||
shift
|
||||
if "$@" 2>&1; then
|
||||
ok "$label"
|
||||
else
|
||||
warn "$label — failed (non-critical, continuing)"
|
||||
ERRORS+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Input
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
prompt_value() {
|
||||
local varname="$1" message="$2" default="$3"
|
||||
# If env var already set, use it silently
|
||||
if [[ -n "${!varname:-}" ]]; then
|
||||
return
|
||||
fi
|
||||
local input
|
||||
if [[ -n "$default" ]]; then
|
||||
read -rp "$message [$default]: " input
|
||||
eval "$varname=\"\${input:-$default}\""
|
||||
else
|
||||
read -rp "$message: " input
|
||||
eval "$varname=\"\$input\""
|
||||
fi
|
||||
}
|
||||
|
||||
# Show long output a screen at a time.
|
||||
#
|
||||
# Only when there is a terminal to page on: with output redirected or piped —
|
||||
# a transcript, a log, the test harness — it has to come through whole, and a
|
||||
# pager would either block or mangle it. `more` rather than `less` because it
|
||||
# exits at the end of the file instead of sitting there waiting to be quit,
|
||||
# which is what you want for something you asked to read once.
|
||||
page() {
|
||||
if [[ -t 1 ]] && command -v more &>/dev/null; then
|
||||
more
|
||||
else
|
||||
cat
|
||||
fi
|
||||
}
|
||||
|
||||
# Ask before acting. Every section that changes the machine goes through this, so
|
||||
# a run is a sequence of things you agreed to rather than a wall of output you
|
||||
# read afterwards to find out what happened.
|
||||
#
|
||||
# Enter means yes — unlike the machine-role question, which has no default. These
|
||||
# are "do the thing you already asked for", and making twenty of them require a
|
||||
# deliberate keystroke would train people to hold the y key down.
|
||||
#
|
||||
# ASSUME_YES=1 answers all of them, for an unattended run.
|
||||
confirm() {
|
||||
local message="${1:-Proceed?}"
|
||||
# Second argument flips the default. Most questions here are "do the thing you
|
||||
# already asked for" and Enter should mean yes; a few are genuine extras, where
|
||||
# defaulting to yes would have people agreeing to them by reflex.
|
||||
local default="${2:-y}"
|
||||
# Third is the name of a function that explains the question. Where one is
|
||||
# given, `?` becomes an answer — so the explanation is available to whoever
|
||||
# wants it without being in the way of whoever does not.
|
||||
local help_fn="${3:-}"
|
||||
local answer prompt
|
||||
|
||||
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
|
||||
|
||||
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
|
||||
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
|
||||
|
||||
while true; do
|
||||
# EOF is not a yes. Without this an unattended run without ASSUME_YES would
|
||||
# spin here forever.
|
||||
if ! read -rp " ${message} ${prompt}: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
||||
fi
|
||||
[[ -z "$answer" ]] && answer="$default"
|
||||
case "$answer" in
|
||||
y | Y | yes | Yes) return 0 ;;
|
||||
n | N | no | No) return 1 ;;
|
||||
"?")
|
||||
if [[ -n "$help_fn" ]]; then
|
||||
echo ""
|
||||
"$help_fn" | page
|
||||
echo ""
|
||||
else
|
||||
warn "Answer y or n."
|
||||
fi
|
||||
;;
|
||||
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Which account this machine is being set up for.
|
||||
#
|
||||
# Asked at the top because two later questions default off it — where Officer is
|
||||
# installed, and where the disk ballast goes — so it has to be settled before
|
||||
# either is put to the user.
|
||||
#
|
||||
# Defaults to whoever invoked sudo. On a re-run, or on a machine that is already
|
||||
# somebody's, that is the answer every time, and typing it again is a chance to
|
||||
# typo it into creating a second account.
|
||||
#
|
||||
# SETUP_USERNAME in the environment answers it ahead of time. Deliberately not
|
||||
# USERNAME: that name is set by some login environments, and a variable this
|
||||
# script silently obeys should not be one that might already be in the
|
||||
# environment for unrelated reasons.
|
||||
ask_username() {
|
||||
local default="${SUDO_USER:-}" answer
|
||||
|
||||
# root invoked the script directly rather than through sudo. It is never the
|
||||
# account being set up, so there is nothing to suggest.
|
||||
[[ "$default" == "root" ]] && default=""
|
||||
|
||||
if [[ -n "${SETUP_USERNAME:-}" ]]; then
|
||||
answer="$SETUP_USERNAME"
|
||||
else
|
||||
echo ""
|
||||
info "Which account is this machine for?"
|
||||
echo " The account you log in and work as, day to day. It will be created"
|
||||
echo " if it does not exist."
|
||||
echo ""
|
||||
warn "Strongly advised: use a normal account, not root."
|
||||
echo " Working as root means everything runs with no safety net. A typo in"
|
||||
echo " a path deletes instead of refusing, anything you run has the whole"
|
||||
echo " machine, and nothing distinguishes you from a process that got out"
|
||||
echo " of hand. sudo gives you the same power when you ask for it, and"
|
||||
echo " only then — which is why root is not accepted as an answer here."
|
||||
|
||||
# Whether this was started FROM a root session, which usually means root is
|
||||
# how they log in. That is exactly the situation the advice above is for, and
|
||||
# the one where general advice is easiest to assume is aimed at somebody else.
|
||||
#
|
||||
# Two ways to be in it, and the second is the one that hides: no SUDO_USER at
|
||||
# all, or a SUDO_USER that is itself uid 0. Some providers ship an image whose
|
||||
# default account is uid 0 under an ordinary-looking name, so `sudo` from it
|
||||
# sets SUDO_USER to something that looks like a normal user and is not.
|
||||
local invoker_uid=""
|
||||
[[ -n "${SUDO_USER:-}" ]] && invoker_uid="$(id -u "$SUDO_USER" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "${SUDO_USER:-}" || "$invoker_uid" == "0" ]]; then
|
||||
echo ""
|
||||
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||
warn "You are running this from '${SUDO_USER}', which is uid 0 — the root account."
|
||||
else
|
||||
warn "You are running this as root directly, not through sudo."
|
||||
fi
|
||||
echo " If that is how you normally log into this machine, now is the"
|
||||
echo " moment to make an account and stop doing that."
|
||||
fi
|
||||
echo ""
|
||||
while [[ -z "${answer:-}" ]]; do
|
||||
if ! read -rp " Username${default:+ [$default]}: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set SETUP_USERNAME=<name> to answer this ahead of time."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
[[ -z "$answer" ]] && warn "There is no default here — type a username."
|
||||
done
|
||||
fi
|
||||
|
||||
# The portable shape of a Linux account name. Worth checking rather than
|
||||
# letting adduser refuse it later, because by then several questions have been
|
||||
# answered against a name that was never going to work.
|
||||
[[ "$answer" =~ ^[a-z_][a-z0-9_-]*\$?$ && ${#answer} -le 32 ]] ||
|
||||
fail "'${answer}' is not a usable Linux username — lower case, starting with a letter or underscore."
|
||||
# By uid, not by name. "root" is a label — what makes an account root is uid 0,
|
||||
# and some providers ship an image whose default login is uid 0 under a
|
||||
# friendlier name. Refusing only the string would let exactly that case through,
|
||||
# which is the one worth catching.
|
||||
local answer_uid
|
||||
answer_uid="$(id -u "$answer" 2>/dev/null || true)"
|
||||
if [[ "$answer_uid" == "0" ]]; then
|
||||
if [[ "$answer" == "root" ]]; then
|
||||
fail "root is not the account to set up here — see the warning above."
|
||||
fi
|
||||
fail "'${answer}' is uid 0 — the root account under another name, and not what to set up here."
|
||||
fi
|
||||
|
||||
USERNAME="$answer"
|
||||
|
||||
# Looked up, not assumed. The original built "/home/$USERNAME", which is merely
|
||||
# the usual answer — an account created with a different home, or one whose home
|
||||
# was moved, would have every later step writing to a directory that is not
|
||||
# theirs.
|
||||
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||
}
|
||||
|
||||
# Where Officer will live.
|
||||
#
|
||||
# Asked in pre-flight with the rest of the questions rather than at the point it
|
||||
# is first needed, because it decides the shape of several later steps — the
|
||||
# directory the repository is cloned into, where DATA_PATH sits beside it, and
|
||||
# which filesystem the app store's containers bind-mount out of. Answering it
|
||||
# once at the start also means the run can be described before it begins.
|
||||
#
|
||||
# One directory holding four, per docs/sidecar-app-store.md:
|
||||
#
|
||||
# <root>/platform/ the app
|
||||
# <root>/data/ DATA_PATH
|
||||
# <root>/dockers/ services the app store provisioned
|
||||
# <root>/capabilities/ the file-based item store
|
||||
#
|
||||
# OFFICER_ROOT in the environment answers it ahead of time.
|
||||
ask_officer_root() {
|
||||
local default="${USER_HOME}/officerdev" answer
|
||||
|
||||
if [[ -n "${OFFICER_ROOT:-}" ]]; then
|
||||
answer="$OFFICER_ROOT"
|
||||
else
|
||||
echo ""
|
||||
info "Where should Officer be installed?"
|
||||
echo " One directory holding the app, its data, the item store and any"
|
||||
echo " containers the app store provisions — so it can be moved, backed"
|
||||
echo " up or deleted as a unit."
|
||||
echo ""
|
||||
if ! read -rp " Path [${default}]: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set OFFICER_ROOT=<path> to answer this ahead of time."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
fi
|
||||
|
||||
# A leading ~ arrives as a literal when it comes from a read or an environment
|
||||
# variable — nothing expands it there — and would create a directory named "~".
|
||||
answer="${answer/#\~/$USER_HOME}"
|
||||
|
||||
[[ "$answer" == /* ]] || fail "That needs to be an absolute path, starting with / — got '${answer}'"
|
||||
|
||||
OFFICER_ROOT="${answer%/}"
|
||||
}
|
||||
|
||||
# The account's PRIMARY GROUP, asked of the system rather than assumed to be
|
||||
# named after the user.
|
||||
#
|
||||
# Debian and Ubuntu create a group per user, so "pastilhas:pastilhas" is right on
|
||||
# most machines — but not on one where the account came from LDAP, or was made
|
||||
# with `useradd -g users`, or is a cloud image with a shared group. There
|
||||
# `chown user:user` fails with "invalid group" and `install -g user` refuses,
|
||||
# both of which abort the step.
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# Run a block as the created user (login shell, inherits HOME)
|
||||
as_user() {
|
||||
sudo -u "$USERNAME" -i bash -c "$1"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# sudoers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Grant an account passwordless sudo, safely.
|
||||
#
|
||||
# A malformed file in /etc/sudoers.d breaks sudo COMPLETELY — and you cannot sudo
|
||||
# to repair it, so on a remote machine that is unrecoverable short of a rescue
|
||||
# console. The same is true of one with loose permissions: sudo refuses to read
|
||||
# its own configuration and every sudo on the box fails.
|
||||
#
|
||||
# The original wrote the file into /etc/sudoers.d first and validated it after,
|
||||
# with a chmod later still. Both of those leave a window where a broken or
|
||||
# world-readable sudoers file is live. This validates a temp file first and then
|
||||
# places it with its mode in a single install(1) — so what lands in /etc is
|
||||
# already known good and already 0440.
|
||||
grant_passwordless_sudo() {
|
||||
# Declared separately, deliberately. In `local a="$1" b="${a}"` bash expands
|
||||
# $a before it has been assigned, so b comes out with the name missing — which
|
||||
# here meant every account's rule landing in the same /etc/sudoers.d/99--nopasswd,
|
||||
# each one silently overwriting the last, and has_passwordless_sudo never
|
||||
# finding the file it was looking for.
|
||||
local user="$1"
|
||||
local dest="/etc/sudoers.d/99-${user}-nopasswd"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
[[ -n "$user" ]] || fail "grant_passwordless_sudo needs a username"
|
||||
|
||||
echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp"
|
||||
|
||||
if ! visudo -c -f "$tmp" >/dev/null 2>&1; then
|
||||
rm -f "$tmp"
|
||||
fail "visudo rejected the sudoers entry for '${user}' — not installing it"
|
||||
fi
|
||||
|
||||
install -m 0440 -o root -g root "$tmp" "$dest"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
has_passwordless_sudo() {
|
||||
local user="$1"
|
||||
[[ -f "/etc/sudoers.d/99-${user}-nopasswd" ]] ||
|
||||
grep -rqsE "^${user}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD" /etc/sudoers /etc/sudoers.d 2>/dev/null
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Operating system detection
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Read one key out of /etc/os-release without leaking the rest of it into this
|
||||
# script. That file defines NAME, VERSION and ID — all generic enough to collide
|
||||
# with something here — so it is sourced in a subshell and only the one value
|
||||
# asked for comes back.
|
||||
os_release() {
|
||||
[[ -r /etc/os-release ]] || return 1
|
||||
# shellcheck disable=SC1091
|
||||
(
|
||||
. /etc/os-release 2>/dev/null
|
||||
printf '%s' "${!1:-}"
|
||||
)
|
||||
}
|
||||
|
||||
# Identify the machine, or refuse to guess.
|
||||
#
|
||||
# /etc/os-release rather than probing for a binary: a box can have more than one
|
||||
# package manager on PATH (a Homebrew install on Linux, a leftover apt on a
|
||||
# converted box), and only os-release can say which distribution the machine
|
||||
# actually IS, or give a version worth reporting.
|
||||
#
|
||||
# ID_LIKE is the fallback so derivatives resolve without being listed by name —
|
||||
# Pop!_OS, Mint and EndeavourOS all answer correctly without appearing below.
|
||||
detect_os() {
|
||||
local kernel like
|
||||
kernel="$(uname -s)"
|
||||
|
||||
case "$kernel" in
|
||||
Darwin)
|
||||
OS="macos"
|
||||
OS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)"
|
||||
OS_NAME="macOS ${OS_VERSION}"
|
||||
PM="brew"
|
||||
;;
|
||||
Linux)
|
||||
OS="$(os_release ID || true)"
|
||||
OS_NAME="$(os_release PRETTY_NAME || true)"
|
||||
OS_VERSION="$(os_release VERSION_ID || true)"
|
||||
like="$(os_release ID_LIKE || true)"
|
||||
|
||||
case "$OS" in
|
||||
ubuntu | debian | linuxmint | pop | raspbian | elementary) PM="apt" ;;
|
||||
arch | manjaro | endeavouros | cachyos | garuda) PM="pacman" ;;
|
||||
fedora | rhel | centos | rocky | almalinux) PM="dnf" ;;
|
||||
*)
|
||||
case " $like " in
|
||||
*" debian "* | *" ubuntu "*) PM="apt" ;;
|
||||
*" arch "*) PM="pacman" ;;
|
||||
*" fedora "* | *" rhel "*) PM="dnf" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
# WSL reports itself as Linux, but has no real systemd session: masking
|
||||
# sleep targets, restarting logind and anything touching the boot path
|
||||
# either fail or silently do nothing. Worth knowing before those steps run.
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true; fi
|
||||
;;
|
||||
MINGW* | MSYS* | CYGWIN*)
|
||||
fail "Windows is not supported. Run this inside WSL2 with an Ubuntu image instead."
|
||||
;;
|
||||
*)
|
||||
fail "Unrecognised kernel '$kernel' — cannot tell what this machine is."
|
||||
;;
|
||||
esac
|
||||
|
||||
# Normalised once here because upstream projects spell it differently:
|
||||
# Neovim ships aarch64, Go and Docker ship arm64, and lazygit ships x86_64.
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) ARCH="amd64" ;;
|
||||
aarch64 | arm64) ARCH="arm64" ;;
|
||||
*) fail "Unsupported CPU architecture '$(uname -m)' — this script installs amd64/arm64 binaries only." ;;
|
||||
esac
|
||||
|
||||
[[ -n "$OS" ]] || fail "Could not identify this distribution (no readable /etc/os-release)."
|
||||
[[ -n "$OS_NAME" ]] || OS_NAME="$OS${OS_VERSION:+ $OS_VERSION}"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Machine role
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# The interface packets actually leave by, which is not always the first one up.
|
||||
default_iface() {
|
||||
ip route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}'
|
||||
}
|
||||
|
||||
# Ask what this machine is, unless the environment already said.
|
||||
#
|
||||
# Asked in pre-flight rather than at the point of use so that the run knows its
|
||||
# own shape before it starts: the steps that care are spread from swap through to
|
||||
# the firewall, and being asked "is this a VPS?" for the fourth time halfway down
|
||||
# a provisioning run is how people start answering without reading.
|
||||
#
|
||||
# NO DEFAULT, deliberately, and it is the only question in the script like that.
|
||||
# A guessed default is right often enough to be trusted and wrong in exactly the
|
||||
# case that costs the most: pinning a static IP on a rented box, or leaving the
|
||||
# firewall open on one. Every branch downstream is about what this machine is
|
||||
# exposed to, so it is worth one deliberate keystroke rather than an Enter.
|
||||
ask_machine_role() {
|
||||
if [[ -n "$MACHINE_ROLE" ]]; then
|
||||
case "$MACHINE_ROLE" in
|
||||
homelab | vps | dev) return ;;
|
||||
*) fail "MACHINE_ROLE must be homelab, vps or dev — got '$MACHINE_ROLE'" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "What is this machine? Several later steps depend on the answer."
|
||||
echo " [1] homelab — yours, on a network you control"
|
||||
echo " [2] vps — rented, public IP, provider's DHCP and console"
|
||||
echo " [3] dev — a laptop or desktop you sit at"
|
||||
echo ""
|
||||
|
||||
local choice
|
||||
while [[ -z "$MACHINE_ROLE" ]]; do
|
||||
# A failed read means EOF, not a wrong answer — without this the loop would
|
||||
# spin forever when stdin is closed, which is how an unattended run hangs.
|
||||
if ! read -rp " Which one? (1/2/3): " choice; then
|
||||
fail "No answer, and this question has no default. Set MACHINE_ROLE=homelab|vps|dev to answer it ahead of time."
|
||||
fi
|
||||
case "$choice" in
|
||||
1 | homelab) MACHINE_ROLE=homelab ;;
|
||||
2 | vps) MACHINE_ROLE=vps ;;
|
||||
3 | dev) MACHINE_ROLE=dev ;;
|
||||
"") warn "There is no default here — pick 1, 2 or 3." ;;
|
||||
*) warn "Not one of the options: '$choice'" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Convenience for the steps that branch on it.
|
||||
is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; }
|
||||
is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }
|
||||
@@ -0,0 +1,398 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — the development environment
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_DEV_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_DEV_LOADED=1
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# git
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Read and written as the account, not as root. `git config --global` writes to
|
||||
# $HOME/.gitconfig, so running it under sudo without -H would write root's.
|
||||
#
|
||||
# ── Why this asks before touching an existing identity ──
|
||||
#
|
||||
# The original set all four values unconditionally on every run. Re-running it on
|
||||
# a machine somebody already uses replaces the name and email they had with
|
||||
# whatever is typed — and prompt_value accepts an empty answer, so pressing
|
||||
# Enter twice wrote `user.name = ""`. An empty name is worse than none at all:
|
||||
# unset makes git refuse to commit and say why, empty makes it commit with a
|
||||
# blank author and never mention it.
|
||||
#
|
||||
# ── And why it is worth being careful about here in particular ──
|
||||
#
|
||||
# docs/agent-git-identity.md: every agent Officer runs commits AS THE OWNER,
|
||||
# because it runs as the owner. So this is not only the human's identity — it is
|
||||
# what `git log` will attribute every agent commit on this machine to.
|
||||
|
||||
# Run from / rather than wherever the script was launched.
|
||||
#
|
||||
# `git config --global` reads and writes $HOME/.gitconfig and needs no repository
|
||||
# — but git still stats the working directory on the way, looking for one. The
|
||||
# script is typically launched from somewhere under the invoking user's home,
|
||||
# which is 0750, so the target account cannot stat it and every call dies with
|
||||
#
|
||||
# fatal: failed to stat '<cwd>': Permission denied
|
||||
#
|
||||
# Found because the writes failed silently: the section reported "written" while
|
||||
# nothing had been. Both wrappers now run in a subshell from /, which every
|
||||
# account can stat, and their exit status is checked by the caller.
|
||||
# `git config --get` exits NON-ZERO when the key is simply unset, and
|
||||
# `VAR="$(git_get …)"` propagates that under `set -e`. So on a machine where git
|
||||
# has never been configured — the fresh machine this script exists for — reading
|
||||
# the current value aborted the run before the section had printed anything.
|
||||
# Missing a value is an answer here, not a failure.
|
||||
git_get() { (cd / && sudo -H -u "$USERNAME" git config --global --get "$1" 2>/dev/null) || true; }
|
||||
git_set() { (cd / && sudo -H -u "$USERNAME" git config --global "$1" "$2"); }
|
||||
|
||||
# Is there anything configured at all?
|
||||
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
|
||||
|
||||
# Ask for a value that must not be empty. The original's prompt accepted empty
|
||||
# and wrote it; this re-asks.
|
||||
ask_required() {
|
||||
local __var="$1" message="$2" default="$3" answer=""
|
||||
while [[ -z "$answer" ]]; do
|
||||
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
[[ -z "$answer" ]] && warn "This one cannot be left blank."
|
||||
done
|
||||
printf -v "$__var" '%s' "$answer"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shell
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# ── One starship config, not two ──
|
||||
#
|
||||
# The platform deploys scripts/setup/starship.toml into every member's home
|
||||
# (os-user-shell.ts), and the comment there calls it "the prompt config the
|
||||
# owner's own install uses — one file, both audiences". That was not true: the
|
||||
# original machine script wrote a DIFFERENT config inline, so the owner got one
|
||||
# prompt and every member got another. This deploys the same file the platform
|
||||
# does, which makes the comment true rather than aspirational.
|
||||
#
|
||||
# It lives one directory up because it is shared with the platform, not owned by
|
||||
# this script.
|
||||
STARSHIP_SRC="${STARSHIP_SRC:-$SCRIPT_DIR/../starship.toml}"
|
||||
|
||||
user_login_shell() { getent passwd "$USERNAME" | cut -d: -f7; }
|
||||
|
||||
oh_my_zsh_installed() { [[ -d "${USER_HOME}/.oh-my-zsh" ]]; }
|
||||
|
||||
install_oh_my_zsh() {
|
||||
# The installer refuses to run unattended over an existing install, so this is
|
||||
# only ever called when there is none.
|
||||
sudo -H -u "$USERNAME" sh -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# `chsh` is what actually changes the login shell. Asked separately from
|
||||
# installing zsh, because having a shell available and being handed it at every
|
||||
# login are different decisions.
|
||||
set_login_shell() {
|
||||
local shell="$1"
|
||||
grep -qxF "$shell" /etc/shells || echo "$shell" >>/etc/shells
|
||||
chsh -s "$shell" "$USERNAME"
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Neovim
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# From the upstream tarball rather than the distribution, which ships Neovim
|
||||
# years behind — Ubuntu 24.04 has 0.9 where upstream is on 0.12, and LazyVim
|
||||
# requires 0.9+ with most plugins wanting newer.
|
||||
#
|
||||
# The asset names are x86_64 and arm64. The original mapped aarch64 to
|
||||
# "aarch64", which is not a name Neovim publishes: on an arm machine it
|
||||
# downloaded a 404 and tar failed on the HTML error page.
|
||||
nvim_asset() {
|
||||
case "$ARCH" in
|
||||
amd64) echo x86_64 ;;
|
||||
arm64) echo arm64 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
nvim_installed_version() { nvim --version 2>/dev/null | awk 'NR == 1 { print $2 }'; }
|
||||
|
||||
nvim_latest_version() {
|
||||
curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest 2>/dev/null |
|
||||
jq -r '.tag_name // empty'
|
||||
}
|
||||
|
||||
# Downloaded to /tmp, not to whatever directory the script was launched from —
|
||||
# the original used `curl -LO`, which drops the tarball beside the script and
|
||||
# leaves it there if tar fails.
|
||||
#
|
||||
# The old install is removed only after the download has succeeded, so a failed
|
||||
# fetch leaves the working copy alone.
|
||||
nvim_install() {
|
||||
local asset tarball dest
|
||||
asset="$(nvim_asset)"
|
||||
tarball="/tmp/nvim-linux-${asset}.tar.gz"
|
||||
dest="/opt/nvim-linux-${asset}"
|
||||
|
||||
curl -fsSL -o "$tarball" \
|
||||
"https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${asset}.tar.gz" || return 1
|
||||
|
||||
# A 404 comes back as an HTML page, and tar's failure on it is unhelpful.
|
||||
# Checking here names the real problem.
|
||||
tar -tzf "$tarball" >/dev/null 2>&1 || {
|
||||
rm -f "$tarball"
|
||||
warn "the download is not a tarball — the release asset may have been renamed"
|
||||
return 1
|
||||
}
|
||||
|
||||
rm -rf "$dest"
|
||||
tar -C /opt -xzf "$tarball"
|
||||
rm -f "$tarball"
|
||||
ln -sf "${dest}/bin/nvim" /usr/local/bin/nvim
|
||||
}
|
||||
|
||||
# Clone a Neovim config into the account's ~/.config/nvim.
|
||||
#
|
||||
# From `cd /` for the same reason git config does: the script's working directory
|
||||
# is usually under the invoking user's home at 0750, which the target account
|
||||
# cannot stat, and git fails there before it does anything useful.
|
||||
nvim_clone_config() {
|
||||
local repo="$1" dest="${USER_HOME}/.config/nvim"
|
||||
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "${USER_HOME}/.config"
|
||||
(cd / && sudo -H -u "$USERNAME" git clone --depth 1 "$repo" "$dest" >/dev/null 2>&1) || return 1
|
||||
|
||||
# The starter is a template, not something to track. Left in place for a
|
||||
# config of the user's own, which they will want to keep pulling.
|
||||
[[ "$repo" == *LazyVim/starter* ]] && sudo -u "$USERNAME" rm -rf "${dest}/.git"
|
||||
return 0
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# JavaScript runtimes
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Three of these are not optional, and it is worth being precise about why,
|
||||
# because "we run on Bun" suggests Node could go and it cannot:
|
||||
#
|
||||
# node pm2 is a Node application (#!/usr/bin/env node), and pm2 supervises
|
||||
# every process here. officer-pty imports node-pty, a native addon with
|
||||
# no Linux prebuild — it compiles against the installed Node on every
|
||||
# machine. Either one alone makes Node load-bearing.
|
||||
# bun the platform itself and nineteen of the twenty pm2 apps.
|
||||
# pm2 the process manager the ecosystem files are written for.
|
||||
#
|
||||
# Deno is not. Nothing in the platform imports it — checked across the whole
|
||||
# tree — and it is offered only because it was in the original script and
|
||||
# somebody may still want it.
|
||||
|
||||
# The current LTS major, asked of nodejs.org rather than hardcoded. The original
|
||||
# pinned setup_22.x, which ages into "the version we happened to pick" the moment
|
||||
# a new LTS lands.
|
||||
node_lts_major() {
|
||||
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
|
||||
jq -r '[.[] | select(.lts != false)][0].version // empty' | sed 's/^v//; s/\..*//'
|
||||
}
|
||||
|
||||
node_lts_label() {
|
||||
curl -fsSL https://nodejs.org/dist/index.json 2>/dev/null |
|
||||
jq -r '[.[] | select(.lts != false)][0] | "\(.version) (\(.lts))" // empty'
|
||||
}
|
||||
|
||||
node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
|
||||
|
||||
install_node() {
|
||||
local major="$1"
|
||||
# NodeSource publishes one setup script per major. Checked before it is piped
|
||||
# into a shell, because a 404 page piped to bash is a confusing way to fail.
|
||||
curl -fsS -o /dev/null "https://deb.nodesource.com/setup_${major}.x" || {
|
||||
warn "NodeSource has no setup script for Node ${major}"
|
||||
return 1
|
||||
}
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${major}.x" | bash - >/dev/null 2>&1
|
||||
pkg_install_now nodejs
|
||||
# Global installs land in /usr/local rather than in a path only root can write,
|
||||
# so `npm i -g` works the same for the owner and for root.
|
||||
npm config set prefix /usr/local >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Present anywhere: on PATH for this root shell, or in the account's own
|
||||
# ~/.bun/bin, which is where the installer puts it and where root cannot see it.
|
||||
bun_installed() { command -v bun &>/dev/null || [[ -x "${USER_HOME}/.bun/bin/bun" ]]; }
|
||||
|
||||
# Asked of whichever copy exists. Before the symlink is made, root's PATH has no
|
||||
# bun at all, so `bun --version` reports nothing on a machine that plainly has it.
|
||||
bun_version() {
|
||||
if command -v bun &>/dev/null; then
|
||||
bun --version
|
||||
elif [[ -x "${USER_HOME}/.bun/bin/bun" ]]; then
|
||||
"${USER_HOME}/.bun/bin/bun" --version
|
||||
fi
|
||||
}
|
||||
|
||||
# The system-wide link, ensured on every run rather than only after an install.
|
||||
#
|
||||
# pm2 started at boot by systemd has no login shell, so ~/.bun/bin is not on its
|
||||
# PATH — and every one of the twenty ecosystem apps that says `script: 'bun'`
|
||||
# then fails to start on reboot while working perfectly when started by hand. A
|
||||
# machine that already had bun before this script ran would never get the link if
|
||||
# it were only made as part of installing.
|
||||
#
|
||||
# Safe across upgrades: a symlink resolves by path, and `bun upgrade` replaces
|
||||
# the file at $BUN_INSTALL/bin/bun rather than moving it. The link only breaks if
|
||||
# the home directory goes, which breaks bun anyway.
|
||||
ensure_bun_symlink() {
|
||||
local bin="${USER_HOME}/.bun/bin/bun"
|
||||
[[ -x "$bin" ]] || return 1
|
||||
[[ "$(readlink -f /usr/local/bin/bun 2>/dev/null)" == "$(readlink -f "$bin")" ]] && return 1
|
||||
ln -sf "$bin" /usr/local/bin/bun
|
||||
return 0
|
||||
}
|
||||
|
||||
# Installed as the account, then symlinked system-wide. pm2 started at boot by
|
||||
# systemd has no login shell and therefore no ~/.bun/bin on PATH — without the
|
||||
# symlink every bun-based sidecar fails to start on reboot and works fine when
|
||||
# started by hand, which is a miserable thing to debug.
|
||||
install_bun() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://bun.sh/install | bash') >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||
}
|
||||
|
||||
pm2_installed() { command -v pm2 &>/dev/null; }
|
||||
install_pm2() { npm install -g pm2 >/dev/null 2>&1; }
|
||||
|
||||
deno_installed() { command -v deno &>/dev/null || [[ -x "${USER_HOME}/.deno/bin/deno" ]]; }
|
||||
install_deno() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Agent CLIs
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Claude Code goes in through Anthropic's own installer rather than npm, matching
|
||||
# what the platform does for members (os-user-claude.ts) and chosen there for the
|
||||
# auto-update the npm package does not do.
|
||||
#
|
||||
# Two things that installer insists on, both of which a naive port gets wrong:
|
||||
#
|
||||
# It REFUSES to run under sudo from a regular user's shell — it checks for uid 0
|
||||
# with SUDO_USER set, because everything it installs goes under $HOME and under
|
||||
# sudo that is root's home. So it must run AS the account, not as root.
|
||||
#
|
||||
# It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash.
|
||||
# `| sh` fails on a dash-based /bin/sh, which is Ubuntu's.
|
||||
#
|
||||
# Both are recorded in os-user-claude.ts too, which found them first.
|
||||
|
||||
CLAUDE_INSTALL_URL="https://claude.ai/install.sh"
|
||||
OPENCODE_INSTALL_URL="https://opencode.ai/install"
|
||||
|
||||
# Where each installer actually puts its binary. They disagree, and the platform
|
||||
# depends on the difference:
|
||||
#
|
||||
# claude ~/.local/bin/claude — claude-manager.ts tries Bun.which then
|
||||
# that exact path
|
||||
# opencode ~/.opencode/bin/opencode — sidecar/opencode/index.ts:22 hardcodes
|
||||
# join(homedir(), '.opencode', 'bin', …)
|
||||
#
|
||||
# Looking for opencode in ~/.local/bin, as an earlier version of this did,
|
||||
# reports a perfectly good install as missing and then installs it again.
|
||||
agent_bin() {
|
||||
case "$1" in
|
||||
claude) echo "${USER_HOME}/.local/bin/claude" ;;
|
||||
opencode) echo "${USER_HOME}/.opencode/bin/opencode" ;;
|
||||
*) echo "${USER_HOME}/.local/bin/$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The directories those live in, for the account's PATH.
|
||||
agent_bin_dirs() { echo "${USER_HOME}/.local/bin" "${USER_HOME}/.opencode/bin"; }
|
||||
|
||||
agent_installed() { [[ -x "$(agent_bin "$1")" ]] || command -v "$1" &>/dev/null; }
|
||||
|
||||
# Which copy answers, so the run can say where it came from. Claude installed
|
||||
# from npm sits in /usr/local/lib/node_modules and does NOT auto-update, which is
|
||||
# the whole reason the platform prefers Anthropic's installer.
|
||||
agent_path() {
|
||||
local name="$1" bin
|
||||
bin="$(agent_bin "$name")"
|
||||
[[ -x "$bin" ]] && {
|
||||
echo "$bin"
|
||||
return
|
||||
}
|
||||
command -v "$name" 2>/dev/null || true
|
||||
}
|
||||
|
||||
agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" == */node_modules/* ]]; }
|
||||
|
||||
agent_version() {
|
||||
local bin
|
||||
bin="$(agent_path "$1")"
|
||||
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
|
||||
}
|
||||
|
||||
install_claude_code() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
[[ -x "$(agent_bin claude)" ]]
|
||||
}
|
||||
|
||||
install_opencode() {
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
[[ -x "$(agent_bin opencode)" ]]
|
||||
}
|
||||
|
||||
install_pi() { npm install -g @mariozechner/pi-coding-agent >/dev/null 2>&1; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Default editor
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# One preference, two mechanisms, and both are needed:
|
||||
#
|
||||
# EDITOR / VISUAL what the account's own shell hands to git, crontab -e,
|
||||
# systemctl edit and anything else that opens an editor
|
||||
# update-alternatives the system-wide `editor` command, which is what root and
|
||||
# `sudoedit` use — an account's shell config cannot reach
|
||||
# those
|
||||
#
|
||||
# This is the setting core.editor was deliberately left out in favour of: set it
|
||||
# here and git follows, along with everything else.
|
||||
|
||||
editor_candidates() {
|
||||
local e
|
||||
for e in nvim vim nano; do command -v "$e" &>/dev/null && echo "$e"; done
|
||||
}
|
||||
|
||||
# `|| true` for the same reason git_get has it: "not set" is an answer, and an
|
||||
# assignment from a function that exits non-zero aborts the run under `set -e`.
|
||||
current_editor() { (cd / && sudo -H -u "$USERNAME" bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
|
||||
|
||||
set_system_editor() {
|
||||
local editor="$1" path
|
||||
path="$(command -v "$editor")" || return 1
|
||||
# Only where the alternatives system is in use. Absent on non-Debian systems,
|
||||
# where there is nothing to set.
|
||||
command -v update-alternatives &>/dev/null || return 0
|
||||
update-alternatives --install /usr/bin/editor editor "$path" 100 >/dev/null 2>&1
|
||||
update-alternatives --set editor "$path" >/dev/null 2>&1
|
||||
}
|
||||
@@ -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,112 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — Docker
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_DOCKER_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_DOCKER_LOADED=1
|
||||
|
||||
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
|
||||
|
||||
docker_is_installed() { command -v docker &>/dev/null; }
|
||||
|
||||
# The daemon, not just the binary. `docker --version` answers from the client
|
||||
# alone and says nothing about whether there is anything to talk to.
|
||||
docker_daemon_ok() { docker info &>/dev/null; }
|
||||
|
||||
user_in_docker_group() { id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx docker; }
|
||||
|
||||
docker_rootless_installed() { [[ -S "/run/user/$(id -u "$USERNAME" 2>/dev/null)/docker.sock" ]]; }
|
||||
|
||||
# The codename Docker's repository is actually published under.
|
||||
#
|
||||
# `lsb_release -cs` is what the original used, and it is wrong on every
|
||||
# derivative: Mint reports "vanessa", Pop reports its own, and Docker publishes
|
||||
# neither — so `apt update` fails on a repository that does not exist. os-release
|
||||
# carries UBUNTU_CODENAME on exactly those systems for exactly this reason, so it
|
||||
# is preferred and VERSION_CODENAME is the fallback.
|
||||
docker_repo_codename() {
|
||||
local c
|
||||
c="$(os_release UBUNTU_CODENAME || true)"
|
||||
[[ -z "$c" ]] && c="$(os_release VERSION_CODENAME || true)"
|
||||
echo "$c"
|
||||
}
|
||||
|
||||
# Which upstream to point at. A derivative is Ubuntu or Debian as far as Docker
|
||||
# is concerned, and ID_LIKE is how it says which.
|
||||
docker_repo_distro() {
|
||||
case "$OS" in
|
||||
ubuntu | debian) echo "$OS" ;;
|
||||
*)
|
||||
case " $(os_release ID_LIKE || true) " in
|
||||
*" ubuntu "*) echo ubuntu ;;
|
||||
*) echo debian ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_docker_engine() {
|
||||
local distro codename
|
||||
distro="$(docker_repo_distro)"
|
||||
codename="$(docker_repo_codename)"
|
||||
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the Docker repository"
|
||||
return 1
|
||||
}
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL "https://download.docker.com/linux/${distro}/gpg" |
|
||||
gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${distro} ${codename} stable" \
|
||||
>/etc/apt/sources.list.d/docker.list
|
||||
|
||||
pkg_refresh >/dev/null
|
||||
pkg_install_now docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
}
|
||||
|
||||
# A shared network so containers from different compose files can reach each
|
||||
# other by name. Harmless if it is already there.
|
||||
ensure_docker_network() {
|
||||
docker network inspect "$DOCKER_NETWORK" &>/dev/null && return 0
|
||||
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ── Rootless, for the owner ──
|
||||
#
|
||||
# Works, and does not work with Officer's app store as it stands. Both are true
|
||||
# and the second is the one nobody would find out until a container failed to
|
||||
# provision, so it is stated at the prompt rather than left here.
|
||||
#
|
||||
# The app store spawns `docker` with no environment of its own —
|
||||
# app-store/compose.ts, app-store/preflight.ts, api/system-monitor — so it talks
|
||||
# to whatever socket the `officer` pm2 process's environment points at. That is
|
||||
# /var/run/docker.sock unless DOCKER_HOST says otherwise, and nothing sets
|
||||
# DOCKER_HOST for the owner: os-user-docker.ts sets it only for member commands.
|
||||
#
|
||||
# pm2 started at boot by systemd has no session either, so exporting it in a
|
||||
# shell rc does not reach the process that matters.
|
||||
install_docker_rootless() {
|
||||
local uid
|
||||
uid="$(id -u "$USERNAME")"
|
||||
|
||||
# Without lingering, the user manager stops when the last session ends and
|
||||
# takes the daemon with it. Officer's shells are not login sessions.
|
||||
loginctl enable-linger "$USERNAME" >/dev/null 2>&1
|
||||
|
||||
sudo -u "$USERNAME" \
|
||||
XDG_RUNTIME_DIR="/run/user/${uid}" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
|
||||
PATH="/usr/bin:/usr/sbin:/bin:/sbin" \
|
||||
dockerd-rootless-setuptool.sh install >/dev/null 2>&1 || return 1
|
||||
|
||||
sudo -u "$USERNAME" \
|
||||
XDG_RUNTIME_DIR="/run/user/${uid}" \
|
||||
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \
|
||||
systemctl --user enable --now docker >/dev/null 2>&1
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — writing files into somebody's home
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
#
|
||||
# ── The rule ──
|
||||
#
|
||||
# A setup script may create a config file. It may not silently replace one the
|
||||
# user wrote. The original did the second: `cp .tmux.conf $USER_HOME/` on every
|
||||
# run, over whatever was there, and five separate `cat >>` into .zshrc with no
|
||||
# guard — so a second pass duplicated the starship init, the nvim PATH, bun, deno
|
||||
# and the aliases.
|
||||
#
|
||||
# Both of those are the same mistake in different shapes: writing without looking
|
||||
# first. The two helpers here are the two safe shapes.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_FILES_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_FILES_LOADED=1
|
||||
|
||||
# Put a config file in place, asking before it replaces one the user has.
|
||||
#
|
||||
# Three outcomes, and the caller can tell them apart by the return code:
|
||||
#
|
||||
# 0 installed — there was nothing there, or the user chose to replace
|
||||
# 1 identical — already exactly this, nothing done
|
||||
# 2 kept — the user chose to keep theirs
|
||||
#
|
||||
# When the file exists and differs, this ASKS rather than deciding. Silently
|
||||
# keeping theirs is safe but unhelpful — they never learn that a newer version
|
||||
# exists — and silently replacing it is how a setup script eats somebody's
|
||||
#configuration. So: keep, replace, or show the difference first, and a replaced file is
|
||||
# always kept beside the new one.
|
||||
#
|
||||
# NOTE for callers: 1 and 2 are outcomes, not failures — but they are still
|
||||
# non-zero, so calling this as a plain command under `set -e` ends the script
|
||||
# before the result can be read. Always capture it:
|
||||
#
|
||||
# install_config "$src" "$dest" "$user" && rc=0 || rc=$?
|
||||
install_config() {
|
||||
local src="$1" dest="$2" owner="$3" answer
|
||||
|
||||
if [[ ! -f "$dest" ]]; then
|
||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||
return 0
|
||||
fi
|
||||
|
||||
cmp -s "$src" "$dest" && return 1
|
||||
|
||||
echo ""
|
||||
warn "${dest} already exists here, and differs from the one this script ships."
|
||||
|
||||
# Never replace a file the user has without being told to. An unattended run
|
||||
# answers "keep", because the alternative is destroying configuration nobody
|
||||
# was present to defend.
|
||||
if [[ "${ASSUME_YES:-}" == "1" ]] || [[ ! -t 0 ]]; then
|
||||
echo " keeping yours (nothing was asked, so nothing is replaced)"
|
||||
return 2
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo " [1] keep yours — nothing changes"
|
||||
echo " [2] use ours — yours is kept as ${dest}.before-machine-setup"
|
||||
echo " [3] show me the difference first"
|
||||
echo ""
|
||||
if ! read -rp " Which one? (1/2/3) [1]: " answer; then
|
||||
echo ""
|
||||
echo " keeping yours"
|
||||
return 2
|
||||
fi
|
||||
case "${answer:-1}" in
|
||||
1)
|
||||
echo " keeping yours"
|
||||
return 2
|
||||
;;
|
||||
2)
|
||||
cp -a "$dest" "${dest}.before-machine-setup"
|
||||
install -D -m 0644 -o "$owner" -g "$(user_group "$owner")" "$src" "$dest"
|
||||
ok "replaced — yours is at ${dest}.before-machine-setup"
|
||||
return 0
|
||||
;;
|
||||
3)
|
||||
echo ""
|
||||
# yours on the left, ours on the right: - is what you would lose,
|
||||
# + is what you would gain.
|
||||
diff -u --label "yours: ${dest}" --label "ours: ${src}" "$dest" "$src" | page
|
||||
echo ""
|
||||
;;
|
||||
*) warn "Pick 1, 2 or 3." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Append a block to a file exactly once.
|
||||
#
|
||||
# The block is wrapped in markers naming what it is, so a second run recognises
|
||||
# its own work instead of adding it again — and so a human reading the file can
|
||||
# see which lines came from here and delete them as a unit.
|
||||
#
|
||||
# append_once ~/.zshrc bun <<'EOF'
|
||||
# export PATH="$HOME/.bun/bin:$PATH"
|
||||
# EOF
|
||||
#
|
||||
# Returns 0 if it wrote, 1 if the block was already there.
|
||||
#
|
||||
# One limitation, and it bites the author rather than the user: RENAMING a marker
|
||||
# orphans the block that used the old name. append_once only recognises the name
|
||||
# it is given, so the previous block stays in the file doing whatever it did.
|
||||
# Changing a block's CONTENT has the same shape — the marker is found, so the new
|
||||
# content is never written. Both need the old block removed by hand.
|
||||
append_once() {
|
||||
local file="$1" name="$2"
|
||||
local begin="# >>> machine-setup: ${name} >>>"
|
||||
local end="# <<< machine-setup: ${name} <<<"
|
||||
|
||||
if [[ -f "$file" ]] && grep -qF "$begin" "$file"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "$begin"
|
||||
cat
|
||||
echo "$end"
|
||||
} >>"$file"
|
||||
}
|
||||
@@ -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,264 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — distro packages
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
|
||||
#
|
||||
# ── The rule: install what is missing, never touch what is there ──
|
||||
#
|
||||
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
|
||||
# the repository has a newer one. On a machine somebody already uses, that can
|
||||
# move a version they chose deliberately, and the setup script is the last thing
|
||||
# that should be doing that behind their back.
|
||||
#
|
||||
# So every install here goes through pkg_install, which queries the package
|
||||
# database first, installs only the subset that is genuinely absent, and prints
|
||||
# both lists before doing it. A package already present is never named on a
|
||||
# command line at all.
|
||||
#
|
||||
# ── Why per-package-manager lists rather than a translation table ──
|
||||
#
|
||||
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
|
||||
# and some packages are not a package elsewhere at all: apt-transport-https,
|
||||
# lsb-release and software-properties-common are apt concepts. A canonical-name
|
||||
# table with per-manager overrides hides both of those behind indirection. A
|
||||
# plain `case $PM` says what each system actually gets, in one place, and matches
|
||||
# the shape scripts/setup-old/setup.sh already used.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_PACKAGES_LOADED=1
|
||||
|
||||
# What the last pkg_install/tools_install actually put on the machine, as opposed
|
||||
# to what it was asked for. Read by the caller to write an honest summary line:
|
||||
# without it every section reports its whole list as installed, including the
|
||||
# packages it deliberately left alone.
|
||||
LAST_INSTALLED=()
|
||||
LAST_KEPT=()
|
||||
LAST_SKIPPED=()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The sections
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Core: what this script itself would break without, plus the command-line tools
|
||||
# that make a machine worth sitting at.
|
||||
#
|
||||
# The first six are load-bearing and each is used by a later step — curl fetches
|
||||
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
|
||||
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
|
||||
# archive, and ca-certificates is what makes any of the fetching work. The rest
|
||||
# are the environment: nothing calls them, they are here because a box you use
|
||||
# should have them.
|
||||
#
|
||||
# Four entries earn a note.
|
||||
#
|
||||
# python3 is not a tool anybody here calls — it is node-gyp's build dependency,
|
||||
# and node-gyp is not optional on Linux. node-pty ships prebuilt binaries for
|
||||
# darwin and win32 ONLY, so on Linux its install script always falls through to
|
||||
# `node-gyp rebuild` and compiles from source. Without python3 that fails, and
|
||||
# the failure surfaces as a broken terminal sidecar rather than as a missing
|
||||
# package. build-essential below is the other half of the same requirement.
|
||||
#
|
||||
# unattended-upgrades installs updates on a timer with nobody watching. apt only:
|
||||
# it is a Debian and Ubuntu package, dnf's equivalent is dnf-automatic and pacman
|
||||
# has no equivalent at all, so it is not a name to translate. Installing the
|
||||
# package is not by itself enough to switch it on — /etc/apt/apt.conf.d/20auto-upgrades
|
||||
# is what the apt-daily timers read, and on this host no package owns that file.
|
||||
# The section makes sure it is there.
|
||||
#
|
||||
# fail2ban is not a tool, it is a daemon: installing it starts it, and Ubuntu
|
||||
# ships /etc/fail2ban/jail.d/defaults-debian.conf with `[sshd] enabled = true`.
|
||||
# Verified on this host — maxretry 5, findtime 600, bantime 600 — so from the
|
||||
# moment it installs, an address failing to log in five times in ten minutes is
|
||||
# blocked for ten, including yours. That is the point of it and it is worth
|
||||
# having by default, but it is why it belongs in this comment rather than being
|
||||
# thought of as one more binary. An existing install with its own jails is
|
||||
# untouched, because pkg_install never names a package that is already there.
|
||||
#
|
||||
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
|
||||
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
|
||||
# distribution's default alongside it. It stays in core because anything that
|
||||
# compiles a native module needs it, but it is the one to move out first if that
|
||||
# ever bites.
|
||||
pkgs_core() {
|
||||
case "$PM" in
|
||||
apt)
|
||||
# apt-transport-https, lsb-release and software-properties-common are not
|
||||
# tools — they are what lets later steps add the Docker repository and the
|
||||
# fastfetch PPA. They have no counterpart on the other systems.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools \
|
||||
fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools \
|
||||
fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools \
|
||||
fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula.
|
||||
echo gnupg git jq wget btop htop tree tmux ripgrep fd
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Querying
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Is this package installed right now?
|
||||
#
|
||||
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
|
||||
# package that was removed but left its config behind — that state would be read
|
||||
# as "present" and the package would never be reinstalled.
|
||||
pkg_is_installed() {
|
||||
case "$PM" in
|
||||
apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
|
||||
pacman) pacman -Qi "$1" &>/dev/null ;;
|
||||
dnf) rpm -q "$1" &>/dev/null ;;
|
||||
brew) brew list --formula "$1" &>/dev/null ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Acting
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Refresh the package index.
|
||||
#
|
||||
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
|
||||
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
|
||||
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
|
||||
# rather than at each call site, because forgetting one turns an unattended run
|
||||
# into one that is silently waiting for a keypress.
|
||||
pkg_refresh() {
|
||||
case "$PM" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
|
||||
pacman) pacman -Sy --noconfirm ;;
|
||||
dnf) dnf makecache ;;
|
||||
brew) brew update ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# What an upgrade would actually move, one package name per line.
|
||||
#
|
||||
# Asked before the upgrade runs so the section can name what it is about to
|
||||
# change rather than asking to be trusted. Needs a refreshed index to be
|
||||
# accurate, which is why pkg_refresh runs first.
|
||||
#
|
||||
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
|
||||
# which is the same calculation the real run does — as opposed to
|
||||
# `apt list --upgradable`, which also lists packages that are held back and
|
||||
# would not actually move.
|
||||
pkg_upgradable() {
|
||||
case "$PM" in
|
||||
apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
|
||||
pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
|
||||
dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
|
||||
brew) brew outdated --quiet 2>/dev/null ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Upgrade everything already installed. Separate from pkg_install on purpose:
|
||||
# this one DOES move versions, so it is a deliberate step rather than something
|
||||
# that happens as a side effect of installing a tool.
|
||||
pkg_upgrade_all() {
|
||||
case "$PM" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
|
||||
pacman) pacman -Su --noconfirm ;;
|
||||
dnf) dnf upgrade -y ;;
|
||||
brew) brew upgrade ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The raw install, with no presence check. Use pkg_install instead.
|
||||
pkg_install_now() {
|
||||
case "$PM" in
|
||||
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
|
||||
pacman) pacman -S --noconfirm --needed "$@" ;;
|
||||
dnf) dnf install -y "$@" ;;
|
||||
brew) brew install "$@" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Announce a section, then install only what is absent from it.
|
||||
#
|
||||
# pkg_install "Core packages" $(pkgs_core)
|
||||
#
|
||||
# Prints both lists before touching anything, so the run says what it is about to
|
||||
# do to this machine and what it is deliberately leaving alone. Returns 0 when
|
||||
# there was nothing to do.
|
||||
pkg_install() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
local pkg
|
||||
local -a missing=() present=()
|
||||
LAST_SKIPPED=()
|
||||
for pkg in "$@"; do
|
||||
if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
|
||||
done
|
||||
|
||||
LAST_INSTALLED=("${missing[@]}")
|
||||
LAST_KEPT=("${present[@]}")
|
||||
|
||||
announce_plan "$label" present missing || return 0
|
||||
pkg_install_now "${missing[@]}"
|
||||
}
|
||||
|
||||
# Print what a section is about to do and ask permission for it.
|
||||
#
|
||||
# Takes the NAMES of the two arrays rather than their contents, because a list
|
||||
# passed by value cannot be told apart from an empty one once it has been through
|
||||
# word splitting.
|
||||
#
|
||||
# Returns non-zero when there is nothing to do, or when the answer was no — in
|
||||
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
|
||||
# refusal so the summary does not claim work that never happened.
|
||||
announce_plan() {
|
||||
local label="$1"
|
||||
local -n _present="$2"
|
||||
local -n _missing="$3"
|
||||
|
||||
echo ""
|
||||
info "${label} — installs what is missing, keeps what you already have"
|
||||
((${#_present[@]})) && echo " already here: ${_present[*]}"
|
||||
|
||||
if ((${#_missing[@]} == 0)); then
|
||||
echo " to install: nothing, all present"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " to install: ${_missing[*]}"
|
||||
if ! confirm "Proceed?"; then
|
||||
warn "skipped by request"
|
||||
LAST_INSTALLED=()
|
||||
LAST_SKIPPED=("${_missing[@]}")
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# One summary line describing what a section actually did, from LAST_INSTALLED
|
||||
# and LAST_KEPT. Call straight after pkg_install or tools_install.
|
||||
summarise_last() {
|
||||
local label="$1"
|
||||
if ((${#LAST_SKIPPED[@]})); then
|
||||
SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
|
||||
elif ((${#LAST_INSTALLED[@]} == 0)); then
|
||||
SUMMARY+=("$label: already present, nothing installed")
|
||||
elif ((${#LAST_KEPT[@]} == 0)); then
|
||||
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
|
||||
else
|
||||
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
|
||||
fi
|
||||
}
|
||||
@@ -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,299 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — Tailscale
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
#
|
||||
# ── Why this runs early ──
|
||||
#
|
||||
# It is a second way into the machine. The section that can lock you out is SSH
|
||||
# hardening, and everything after this one can break networking in some smaller
|
||||
# way; having the tailnet up first means a mistake is recoverable rather than a
|
||||
# trip to a rescue console.
|
||||
#
|
||||
# ── Why it matters to Officer specifically ──
|
||||
#
|
||||
# The platform's CLAUDE.md is explicit: the perimeter IS the tailnet. Origin
|
||||
# checking was removed outright on 2026-08-13 because the tailnet stands in its
|
||||
# place, so a valid token plus the tailnet IS the lock — not one layer of two.
|
||||
# An Officer install with no tailnet is missing the half the design assumes.
|
||||
#
|
||||
# ── Why the original hung ──
|
||||
#
|
||||
# It passed --authkey unconditionally, and its prompt accepted an empty answer.
|
||||
# `tailscale up --authkey ""` falls back to interactive login: it prints a URL and
|
||||
# blocks, with no timeout, forever. Nothing here passes an empty key, every call
|
||||
# has a timeout, and the state is read before anything is run.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_TAILSCALE_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_TAILSCALE_LOADED=1
|
||||
|
||||
TS_EXIT_SYSCTL=/etc/sysctl.d/99-tailscale-exit.conf
|
||||
TS_DISPATCHER=/etc/networkd-dispatcher/routable.d/50-tailscale-exit
|
||||
|
||||
# Printed only when asked for. The section leads with the question rather than
|
||||
# with ten lines of explanation: somebody who runs Tailscale already does not need
|
||||
# to be told what it is, and somebody who does not can type ?.
|
||||
tailscale_help() {
|
||||
echo " Tailscale is a private network between your own machines, over"
|
||||
echo " WireGuard. Every device you enrol gets a stable 100.x address and"
|
||||
echo " can reach every other, wherever they are — through NAT, across"
|
||||
echo " providers, without either end having a public address."
|
||||
echo ""
|
||||
echo " Nothing is published to the open internet to make that work: no"
|
||||
echo " port forwarding, no exposed ports, no holes in the firewall."
|
||||
echo ""
|
||||
echo " For Officer it is not a convenience. The platform is built assuming"
|
||||
echo " the tailnet IS the perimeter, and there is no origin checking behind"
|
||||
echo " it — a valid token plus the tailnet is the whole lock. Without the"
|
||||
echo " tailnet you are running with half of it missing."
|
||||
echo ""
|
||||
echo " It is installed at this point in the run, before anything that can"
|
||||
echo " lock you out of the machine, so there is always a second way in."
|
||||
}
|
||||
|
||||
# The menu itself, in a function because it is shown twice — once to ask, and
|
||||
# again after ? has printed the long answer, so the reader is not dropped back at
|
||||
# a bare prompt having forgotten what the options were.
|
||||
tailscale_network_menu() {
|
||||
info "Which network should this machine join?"
|
||||
echo ""
|
||||
echo " [1] set up your own network — offscale"
|
||||
echo " Your own coordination server. The protocol on the wire is"
|
||||
echo " Tailscale's and the encryption is WireGuard's; offscale changes"
|
||||
echo " neither — it runs headscale's open-source code. What changes is"
|
||||
echo " the work: managed from an app rather than a terminal, and"
|
||||
echo " enrolling a device is a link and a tap."
|
||||
echo " offscale — just like headscale, and just like Tailscale's own"
|
||||
echo " service — needs to run on a publicly reachable server of its"
|
||||
echo " own. A small VPS is enough. Not this machine, not behind a home"
|
||||
echo " router: every device that joins has to find it, including phones"
|
||||
echo " on mobile data. The only difference from option 3 is who runs"
|
||||
echo " that server."
|
||||
echo " Follow that setup through first, then come back here with its"
|
||||
echo " address and a key."
|
||||
echo " https://officer.dev/infrastructure/offscale.html#install"
|
||||
echo ""
|
||||
echo " [2] use a network you already run — headscale or offscale"
|
||||
echo " You already have a coordination server somewhere. Point this"
|
||||
echo " machine at it and it joins that network alongside the rest."
|
||||
echo ""
|
||||
echo " [3] the easy route — tailscale.com"
|
||||
echo " Tailscale runs the coordination for you. Nothing to host and"
|
||||
echo " nothing to maintain, free for personal use; the trade is that"
|
||||
echo " the list of your machines lives with them."
|
||||
echo ""
|
||||
echo " [4] no private network at all"
|
||||
echo " This machine is reached over the open internet, or not at all."
|
||||
echo " Everything the tailnet was doing becomes yours to do."
|
||||
echo ""
|
||||
echo " [?] what are tailscale, headscale and offscale?"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# The long answer, printed when somebody types ?. Covers all three names,
|
||||
# because the menu offers all three and two of them are not words anyone outside
|
||||
# this project would know.
|
||||
tailscale_networks_help() {
|
||||
echo " Tailscale, headscale and offscale are three answers to one question:"
|
||||
echo " who keeps the list of your machines and hands out the keys they use"
|
||||
echo " to find each other."
|
||||
echo ""
|
||||
echo " The network itself is the same in all three cases. Machines talk"
|
||||
echo " directly to each other over WireGuard, encrypted end to end. What"
|
||||
echo " differs is only the coordination server — the thing that knows which"
|
||||
echo " machines are yours. It never carries your traffic."
|
||||
echo ""
|
||||
echo " TAILSCALE"
|
||||
echo " The company's own coordination server. Nothing to run, nothing to"
|
||||
echo " maintain, free for personal use. You sign in with an existing"
|
||||
echo " identity and your machines appear in their admin console."
|
||||
echo " The trade is that the list of your machines lives with them."
|
||||
echo ""
|
||||
echo " HEADSCALE"
|
||||
echo " An open-source coordination server you run yourself. The same"
|
||||
echo " Tailscale clients connect to it, so the machines behave identically;"
|
||||
echo " the difference is that nobody else holds the list. The cost is that"
|
||||
echo " it is now a service you host, and it needs to be reachable."
|
||||
echo ""
|
||||
echo " OFFSCALE"
|
||||
echo " Our own distribution of headscale, which is to say: headscale. The"
|
||||
echo " protocol on the wire is Tailscale's and the encryption is"
|
||||
echo " WireGuard's, and offscale changes neither — it runs the same"
|
||||
echo " open-source project. A machine on an offscale network behaves"
|
||||
echo " exactly as it would on either of the other two. There is no offscale"
|
||||
echo " protocol to be locked into, because there is no offscale protocol."
|
||||
echo ""
|
||||
echo " Clients: stock Tailscale on computers. On iPhone, iPad and Android"
|
||||
echo " there is our own app — the Tailscale client, our branding, and one"
|
||||
echo " real difference: it takes an invite from the server directly. That"
|
||||
echo " is the part of running headscale people give up at, because the"
|
||||
echo " official app has to be talked into using a server that is not"
|
||||
echo " Tailscale's. Desktop apps of our own are not there yet; on a"
|
||||
echo " computer you point the official client at your own server."
|
||||
echo ""
|
||||
# Where it runs matters more than how it installs, and is the thing people
|
||||
# get wrong: a coordination server at home is unreachable from exactly the
|
||||
# devices a private network exists to reach.
|
||||
echo " Where it runs: on a publicly reachable server of its own — a small"
|
||||
echo " VPS is enough. Not on this machine, and not behind a home router."
|
||||
echo " Every device that joins has to find it, including phones on mobile"
|
||||
echo " data and laptops in other buildings, so it needs an address that"
|
||||
echo " resolves from anywhere."
|
||||
echo ""
|
||||
echo " This is not something offscale asks for and the others do not. It"
|
||||
echo " is true of headscale, and it is true of Tailscale — their"
|
||||
echo " coordination server is publicly reachable too, they simply run it"
|
||||
echo " for you. That is the whole of the difference between choosing"
|
||||
echo " option 3 and choosing to host it yourself."
|
||||
echo ""
|
||||
echo " What it does that plain headscale does not:"
|
||||
echo " · installs in one command on that server, certificates included"
|
||||
echo " · health, logs, restarts and access policies from the app,"
|
||||
echo " instead of a config file and a CLI"
|
||||
echo " · enrolling a device is a link and a tap — the key is minted"
|
||||
echo " and handed over for you"
|
||||
echo " · several networks at once, and services reachable across them"
|
||||
echo ""
|
||||
echo " https://officer.dev/infrastructure/offscale.html"
|
||||
echo ""
|
||||
echo " FOR OFFICER"
|
||||
echo " Whichever you pick, the tailnet is what Officer treats as its"
|
||||
echo " perimeter, and it is not one layer of two — there is no origin"
|
||||
echo " checking behind it. Installed at this point in the run,"
|
||||
echo " before anything that can lock you out, so there is always a second"
|
||||
echo " way in."
|
||||
echo ""
|
||||
echo " Officer also administers it. Its Headscale app talks to headscale"
|
||||
echo " and offscale servers alike: register as many as you run, see which"
|
||||
echo " are actually up — each is probed, not remembered — and switch"
|
||||
echo " between them. On whichever is active you get the nodes, the users,"
|
||||
echo " the pre-auth keys, the invites and the ACL policy, with an"
|
||||
echo " assistant for writing it, plus a console and diagnostics. So the"
|
||||
echo " server this section sets up is managed from the same place as"
|
||||
echo " everything else on this machine, rather than over ssh and a CLI."
|
||||
}
|
||||
|
||||
tailscale_is_installed() { command -v tailscale &>/dev/null; }
|
||||
|
||||
# NeedsLogin, Running, Stopped, NoState… Read before acting, because the original's
|
||||
# failure was running `up` blindly against a node that was already up.
|
||||
tailscale_state() {
|
||||
tailscale status --json 2>/dev/null | awk -F'"' '/"BackendState"/ { print $4; exit }'
|
||||
}
|
||||
|
||||
tailscale_ip() { tailscale ip -4 2>/dev/null | head -1; }
|
||||
|
||||
# Which control plane this node is talking to. Empty means Tailscale's own.
|
||||
tailscale_control_url() {
|
||||
tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }'
|
||||
}
|
||||
|
||||
tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; }
|
||||
|
||||
# Tailscale's own coordination server, spelled out.
|
||||
#
|
||||
# Passed explicitly even when it is the default, because `tailscale up` with no
|
||||
# --login-server keeps whatever ControlURL is already stored. On a node already
|
||||
# pointed at a self-hosted server, choosing "the easy route" would otherwise
|
||||
# leave it exactly where it was — no error, no message, wrong answer.
|
||||
TS_DEFAULT_CONTROL_URL="https://controlplane.tailscale.com"
|
||||
|
||||
# Moving a node between coordination servers is not something `up` will do while
|
||||
# it is logged in to one. Logging out first is the documented way, and doing it
|
||||
# unasked would be worse than saying so.
|
||||
# What choosing "no private network" actually hands you, said before it is
|
||||
# chosen rather than discovered afterwards.
|
||||
tailscale_none_warning() {
|
||||
echo " Without a tailnet, everything it was doing becomes yours:"
|
||||
echo ""
|
||||
echo " · Anything you want to reach remotely has to be published to the"
|
||||
echo " open internet deliberately, and kept closed otherwise."
|
||||
echo " · TLS certificates are yours to obtain and to keep renewed."
|
||||
echo " · Every exposed service needs its own authentication, because"
|
||||
echo " there is no longer a network boundary in front of it."
|
||||
echo " · This machine will be found. Anything listening on a public"
|
||||
echo " address is scanned within minutes and attacked continuously."
|
||||
echo ""
|
||||
echo " For Officer specifically, this removes a layer that cannot be put"
|
||||
echo " back from a setting:"
|
||||
echo ""
|
||||
echo " There is no origin checking in the platform. It was removed"
|
||||
echo " because the tailnet is the perimeter, so a valid token plus the"
|
||||
echo " tailnet is the entire lock. With no tailnet, the token is the"
|
||||
echo " only thing left. Put an HTTPS reverse proxy in front of the"
|
||||
echo " platform and restrict who can reach it at the network layer."
|
||||
}
|
||||
|
||||
tailscale_needs_logout() {
|
||||
local current="$1" target="$2"
|
||||
[[ -n "$current" && -n "$target" && "$current" != "$target" ]]
|
||||
}
|
||||
|
||||
# Routing has to be on before this machine can forward anyone else's packets,
|
||||
# whether as an exit node or as a subnet router. Written as a drop-in so it is
|
||||
# visible as this script's doing.
|
||||
enable_ip_forwarding() {
|
||||
cat >"$TS_EXIT_SYSCTL" <<'EOF'
|
||||
# Written by machine-setup: required to forward traffic for other tailnet nodes,
|
||||
# as an exit node or as a subnet router.
|
||||
net.ipv4.ip_forward = 1
|
||||
net.ipv6.conf.all.forwarding = 1
|
||||
EOF
|
||||
sysctl --system >/dev/null 2>&1
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# UDP GRO forwarding, which Tailscale documents as roughly doubling throughput on
|
||||
# a node that forwards for others. Applied on every routable event rather than
|
||||
# once, because the settings are per-interface and do not survive the link going
|
||||
# down and back up.
|
||||
install_exit_node_tuning() {
|
||||
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
|
||||
|
||||
mkdir -p "$(dirname "$TS_DISPATCHER")"
|
||||
cat >"$TS_DISPATCHER" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Written by machine-setup. NIC offload settings for a Tailscale exit node or
|
||||
# subnet router — Tailscale's own recommendation for forwarding throughput.
|
||||
set -Eeuo pipefail
|
||||
|
||||
IF="${IFACE:-}"
|
||||
if [[ -z "${IF}" ]]; then
|
||||
IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')"
|
||||
fi
|
||||
|
||||
[[ -n "${IF}" ]] || exit 0
|
||||
command -v ethtool >/dev/null 2>&1 || exit 0
|
||||
|
||||
ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true
|
||||
ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true
|
||||
ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
chmod 755 "$TS_DISPATCHER"
|
||||
systemctl enable --now networkd-dispatcher >/dev/null 2>&1 || true
|
||||
|
||||
# And once now, for the interface that is already up.
|
||||
IFACE="$(default_iface)" bash "$TS_DISPATCHER" >/dev/null 2>&1 || true
|
||||
# Returns 0 whatever happens. This is an optional improvement, and a
|
||||
# function that ends on a failing command is fatal under `set -e` when it
|
||||
# is called as a plain command — which would abort the remaining sections
|
||||
# over something the run could simply report. The caller checks the outcome.
|
||||
return 0
|
||||
}
|
||||
|
||||
# The LAN this machine sits on, as a CIDR — the useful default for a subnet
|
||||
# router, and the number nobody remembers offhand.
|
||||
lan_cidr() {
|
||||
local iface
|
||||
iface="$(default_iface)"
|
||||
ip -4 route show dev "$iface" 2>/dev/null |
|
||||
awk '$1 ~ /\// && $1 !~ /^default/ { print $1; exit }'
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# machine-setup — command-line tools that do not come from the distribution
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only, like the other lib/ files.
|
||||
#
|
||||
# These four were buried inside "System Update & Essentials", after the package
|
||||
# install and with no announcement, so a run appeared to be installing system
|
||||
# packages and then started downloading tarballs and printing a shell tutorial.
|
||||
# They are their own concern: upstream binaries, fetched from upstream, on their
|
||||
# own release cadence.
|
||||
#
|
||||
# Each one is checked before it is fetched. The original re-ran every installer
|
||||
# on every run — which is how a machine that already had starship got it
|
||||
# reinstalled, along with its "add this to your ~/.zshrc" instructions, which we
|
||||
# do not want because this script writes the shell config itself.
|
||||
|
||||
[[ -n "${MACHINE_SETUP_TOOLS_LOADED:-}" ]] && return 0
|
||||
MACHINE_SETUP_TOOLS_LOADED=1
|
||||
|
||||
# The set installed on every machine, in the order they are fetched.
|
||||
tools_default() { echo lazydocker lazygit starship fastfetch; }
|
||||
|
||||
# The command that proves a tool is already here. Same as the tool name for all
|
||||
# four today, but kept as a mapping because that is not a rule — a package and
|
||||
# the binary it provides disagree often enough (fd-find/fdfind) to be worth the
|
||||
# indirection.
|
||||
tool_command() {
|
||||
case "$1" in
|
||||
lazydocker) echo lazydocker ;;
|
||||
lazygit) echo lazygit ;;
|
||||
starship) echo starship ;;
|
||||
fastfetch) echo fastfetch ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The installers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
tool_install_lazydocker() {
|
||||
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
|
||||
DIR=/usr/local/bin bash
|
||||
}
|
||||
|
||||
# The one that was actually broken on arm64: the asset name was hardcoded to
|
||||
# x86_64, so an arm machine downloaded a 404 and tar failed halfway through the
|
||||
# run. lazygit spells the architectures x86_64 and arm64, which is neither of the
|
||||
# two spellings ARCH uses, hence the mapping.
|
||||
tool_install_lazygit() {
|
||||
local version asset url
|
||||
case "$ARCH" in
|
||||
amd64) asset="x86_64" ;;
|
||||
arm64) asset="arm64" ;;
|
||||
esac
|
||||
|
||||
version="$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest | jq -r '.tag_name')"
|
||||
# Strip only the leading v. The original used `tr -d 'v'`, which deletes every
|
||||
# v in the string and would mangle any tag that had one anywhere else.
|
||||
version="${version#v}"
|
||||
[[ -n "$version" ]] || {
|
||||
warn "could not read the latest lazygit version — skipping"
|
||||
return 0
|
||||
}
|
||||
|
||||
url="https://github.com/jesseduffield/lazygit/releases/download/v${version}/lazygit_${version}_Linux_${asset}.tar.gz"
|
||||
curl -fsSLo /tmp/lazygit.tar.gz "$url"
|
||||
tar -C /usr/local/bin -xzf /tmp/lazygit.tar.gz lazygit
|
||||
rm -f /tmp/lazygit.tar.gz
|
||||
}
|
||||
|
||||
# Quiet on purpose. The installer ends by printing how to add starship to bash,
|
||||
# zsh, ion, tcsh and xonsh — five shells' worth of instructions for a step that
|
||||
# already writes the zsh config itself. Errors still come through.
|
||||
tool_install_starship() {
|
||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
||||
}
|
||||
|
||||
# A distribution package everywhere, but not always one the distribution ships:
|
||||
# Ubuntu only picked fastfetch up in 24.10, so on noble and older the PPA is the
|
||||
# only source. Checked rather than assumed, so the PPA stops being added the
|
||||
# moment the archive has it.
|
||||
tool_install_fastfetch() {
|
||||
if [[ "$PM" == "apt" ]] && ! apt-cache policy fastfetch 2>/dev/null | grep -q 'Candidate: [0-9]'; then
|
||||
info " fastfetch is not in this release's archive — adding the upstream PPA"
|
||||
add-apt-repository -y ppa:zhangsongcui3371/fastfetch >/dev/null 2>&1 ||
|
||||
{
|
||||
warn "could not add the fastfetch PPA — skipping"
|
||||
return 0
|
||||
}
|
||||
pkg_refresh >/dev/null 2>&1
|
||||
fi
|
||||
pkg_install_now fastfetch
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Acting
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Announce the section, then fetch only what is absent — same contract and same
|
||||
# output shape as pkg_install, so the two read alike in a transcript.
|
||||
tools_install() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
local tool
|
||||
local -a missing=() present=()
|
||||
LAST_SKIPPED=()
|
||||
for tool in "$@"; do
|
||||
if tool_is_installed "$tool"; then present+=("$tool"); else missing+=("$tool"); fi
|
||||
done
|
||||
|
||||
LAST_INSTALLED=("${missing[@]}")
|
||||
LAST_KEPT=("${present[@]}")
|
||||
|
||||
announce_plan "$label" present missing || return 0
|
||||
|
||||
for tool in "${missing[@]}"; do
|
||||
info " installing ${tool}..."
|
||||
"tool_install_${tool}"
|
||||
done
|
||||
}
|
||||
Executable
+2401
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
+508
@@ -0,0 +1,508 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# =============================================================================
|
||||
# officer-setup — the platform, on a machine that is already provisioned
|
||||
#
|
||||
# The second half of the install. machine-setup/ brings a blank box up to a
|
||||
# usable machine; this puts Officer on top of it.
|
||||
#
|
||||
# Run as root: sudo scripts/setup/officer-setup.sh
|
||||
# =============================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
|
||||
|
||||
ONLY_STEP=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--only)
|
||||
ONLY_STEP="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--only=*)
|
||||
ONLY_STEP="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
echo "usage: officer-setup.sh [--only <step>] [--list]"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "unknown option: $1" >&2 && exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# shellcheck source=officer-setup/lib/base.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
|
||||
# shellcheck source=officer-setup/lib/preflight.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
|
||||
# shellcheck source=officer-setup/lib/repo.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/repo.sh"
|
||||
# shellcheck source=officer-setup/lib/layout.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
|
||||
# shellcheck source=officer-setup/lib/postgres.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
|
||||
# shellcheck source=officer-setup/lib/env.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/env.sh"
|
||||
|
||||
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
|
||||
|
||||
# =============================================================================
|
||||
# 1. Pre-flight
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}║ Officer Setup ║${NC}"
|
||||
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
fail "Please run as root: sudo ./officer-setup.sh"
|
||||
fi
|
||||
|
||||
# ── what machine-setup already established ──
|
||||
echo ""
|
||||
if load_machine_answers; then
|
||||
info "Read from machine-setup: ${MACHINE_ANSWERS}"
|
||||
else
|
||||
warn "machine-setup has not run on this machine"
|
||||
echo " That is fine if you provisioned it another way — the questions it"
|
||||
echo " would have answered are asked below instead."
|
||||
fi
|
||||
|
||||
# ── the account ──
|
||||
#
|
||||
# A remembered answer can go stale: the account it names may have been renamed or
|
||||
# removed since machine-setup ran. That is a reason to ask again, not a reason to
|
||||
# stop — so the remembered value is checked before it is trusted, and a bad one
|
||||
# is reported and replaced rather than ending the run.
|
||||
if [[ -n "$USERNAME" ]] && ! owner_exists; then
|
||||
warn "the remembered account '${USERNAME}' does not exist on this machine any more"
|
||||
USERNAME=""
|
||||
fi
|
||||
|
||||
while [[ -z "$USERNAME" ]] || ! owner_exists; do
|
||||
echo ""
|
||||
info "Which account owns this Officer install?"
|
||||
echo " Its files, its node_modules and its pm2 process list all belong to"
|
||||
echo " this account rather than to root."
|
||||
echo ""
|
||||
ask_required USERNAME "Username" "${SUDO_USER:-}"
|
||||
owner_exists || warn "There is no account called '${USERNAME}' on this machine."
|
||||
done
|
||||
|
||||
resolve_user_home
|
||||
|
||||
# ── where it goes ──
|
||||
if [[ -z "$OFFICER_ROOT" ]]; then
|
||||
echo ""
|
||||
info "Where should Officer be installed?"
|
||||
echo " One directory holding the app, its data, the item store and any"
|
||||
echo " containers the app store provisions."
|
||||
echo ""
|
||||
ask_required OFFICER_ROOT "Path" "${USER_HOME}/officerdev"
|
||||
fi
|
||||
OFFICER_ROOT="${OFFICER_ROOT/#\~/$USER_HOME}"
|
||||
[[ "$OFFICER_ROOT" == /* ]] || fail "That needs to be an absolute path — got '${OFFICER_ROOT}'"
|
||||
OFFICER_ROOT="${OFFICER_ROOT%/}"
|
||||
|
||||
info "Account: ${USERNAME} (home ${USER_HOME})"
|
||||
info "Officer: ${OFFICER_ROOT}"
|
||||
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
|
||||
|
||||
# ── is the machine actually ready ──
|
||||
#
|
||||
# Checked and reported together. Finding out about a missing bun three sections
|
||||
# in, after a repository has been cloned and a database started, is a worse way
|
||||
# to learn it.
|
||||
echo ""
|
||||
info "What Officer needs from this machine"
|
||||
|
||||
mapfile -t MISSING < <(missing_tools)
|
||||
mapfile -t MISSING_OPT < <(missing_optional_tools)
|
||||
|
||||
for t in "${REQUIRED_TOOLS[@]}"; do
|
||||
if command -v "$t" &>/dev/null; then
|
||||
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
|
||||
else
|
||||
printf ' %-6s %-10s %s\n' "$t" "MISSING" "$(tool_why "$t")"
|
||||
fi
|
||||
done
|
||||
for t in "${OPTIONAL_TOOLS[@]}"; do
|
||||
if command -v "$t" &>/dev/null; then
|
||||
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
|
||||
else
|
||||
printf ' %-6s %-10s %s\n' "$t" "absent" "$(tool_why "$t") — optional"
|
||||
fi
|
||||
done
|
||||
|
||||
if ((${#MISSING[@]} > 0)); then
|
||||
echo ""
|
||||
fail "Missing: ${MISSING[*]}. Run scripts/setup/machine-setup/machine-setup.sh first, or install them yourself."
|
||||
fi
|
||||
|
||||
if ((${#MISSING_OPT[@]} > 0)); then
|
||||
echo ""
|
||||
warn "No Docker. Postgres will have to be one you already run, and the app"
|
||||
echo " store cannot provision anything until Docker is installed."
|
||||
fi
|
||||
|
||||
if [[ -f "$PROGRESS_FILE" ]]; then
|
||||
echo ""
|
||||
info "Resuming — $(wc -l <"$PROGRESS_FILE") step(s) already done, and they will be skipped"
|
||||
echo " To start over instead: sudo rm ${PROGRESS_FILE}"
|
||||
else
|
||||
echo ""
|
||||
echo " This can be stopped at any point and run again later. Completed"
|
||||
echo " steps are remembered and skipped."
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 2. Layout
|
||||
# =============================================================================
|
||||
#
|
||||
# Before the repository, because the repository is cloned into it.
|
||||
|
||||
step "Layout"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Layout — everything Officer owns, under one root"
|
||||
echo " ${OFFICER_ROOT}/"
|
||||
echo " platform/ the app"
|
||||
echo " data/ managed homes, attachments, job logs"
|
||||
echo " dockers/ anything the app store provisions"
|
||||
echo " capabilities/ skills, tools, tasks, processes"
|
||||
echo ""
|
||||
echo " Nothing here is configurable. The original asked separately for the"
|
||||
echo " data directory and the item store, which were two answers that had"
|
||||
echo " to agree with each other. One root now, and the rest follows."
|
||||
echo ""
|
||||
echo " To put data/ on a bigger volume later, symlink it — that is a"
|
||||
echo " decision about storage rather than about how Officer is laid out."
|
||||
|
||||
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
|
||||
if ((${#WRONG_OWNER[@]} > 0)); then
|
||||
echo ""
|
||||
warn "these exist but do not belong to ${USERNAME}:"
|
||||
printf ' %s\n' "${WRONG_OWNER[@]}"
|
||||
echo " Everything that writes into them runs as ${USERNAME} — the platform"
|
||||
echo " under pm2, the app store's compose files, the item store the agent"
|
||||
echo " authors into. Left as they are, those writes fail in a way that"
|
||||
echo " reads as a bug in the platform."
|
||||
if confirm "Give them to ${USERNAME}?"; then
|
||||
for d in "${WRONG_OWNER[@]}"; do chown -R "${USERNAME}:$(user_group)" "$d"; done
|
||||
ok "ownership corrected"
|
||||
SUMMARY+=("Layout: ownership corrected on ${#WRONG_OWNER[@]} directory(ies)")
|
||||
fi
|
||||
fi
|
||||
|
||||
create_layout
|
||||
ok "layout in place under ${OFFICER_ROOT}"
|
||||
SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)")
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3. Repository
|
||||
# =============================================================================
|
||||
|
||||
step "Repository"
|
||||
if ! skip; then
|
||||
PLATFORM_DIR="$(platform_dir)"
|
||||
|
||||
echo ""
|
||||
info "Repository — where the platform's code lives"
|
||||
echo " path: ${PLATFORM_DIR}"
|
||||
|
||||
if repo_exists; then
|
||||
echo " remote: $(repo_remote)"
|
||||
echo " branch: $(repo_branch)"
|
||||
echo " working: $(repo_is_dirty && echo 'has uncommitted changes' || echo 'clean')"
|
||||
|
||||
# Reported, never silently corrected. Repointing somebody's remote is a
|
||||
# decision about where their work goes, and this script is not entitled to
|
||||
# make it quietly.
|
||||
if [[ -n "$(repo_remote)" && "$(repo_remote)" != "$OFFICER_REPO" ]]; then
|
||||
echo ""
|
||||
warn "this checkout points somewhere other than ${OFFICER_REPO}"
|
||||
echo " Left alone. To move it:"
|
||||
echo " git -C ${PLATFORM_DIR} remote set-url origin ${OFFICER_REPO}"
|
||||
fi
|
||||
|
||||
if repo_is_dirty; then
|
||||
echo ""
|
||||
echo " not pulling — there are uncommitted changes here, and a pull"
|
||||
echo " would either fail or bury them"
|
||||
SUMMARY+=("Repository: present at ${PLATFORM_DIR}, left alone (uncommitted changes)")
|
||||
elif confirm "Pull the latest changes?"; then
|
||||
if pull_repo; then
|
||||
ok "up to date on $(repo_branch)"
|
||||
SUMMARY+=("Repository: pulled, on $(repo_branch)")
|
||||
else
|
||||
# --ff-only, so this means the branch has diverged rather than that the
|
||||
# network failed. Saying which matters.
|
||||
warn "could not fast-forward — the local branch has diverged from the remote"
|
||||
ERRORS+=("Repository: pull refused, branch diverged")
|
||||
SUMMARY+=("Repository: present, pull refused (diverged)")
|
||||
fi
|
||||
else
|
||||
SUMMARY+=("Repository: present at ${PLATFORM_DIR}")
|
||||
fi
|
||||
|
||||
else
|
||||
echo " nothing there yet"
|
||||
echo ""
|
||||
info "Clone from ${OFFICER_REPO}?"
|
||||
echo " Cloned as ${USERNAME}, not as root — a repository owned by root is"
|
||||
echo " one you cannot pull, commit in, or install into."
|
||||
|
||||
CLONE_URL="$OFFICER_REPO"
|
||||
|
||||
if confirm "Clone it now?"; then
|
||||
if clone_repo "$CLONE_URL"; then
|
||||
ok "cloned to ${PLATFORM_DIR} on $(repo_branch)"
|
||||
SUMMARY+=("Repository: cloned from ${CLONE_URL}")
|
||||
else
|
||||
# GIT_TERMINAL_PROMPT=0 in clone_repo means this is a real failure rather
|
||||
# than a prompt nobody answered.
|
||||
fail "could not clone ${CLONE_URL} — nothing below can run without it."
|
||||
fi
|
||||
else
|
||||
fail "Nothing below can run without the repository."
|
||||
fi
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 4. Dependencies
|
||||
# =============================================================================
|
||||
|
||||
step "Dependencies"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Dependencies — bun install, as ${USERNAME}"
|
||||
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
|
||||
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
|
||||
echo ""
|
||||
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
|
||||
echo " so a package.json that disagrees with it fails rather than quietly"
|
||||
echo " picking newer versions. That friction is deliberate."
|
||||
echo ""
|
||||
echo " node-pty has no Linux prebuild, so this compiles it from source"
|
||||
echo " every time — which is what build-essential and python3 are for."
|
||||
|
||||
if deps_installed && node_pty_built; then
|
||||
ok "already installed, and node-pty is built"
|
||||
SUMMARY+=("Dependencies: already installed")
|
||||
elif confirm "Install them?"; then
|
||||
if install_deps; then
|
||||
if node_pty_built; then
|
||||
ok "installed, node-pty built"
|
||||
SUMMARY+=("Dependencies: installed")
|
||||
else
|
||||
# The install can succeed while the native module does not get built —
|
||||
# bun skips a dependency's lifecycle scripts unless it trusts the
|
||||
# package. Worth naming, because the symptom is a terminal that never
|
||||
# comes up rather than an install error.
|
||||
warn "installed, but node-pty has no built module at node_modules/node-pty/build/Release/"
|
||||
echo " The terminal sidecar cannot start without it. Try:"
|
||||
echo " cd $(platform_dir) && bun install --force"
|
||||
ERRORS+=("Dependencies: node-pty not built")
|
||||
SUMMARY+=("Dependencies: installed, node-pty NOT built")
|
||||
fi
|
||||
else
|
||||
warn "bun install failed"
|
||||
echo " If it complained about the lockfile, package.json and bun.lock"
|
||||
echo " disagree — that is the frozen lockfile doing its job, and it"
|
||||
echo " wants a human to look at the diff."
|
||||
ERRORS+=("Dependencies: bun install failed")
|
||||
SUMMARY+=("Dependencies: FAILED")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request"
|
||||
SUMMARY+=("Dependencies: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 5. Database
|
||||
# =============================================================================
|
||||
#
|
||||
# POSTGRES_URL is set here and written by the environment section below.
|
||||
|
||||
step "Database"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Database — Postgres, the only one Officer has"
|
||||
echo " It holds the account, passkeys, settings, dashboards, email"
|
||||
echo " accounts and the job queue. Nothing else in the platform is a"
|
||||
echo " database."
|
||||
echo ""
|
||||
# One network for everything Officer provisions. Created before the compose
|
||||
# file references it, since it is declared external there.
|
||||
if ensure_docker_network; then
|
||||
ok "docker network '${OFFICER_NETWORK}' created"
|
||||
SUMMARY+=("Docker network: ${OFFICER_NETWORK} created")
|
||||
elif docker_network_exists; then
|
||||
echo " network: ${OFFICER_NETWORK} (already there)"
|
||||
fi
|
||||
|
||||
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
||||
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
||||
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
||||
|
||||
POSTGRES_URL=""
|
||||
|
||||
# An existing compose file means this ran before. Reuse its password rather
|
||||
# than minting a new one, which would leave the container and the URL
|
||||
# disagreeing about the credential.
|
||||
if pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"; then
|
||||
POSTGRES_URL="$(pg_url "$PG_EXISTING_PASSWORD")"
|
||||
echo ""
|
||||
echo " already provisioned here — reusing the password from $(pg_env_file)"
|
||||
pg_container_running || {
|
||||
info " starting it"
|
||||
pg_compose_up >/dev/null 2>&1 || true
|
||||
}
|
||||
if pg_wait_ready; then
|
||||
ok "postgres answering on 127.0.0.1:${PG_PORT}"
|
||||
SUMMARY+=("Database: existing Postgres at ${PG_CONTAINER}")
|
||||
else
|
||||
warn "the container is not answering — check: docker logs ${PG_CONTAINER}"
|
||||
ERRORS+=("Database: provisioned but not answering")
|
||||
fi
|
||||
|
||||
else
|
||||
echo ""
|
||||
info "Which Postgres should Officer use?"
|
||||
echo ""
|
||||
echo " [1] provision one here"
|
||||
echo " ${PG_IMAGE} in $(pg_service_dir), bound to 127.0.0.1 only."
|
||||
echo " Docker publishes ports by writing iptables rules beneath ufw,"
|
||||
echo " so a database published to every interface is reachable from"
|
||||
echo " the internet whatever the firewall says. Loopback is all the"
|
||||
echo " platform needs — it runs on this machine."
|
||||
echo ""
|
||||
echo " [2] use one you already run"
|
||||
echo " Give the connection URL. Nothing is provisioned."
|
||||
echo ""
|
||||
|
||||
DB_PICK=""
|
||||
while [[ -z "$DB_PICK" ]]; do
|
||||
if ! read -rp " Which one? (1/2) [1]: " DB_CHOICE; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
case "${DB_CHOICE:-1}" in
|
||||
1)
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker is not installed, so there is nothing to provision into."
|
||||
continue
|
||||
fi
|
||||
if pg_port_in_use; then
|
||||
warn "something is already listening on ${PG_PORT} — provisioning here would fail to bind"
|
||||
echo " If that is a Postgres you already run, pick 2 and give its URL."
|
||||
continue
|
||||
fi
|
||||
DB_PICK=provision
|
||||
;;
|
||||
2) DB_PICK=existing ;;
|
||||
*) warn "Pick 1 or 2." ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$DB_PICK" == provision ]]; then
|
||||
PG_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
|
||||
write_pg_compose "$PG_PASSWORD"
|
||||
ok "compose written to $(pg_compose_file)"
|
||||
if pg_compose_up && pg_wait_ready; then
|
||||
POSTGRES_URL="$(pg_url "$PG_PASSWORD")"
|
||||
ok "postgres answering on 127.0.0.1:${PG_PORT}, database '${PG_DATABASE}'"
|
||||
SUMMARY+=("Database: provisioned at $(pg_service_dir)")
|
||||
else
|
||||
warn "the container did not come up — check: docker logs ${PG_CONTAINER}"
|
||||
ERRORS+=("Database: container did not start")
|
||||
SUMMARY+=("Database: provisioning FAILED")
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
ask_required POSTGRES_URL "Connection URL" "postgresql://user:password@host:5432/officer"
|
||||
if pg_url_works "$POSTGRES_URL"; then
|
||||
ok "reachable"
|
||||
SUMMARY+=("Database: existing, ${POSTGRES_URL%%:*}://…")
|
||||
else
|
||||
# Not fatal. The URL may be right and the database not started yet, and
|
||||
# refusing to continue over that would be worse than saying so.
|
||||
warn "could not connect with that URL"
|
||||
echo " Kept anyway — check it before running the schema step."
|
||||
ERRORS+=("Database: the given URL did not answer")
|
||||
SUMMARY+=("Database: existing URL kept, did not answer")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 6. Environment
|
||||
# =============================================================================
|
||||
|
||||
step "Environment"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Environment — $(env_file)"
|
||||
|
||||
# Read back before anything is asked; existing values become the defaults.
|
||||
ENV_PORT="$(env_get PORT)"
|
||||
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
|
||||
|
||||
if env_exists; then
|
||||
echo " exists — its values are the defaults below"
|
||||
else
|
||||
echo " does not exist yet"
|
||||
fi
|
||||
|
||||
# ── what is asked ──
|
||||
echo ""
|
||||
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
|
||||
ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}"
|
||||
|
||||
echo ""
|
||||
echo " to write:"
|
||||
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
|
||||
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
|
||||
echo ""
|
||||
echo " the install root is not written here — the platform derives it as the"
|
||||
echo " parent of the repo, so data/, capabilities/ and dockers/ follow from"
|
||||
echo " ${OFFICER_ROOT} without anything having to agree with anything."
|
||||
echo ""
|
||||
|
||||
if confirm "Write it?"; then
|
||||
write_env
|
||||
ok "written, 0600, owned by ${USERNAME}"
|
||||
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
|
||||
SUMMARY+=("Environment: $(env_file)")
|
||||
else
|
||||
warn "skipped by request"
|
||||
SUMMARY+=("Environment: SKIPPED by request")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# NOT BUILT YET
|
||||
# =============================================================================
|
||||
# 6 Schema db:push
|
||||
# 7 Build gen:index
|
||||
# 8 Services pm2 startOrRestart · save · startup
|
||||
# 9 Verify are the processes actually up
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
|
||||
echo ""
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — shared foundation
|
||||
# =============================================================================
|
||||
#
|
||||
# Sourced by officer-setup.sh before anything runs. DEFINITIONS ONLY, the same
|
||||
# rule machine-setup/lib holds to: nothing here installs, writes or restarts.
|
||||
#
|
||||
# ── Why this is a separate script from machine-setup ──
|
||||
#
|
||||
# They answer different questions. machine-setup asks what a MACHINE should be —
|
||||
# users, ssh, firewall, runtimes — and is worth running on a box that will never
|
||||
# see Officer. This one puts Officer on a machine that is already ready, and
|
||||
# assumes nothing about how it got that way.
|
||||
#
|
||||
# The split also means the failure modes stay apart: a broken firewall rule and a
|
||||
# failed database migration are not the same kind of problem and should not be
|
||||
# in the same run.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_BASE_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_BASE_LOADED=1
|
||||
|
||||
SUMMARY=()
|
||||
ERRORS=()
|
||||
CURRENT_STEP=""
|
||||
SKIP_STEP=false
|
||||
|
||||
USERNAME="${SETUP_USERNAME:-}"
|
||||
USER_HOME=""
|
||||
OFFICER_ROOT="${OFFICER_ROOT:-}"
|
||||
MACHINE_ROLE="${MACHINE_ROLE:-}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${CYAN}::${NC} $*"; }
|
||||
ok() { echo -e " ${GREEN}OK${NC}: $*"; }
|
||||
warn() { echo -e " ${YELLOW}WARN${NC}: $*"; }
|
||||
fail() {
|
||||
echo -e " ${RED}FAIL${NC}: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
ONLY_STEP="${ONLY_STEP:-}"
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
else
|
||||
SKIP_STEP=true
|
||||
fi
|
||||
return
|
||||
fi
|
||||
if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then
|
||||
echo -e " ${GREEN}SKIP${NC}: $1 (already done)"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
}
|
||||
|
||||
skip() { [[ "$SKIP_STEP" == true ]]; }
|
||||
|
||||
step_ok() {
|
||||
[[ -n "$ONLY_STEP" ]] && return 0
|
||||
echo "$CURRENT_STEP" >>"$PROGRESS_FILE"
|
||||
}
|
||||
|
||||
page() {
|
||||
if [[ -t 1 ]] && command -v more &>/dev/null; then more; else cat; fi
|
||||
}
|
||||
|
||||
confirm() {
|
||||
local message="${1:-Proceed?}" default="${2:-y}" help_fn="${3:-}" answer prompt
|
||||
|
||||
[[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; }
|
||||
|
||||
if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi
|
||||
[[ -n "$help_fn" ]] && prompt="${prompt%]}/?]"
|
||||
|
||||
while true; do
|
||||
if ! read -rp " ${message} ${prompt}: " answer; then
|
||||
echo ""
|
||||
fail "No answer. Set ASSUME_YES=1 to run without prompts."
|
||||
fi
|
||||
[[ -z "$answer" ]] && answer="$default"
|
||||
case "$answer" in
|
||||
y | Y | yes | Yes) return 0 ;;
|
||||
n | N | no | No) return 1 ;;
|
||||
"?")
|
||||
if [[ -n "$help_fn" ]]; then
|
||||
echo ""
|
||||
"$help_fn" | page
|
||||
echo ""
|
||||
else
|
||||
warn "Answer y or n."
|
||||
fi
|
||||
;;
|
||||
*) warn "Answer y or n${help_fn:+, or ? for what this is}." ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
ask_required() {
|
||||
local __var="$1" message="$2" default="$3" answer=""
|
||||
while [[ -z "$answer" ]]; do
|
||||
if ! read -rp " ${message}${default:+ [$default]}: " answer; then
|
||||
echo ""
|
||||
fail "No answer."
|
||||
fi
|
||||
answer="${answer:-$default}"
|
||||
[[ -z "$answer" ]] && warn "This one cannot be left blank."
|
||||
done
|
||||
printf -v "$__var" '%s' "$answer"
|
||||
}
|
||||
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# Run something as the account that owns the install. Officer's files, its
|
||||
# node_modules and its pm2 process list all belong to that account, not to root —
|
||||
# a repository cloned as root is one the owner cannot pull.
|
||||
as_owner() { (cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1"); }
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the environment file
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── What is NOT here ──
|
||||
#
|
||||
# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite
|
||||
# key store (docs/secret-store.md), and writing them here in the meantime would
|
||||
# mean generating a value that the store then has to be reconciled with — two
|
||||
# origins for one secret, which is the failure the store exists to end.
|
||||
#
|
||||
# The consequence is honest and deliberate: jwt.ts throws at module load without
|
||||
# JWT_SECRET, so an install made by this script does not boot until the store
|
||||
# lands. That sequencing was chosen rather than stumbled into.
|
||||
#
|
||||
# ── Derived, not asked ──
|
||||
#
|
||||
# DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone too, and this time nothing
|
||||
# replaces them. The platform derives the install root as the parent of its own
|
||||
# working directory, so data/, capabilities/ and dockers/ follow from the layout
|
||||
# on disk, and the owner's home comes from the OS. They were three environment
|
||||
# variables that had to agree with each other and with the directory tree.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_ENV_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_ENV_LOADED=1
|
||||
|
||||
env_file() { echo "$(platform_dir)/.env"; }
|
||||
|
||||
env_exists() { [[ -f "$(env_file)" ]]; }
|
||||
|
||||
# One value out of an existing .env, without sourcing it — the file holds
|
||||
# secrets and arbitrary shell would run as root.
|
||||
env_get() {
|
||||
[[ -r "$(env_file)" ]] || return 0
|
||||
awk -F= -v k="$1" '
|
||||
$1 == k {
|
||||
v = substr($0, index($0, "=") + 1)
|
||||
gsub(/^"|"$/, "", v)
|
||||
print v
|
||||
exit
|
||||
}' "$(env_file)"
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local dest
|
||||
dest="$(env_file)"
|
||||
|
||||
[[ -f "$dest" ]] && cp -a "$dest" "${dest}.before-officer-setup"
|
||||
|
||||
# Restrictive from the moment it exists rather than chmod'd afterwards, so the
|
||||
# secrets are never briefly world-readable. Restored straight after: umask is
|
||||
# not scoped to a function, and leaving it at 077 would quietly make every file
|
||||
# a later section creates owner-only.
|
||||
local prior_umask
|
||||
prior_umask="$(umask)"
|
||||
umask 077
|
||||
cat >"$dest" <<ENVF
|
||||
# Written by officer-setup.
|
||||
#
|
||||
# Everything Officer reads at runtime. Kept at 0600 and owned by ${USERNAME}: it
|
||||
# holds the token-signing secret and the database credential.
|
||||
|
||||
PORT="${ENV_PORT}"
|
||||
|
||||
# The browser relay listens on its own port, separate from the app.
|
||||
BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT}"
|
||||
|
||||
POSTGRES_URL="${POSTGRES_URL}"
|
||||
|
||||
ENVF
|
||||
|
||||
umask "$prior_umask"
|
||||
|
||||
chown "${USERNAME}:$(user_group)" "$dest"
|
||||
chmod 600 "$dest"
|
||||
return 0
|
||||
}
|
||||
@@ -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,153 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — Postgres
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── Only Postgres ──
|
||||
#
|
||||
# The original offered five containers. Of those, Redis and SearXNG are not
|
||||
# referenced anywhere in the platform — no import, no environment variable, no
|
||||
# mention — and Nginx Proxy Manager is a deployment choice rather than something
|
||||
# a setup script should pick. Mailhog is a development convenience and is offered
|
||||
# separately.
|
||||
#
|
||||
# Postgres is the only one Officer cannot run without: it is the single database,
|
||||
# holding the account, passkeys, settings, dashboards, email accounts and the
|
||||
# queue.
|
||||
#
|
||||
# ── Where it goes ──
|
||||
#
|
||||
# $OFFICER_ROOT/dockers/postgres/, which is the same convention the app store
|
||||
# uses for anything it provisions: one directory per service, the compose file
|
||||
# inside it, and RELATIVE bind mounts so the data sits beside the compose file
|
||||
# where both a human and the platform can find it.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_POSTGRES_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_POSTGRES_LOADED=1
|
||||
|
||||
# One network for everything Officer provisions, so containers can reach each
|
||||
# other by name. Postgres needs nothing from it today — the platform is a host
|
||||
# process and reaches it over loopback — but a reverse proxy in front of the web
|
||||
# UI, or any app-store service that talks to another, does. Creating it now means
|
||||
# the later ones do not have to migrate onto it.
|
||||
OFFICER_NETWORK="${OFFICER_NETWORK:-officerdev}"
|
||||
|
||||
PG_IMAGE="${PG_IMAGE:-postgres:18-alpine}"
|
||||
PG_DATABASE="${PG_DATABASE:-officer}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
||||
PG_PORT="${PG_PORT:-5432}"
|
||||
|
||||
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
||||
ensure_docker_network() {
|
||||
docker_network_exists && return 1
|
||||
docker network create "$OFFICER_NETWORK" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
pg_service_dir() { echo "${OFFICER_ROOT}/dockers/postgres"; }
|
||||
pg_compose_file() { echo "$(pg_service_dir)/docker-compose.yaml"; }
|
||||
pg_env_file() { echo "$(pg_service_dir)/.env"; }
|
||||
|
||||
pg_compose_exists() { [[ -f "$(pg_compose_file)" ]]; }
|
||||
pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{{.Names}}' 2>/dev/null | grep -q .; }
|
||||
|
||||
# Is something already answering on the port? A Postgres the user runs their own
|
||||
# way is a perfectly good answer, and finding out by failing to bind is not.
|
||||
pg_port_in_use() { ss -ltn 2>/dev/null | grep -qE "127\.0\.0\.1:${PG_PORT}\b|\*:${PG_PORT}\b|0\.0\.0\.0:${PG_PORT}\b"; }
|
||||
|
||||
# Bound to loopback, deliberately, and the reason is worth keeping next to the
|
||||
# line it explains.
|
||||
#
|
||||
# Publishing a port makes Docker write its own DNAT and ACCEPT rules into
|
||||
# iptables, and those are evaluated BEFORE ufw sees the packet. So `ports:
|
||||
# "5432:5432"` is reachable from the internet while `ufw status` reports
|
||||
# everything denied. Binding to 127.0.0.1 sidesteps it entirely: the DNAT rule
|
||||
# only matches traffic arriving on loopback.
|
||||
#
|
||||
# Loopback is not the whole story, though, and the password is not decoration.
|
||||
# Every account ON this machine can open 127.0.0.1:5432 — including the per-user
|
||||
# Linux accounts Officer gives its members. What stops them is that they cannot
|
||||
# authenticate. The password is the boundary between the platform and anyone
|
||||
# with a login here, which is why it is random and why both files holding it are
|
||||
# 0600.
|
||||
write_pg_compose() {
|
||||
local password="$1" dir
|
||||
dir="$(pg_service_dir)"
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$dir"
|
||||
|
||||
cat >"$(pg_compose_file)" <<COMPOSE
|
||||
# Written by officer-setup. Officer's database.
|
||||
#
|
||||
# The port is bound to 127.0.0.1 on purpose. Docker publishes ports by writing
|
||||
# iptables rules beneath ufw, so "5432:5432" would be reachable from the internet
|
||||
# whatever the firewall reports. The platform runs on this machine, so loopback
|
||||
# is all it needs.
|
||||
services:
|
||||
postgres:
|
||||
image: ${PG_IMAGE}
|
||||
container_name: ${PG_CONTAINER}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${PG_PORT}:5432"
|
||||
environment:
|
||||
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${PG_DATABASE}
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- ./data:/var/lib/postgresql/data
|
||||
- ./dumps:/dumps
|
||||
networks:
|
||||
- ${OFFICER_NETWORK}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
${OFFICER_NETWORK}:
|
||||
external: true
|
||||
COMPOSE
|
||||
|
||||
# The password lives beside the compose file rather than inside it, so the
|
||||
# compose file can be read, copied or committed without carrying a credential.
|
||||
umask 077
|
||||
cat >"$(pg_env_file)" <<ENVF
|
||||
# Written by officer-setup. Read by docker compose from this directory.
|
||||
POSTGRES_PASSWORD=${password}
|
||||
ENVF
|
||||
chown "${USERNAME}:$(user_group)" "$(pg_compose_file)" "$(pg_env_file)"
|
||||
chmod 600 "$(pg_env_file)"
|
||||
return 0
|
||||
}
|
||||
|
||||
pg_password_from_env_file() {
|
||||
[[ -r "$(pg_env_file)" ]] || return 1
|
||||
awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)"
|
||||
}
|
||||
|
||||
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
|
||||
|
||||
# Wait for it to answer, rather than assuming `up -d` means ready. Postgres
|
||||
# initialises its data directory on first start, which takes several seconds, and
|
||||
# everything after this — db:push especially — fails confusingly against a
|
||||
# database that is still starting.
|
||||
pg_wait_ready() {
|
||||
local tries="${1:-30}"
|
||||
while ((tries-- > 0)); do
|
||||
docker exec "$PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1 && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
pg_url() { echo "postgresql://postgres:${1}@127.0.0.1:${PG_PORT}/${PG_DATABASE}"; }
|
||||
|
||||
# Does this URL actually answer? Asked of any URL, provisioned or given, because
|
||||
# a database nobody can reach is the failure that makes every later section look
|
||||
# broken for its own reasons.
|
||||
pg_url_works() {
|
||||
local url="$1"
|
||||
as_owner "docker run --rm --network host ${PG_IMAGE} psql '${url}' -c 'select 1' >/dev/null 2>&1" /
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — is this machine ready
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── Inherited, not re-asked ──
|
||||
#
|
||||
# machine-setup saves the account, the Officer path and the machine role beside
|
||||
# itself. This reads the same file, so a normal run — machine-setup, then this —
|
||||
# asks nothing at all. It only prompts on a machine where machine-setup never
|
||||
# ran, which is a supported case rather than an error: somebody may have
|
||||
# provisioned the box their own way.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_PREFLIGHT_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_PREFLIGHT_LOADED=1
|
||||
|
||||
# Where machine-setup keeps what it was told. Beside this script, one directory
|
||||
# across.
|
||||
MACHINE_ANSWERS="${MACHINE_ANSWERS:-${SCRIPT_DIR}/machine-setup/.setup-answers}"
|
||||
|
||||
# Read as assignments rather than sourced: the file is read by a root run and
|
||||
# sourcing it would make it executable content.
|
||||
load_machine_answers() {
|
||||
[[ -r "$MACHINE_ANSWERS" ]] || return 1
|
||||
local key value
|
||||
while IFS='=' read -r key value; do
|
||||
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
||||
[[ -n "$value" ]] || continue
|
||||
case "$key" in
|
||||
MACHINE_ROLE) if [[ -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="$value"; fi ;;
|
||||
SETUP_USERNAME) if [[ -z "$USERNAME" ]]; then USERNAME="$value"; fi ;;
|
||||
OFFICER_ROOT) if [[ -z "$OFFICER_ROOT" ]]; then OFFICER_ROOT="$value"; fi ;;
|
||||
esac
|
||||
done <"$MACHINE_ANSWERS"
|
||||
return 0
|
||||
}
|
||||
|
||||
# What Officer needs to already be here, and what installs it.
|
||||
#
|
||||
# Checked together and reported together: finding out about a missing bun three
|
||||
# sections in, after the repository has been cloned and a database started, is a
|
||||
# worse way to learn it than being told at the start.
|
||||
REQUIRED_TOOLS=(git node bun pm2)
|
||||
OPTIONAL_TOOLS=(docker)
|
||||
|
||||
missing_tools() {
|
||||
local t
|
||||
for t in "${REQUIRED_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
||||
}
|
||||
|
||||
missing_optional_tools() {
|
||||
local t
|
||||
for t in "${OPTIONAL_TOOLS[@]}"; do command -v "$t" &>/dev/null || echo "$t"; done
|
||||
}
|
||||
|
||||
tool_why() {
|
||||
case "$1" in
|
||||
git) echo "to clone and update the platform" ;;
|
||||
node) echo "pm2 runs on it, and the terminal sidecar builds node-pty against it" ;;
|
||||
bun) echo "the platform itself and nineteen of the twenty processes" ;;
|
||||
pm2) echo "supervises every process; the ecosystem files are written for it" ;;
|
||||
docker) echo "Postgres, and anything the app store provisions" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The account has to exist before anything is written to its home.
|
||||
owner_exists() { id "$USERNAME" &>/dev/null; }
|
||||
|
||||
resolve_user_home() {
|
||||
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the repository
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── Cloned as the owner, never as root ──
|
||||
#
|
||||
# A repository cloned by root is one the owner cannot pull, cannot commit in, and
|
||||
# whose node_modules they cannot write. Every git operation here runs as the
|
||||
# account, from a directory that account can stat.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_REPO_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_REPO_LOADED=1
|
||||
|
||||
OFFICER_REPO="${OFFICER_REPO:-https://gitea.officer.dev/officerdev/platform.git}"
|
||||
|
||||
platform_dir() { echo "${OFFICER_ROOT}/platform"; }
|
||||
|
||||
repo_exists() { [[ -d "$(platform_dir)/.git" ]]; }
|
||||
|
||||
repo_remote() { (cd "$(platform_dir)" 2>/dev/null && git remote get-url origin 2>/dev/null) || true; }
|
||||
repo_branch() { (cd "$(platform_dir)" 2>/dev/null && git branch --show-current 2>/dev/null) || true; }
|
||||
repo_is_dirty() { [[ -n "$(cd "$(platform_dir)" 2>/dev/null && git status --porcelain 2>/dev/null)" ]]; }
|
||||
|
||||
# Split an ssh:// URL into host and port, for the reachability check below.
|
||||
repo_ssh_host() { sed -E 's|^ssh://[^@]*@([^:/]+).*|\1|' <<<"$1"; }
|
||||
repo_ssh_port() { sed -nE 's|^ssh://[^@]*@[^:]+:([0-9]+)/.*|\1|p' <<<"$1"; }
|
||||
|
||||
# Can this account actually clone it?
|
||||
#
|
||||
# `git ls-remote` is the real question — not "does the host answer" but "can this
|
||||
# account read this repository". Both prompts are disabled, because neither fails
|
||||
# cleanly on its own: over https git asks for a username nobody is there to type,
|
||||
# and over ssh it asks for a password or stops on host-key verification. With
|
||||
# both off, an unreachable or unreadable repository is an immediate non-zero
|
||||
# instead of a hang.
|
||||
repo_reachable() {
|
||||
as_owner "GIT_TERMINAL_PROMPT=0 \
|
||||
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=8' \
|
||||
timeout 20 git ls-remote '$1' >/dev/null 2>&1" /
|
||||
}
|
||||
|
||||
# The https form of the same repository, for a machine with no key.
|
||||
repo_https_url() {
|
||||
sed -E 's|^ssh://[^@]*@([^:/]+)(:[0-9]+)?/|https://\1/|' <<<"$1"
|
||||
}
|
||||
|
||||
clone_repo() {
|
||||
local url="$1" dest
|
||||
dest="$(platform_dir)"
|
||||
install -d -m 0755 -o "$USERNAME" -g "$(user_group)" "$OFFICER_ROOT"
|
||||
as_owner "GIT_TERMINAL_PROMPT=0 git clone '${url}' '${dest}'" /
|
||||
}
|
||||
|
||||
pull_repo() { as_owner "git -C '$(platform_dir)' pull --ff-only" /; }
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# ── The lockfile is frozen, and that is the point ──
|
||||
#
|
||||
# bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from
|
||||
# bun.lock and nothing else. A package.json that disagrees with the lockfile is a
|
||||
# hard failure rather than a quiet resolution — which is deliberate: the friction
|
||||
# exists so that an unexplained lockfile change shows up in a diff. See the
|
||||
# supply-chain note in CLAUDE.md.
|
||||
#
|
||||
# So a failure here is usually one of two things, and they need different
|
||||
# answers: the lockfile genuinely disagrees with package.json, or node-pty failed
|
||||
# to build. Both are reported as such rather than as "install failed".
|
||||
|
||||
deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
|
||||
|
||||
# node-pty has no Linux prebuild, so `bun install` compiles it every time. This is
|
||||
# the artefact that proves it worked, and its absence is why the terminal sidecar
|
||||
# would not start.
|
||||
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
|
||||
|
||||
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
|
||||
@@ -32,8 +32,6 @@ type ManagedUser = {
|
||||
|
||||
type UsersResponse = {
|
||||
users: ManagedUser[];
|
||||
/** False on a host without per-user Linux accounts, where those controls would only ever refuse. */
|
||||
osUsersEnabled: boolean;
|
||||
/** Every role, for displaying the owner's own value. */
|
||||
roles: string[];
|
||||
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
|
||||
@@ -183,7 +181,7 @@ export const UsersSection = () => {
|
||||
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
|
||||
anyone who has one (retry after fixing a host problem, or replace their key) — the
|
||||
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
|
||||
{data.osUsersEnabled && !user.isOwner && (
|
||||
{!user.isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
+8
-3
@@ -3,6 +3,8 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { serve } from 'bun';
|
||||
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
||||
import { assertCapabilityTotality } from './servers/capabilities/totality';
|
||||
import { assertInstallLayout } from './servers/data-path';
|
||||
import { PORT } from './servers/officer-url.mjs';
|
||||
import { assertSecretsClosed } from './servers/os-user';
|
||||
import { resolveHomeDir } from './servers/user-home';
|
||||
import { resolveAuthToken } from './servers/auth-token';
|
||||
@@ -22,7 +24,6 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod
|
||||
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||
import { toShellUsername } from './servers/data-path';
|
||||
|
||||
const { PORT = '5000' } = process.env;
|
||||
|
||||
// Build static file routes from public/
|
||||
const publicRoutes: Record<string, (req: Request) => Response> = {};
|
||||
@@ -151,9 +152,13 @@ assertCapabilityTotality({
|
||||
wsProviders: Object.keys(handlers),
|
||||
});
|
||||
|
||||
// That the working directory is the repo, because every path derives from its parent. First of the three,
|
||||
// since a wrong answer here makes the other two check the wrong files.
|
||||
assertInstallLayout();
|
||||
|
||||
// And, when members have real Linux accounts, that they cannot read the credentials that would make those
|
||||
// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable
|
||||
// JWT_SECRET is worse than no isolation, because the model looks intact. No-op while the feature is off.
|
||||
// JWT_SECRET is worse than no isolation, because the model looks intact.
|
||||
await assertSecretsClosed(process.cwd());
|
||||
|
||||
async function upgradeWs(
|
||||
@@ -245,7 +250,7 @@ async function upgradeWs(
|
||||
}
|
||||
|
||||
const server = serve({
|
||||
port: Number(PORT),
|
||||
port: PORT,
|
||||
idleTimeout: 60,
|
||||
maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
|
||||
routes: {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import * as errors from '../custom-errors';
|
||||
import { resolveAuthToken } from '../auth-token';
|
||||
import { isSuperAdmin } from '../super-admin';
|
||||
import { isApiRequestAllowed } from '../capabilities/authorize';
|
||||
import { isExemptApiPath } from '../capabilities/totality';
|
||||
|
||||
// The global authorization gate: a valid NON-owner token may reach only what its ROLE has been granted.
|
||||
//
|
||||
// Mounted in hono.ts ahead of every router, and it re-verifies the token itself rather than trusting an
|
||||
// earlier middleware — so it covers routes that never mount `userMiddleware`, which is most of the reason
|
||||
// it exists. See auth-token.ts: the caller resolved here must be the same caller `userMiddleware` would
|
||||
// resolve, or the two doors disagree about who someone is.
|
||||
//
|
||||
// A missing or invalid token passes straight through. Signin needs that, and `userMiddleware` rejects bad
|
||||
// tokens on the protected routes. Only a VALID non-owner token is constrained here.
|
||||
//
|
||||
// ── What this file used to be ──
|
||||
//
|
||||
// This was `originScopeMiddleware`, the second half of `origin-validation.ts`, which also enforced
|
||||
// per-origin path scoping: an app shipping a custom-scheme origin (`officer://<hex>` via
|
||||
// OFFICER_<APP>_ORIGIN) could reach only `/api/auth` plus its own feature.
|
||||
//
|
||||
// All of that is gone as of 2026-08-13, along with ALLOW_ANY_ORIGIN and ALLOW_ANY_ORIGIN_MUSIC. Origin
|
||||
// was never authentication here — the custom-scheme origin is chosen by the client, forgeable outside a
|
||||
// browser, and extractable from a shipped app binary — and the flag that disabled it defaulted to ON, so
|
||||
// on every real install none of it ran. It was documented as defence in depth that was switched off.
|
||||
//
|
||||
// This half was deliberately NOT under that flag, because it is account-based rather than origin-based.
|
||||
// Its old comment called it "the airtight half", and separating the two is why the name changed: nothing
|
||||
// in here looks at an Origin header any more.
|
||||
export const capabilityGateMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
const path = ctx.req.path;
|
||||
|
||||
const authorization = ctx.req.header('authorization');
|
||||
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
|
||||
const payload = token ? await resolveAuthToken(token) : null;
|
||||
const isOwner = payload ? await isSuperAdmin(payload) : false;
|
||||
|
||||
// Exempt paths (signin, the public pages, the Bitwarden door) are skipped because they are served above
|
||||
// the account gate — the same list the boot check uses, deliberately.
|
||||
//
|
||||
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
|
||||
// platform account, so there is nothing here to resolve.
|
||||
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
|
||||
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
|
||||
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from './body-parser';
|
||||
export * from './user-middleware';
|
||||
export * from './origin-middleware';
|
||||
export * from './origin-validation';
|
||||
export * from './capability-gate';
|
||||
export * from './rate-limiter';
|
||||
export * from './known-users';
|
||||
export * from './auth-audit';
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
|
||||
// origin-validation reads PUBLIC_URL / PUBLIC_BUILD_ENV at module load, so set them before importing.
|
||||
process.env.PUBLIC_URL = 'https://officer.example.com';
|
||||
process.env.PUBLIC_BUILD_ENV = 'production';
|
||||
// Bun loads the host's .env into tests, so an operational kill switch left on there would silently turn
|
||||
// these assertions into no-ops — which is exactly what ALLOW_ANY_ORIGIN=true did. Pin the escape hatches
|
||||
// off: this file's whole job is asserting that the checks reject things.
|
||||
process.env.ALLOW_ANY_ORIGIN = 'false';
|
||||
process.env.ALLOW_ANY_ORIGIN_MUSIC = 'false';
|
||||
|
||||
const { isOriginAllowed } = await import('./origin-validation');
|
||||
|
||||
test('accepts the configured origin', () => {
|
||||
expect(isOriginAllowed('https://officer.example.com', 'officer.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
test('accepts the Host forwarded by the reverse proxy when there is no Origin', () => {
|
||||
expect(isOriginAllowed(undefined, 'officer.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a foreign Origin', () => {
|
||||
expect(isOriginAllowed('https://evil.com', 'officer.example.com')).toBe(false);
|
||||
expect(isOriginAllowed('https://officer.example.com.evil.com', 'officer.example.com')).toBe(false);
|
||||
});
|
||||
|
||||
// Regression: the Host branch used `configuredOrigin.endsWith(host)`, so any suffix of the origin —
|
||||
// down to a bare TLD — authenticated as the real host.
|
||||
test('rejects Hosts that are merely suffixes of the configured origin', () => {
|
||||
for (const host of ['com', 'example.com', 'r.example.com', 'ficer.example.com', 'evil.com']) {
|
||||
expect(isOriginAllowed(undefined, host)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a request carrying neither Origin nor Host', () => {
|
||||
expect(isOriginAllowed(undefined, undefined)).toBe(false);
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import * as errors from '../custom-errors';
|
||||
import { IS_DEV_BUILD } from '../build-env';
|
||||
import { resolveAuthToken } from '../auth-token';
|
||||
import { isSuperAdmin } from '../super-admin';
|
||||
import { isApiRequestAllowed } from '../capabilities/authorize';
|
||||
import { isExemptApiPath } from '../capabilities/totality';
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
// The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev),
|
||||
// not a hardcoded domain.
|
||||
const PUBLIC_ORIGIN = (() => {
|
||||
try {
|
||||
return PUBLIC_URL ? new URL(PUBLIC_URL).origin : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
const WEB_ORIGINS: string[] = PUBLIC_ORIGIN ? [PUBLIC_ORIGIN] : [];
|
||||
|
||||
// Host authorities (`example.com`, or `example.com:8080` off the default port) for the same origins.
|
||||
// Officer always sits behind an HTTPS reverse proxy, so the proxy's `Host` header is expected to
|
||||
// match PUBLIC_URL's authority exactly.
|
||||
const WEB_HOSTS: string[] = WEB_ORIGINS.map((o) => new URL(o).host);
|
||||
|
||||
const CHROME_EXTENSIONS: string[] = [
|
||||
// 'chrome-extension://<id>'
|
||||
];
|
||||
|
||||
// Every `OFFICER_<APP>_ORIGIN` in the environment is an allowed app origin — each app ships a
|
||||
// custom-scheme origin with an embedded token (`officer://<hex>`), set on the host, never in the repo.
|
||||
// Adding an app is adding an env var; no code change, which is the point. The slug is what sits between
|
||||
// the two fixed words: OFFICER_MUSIC_ORIGIN → MUSIC.
|
||||
const APP_ORIGIN_VAR = /^OFFICER_([A-Z0-9_]+)_ORIGIN$/;
|
||||
|
||||
// Apps that are the whole platform rather than one feature of it, so they get NO path scoping — the main
|
||||
// Officer app needs every prefix the web app needs.
|
||||
//
|
||||
// These used to be `superAdminOnly`, which refused a non-owner outright, here and at signin. That was
|
||||
// correct while single-user was the invariant and the only non-owner accounts were music-app accounts:
|
||||
// there was no way to express "this person may use the platform, but only these parts of it", so the
|
||||
// honest answer was to keep them out of it entirely.
|
||||
//
|
||||
// Capabilities express exactly that, per feature, at both doors. So the blunt version is gone — a member
|
||||
// signs into the web app and sees what their role was granted. Keeping both would mean a member who has
|
||||
// been granted Gitea still cannot reach the page, which is not a second layer of defence, just a bug.
|
||||
const PLATFORM_APPS = new Set(['APP']);
|
||||
|
||||
// Where an app's API surface isn't `/api/<slug>`. Only exceptions belong here.
|
||||
const APP_SCOPE_OVERRIDES: Record<string, string[]> = {
|
||||
// OffTail signs in and mints a Headscale pre-auth key. The VPN's own traffic goes straight to
|
||||
// Headscale and never through /api, so this is its entire platform surface.
|
||||
TAIL: ['/api/vpn'],
|
||||
};
|
||||
|
||||
type AppOrigin = { slug: string; origin: string };
|
||||
|
||||
const APP_ORIGIN_LIST: AppOrigin[] = Object.entries(process.env).flatMap(([key, value]) => {
|
||||
const slug = key.match(APP_ORIGIN_VAR)?.[1];
|
||||
return slug && value ? [{ slug, origin: value }] : [];
|
||||
});
|
||||
|
||||
const APP_ORIGINS: string[] = APP_ORIGIN_LIST.map((a) => a.origin);
|
||||
|
||||
// The account backstop used to live here as two hand-written lists: NON_OWNER_PATHS, confining every
|
||||
// non-owner to '/api/auth' + '/api/music', and NON_OWNER_WS_PROVIDERS doing the same for sockets. Both
|
||||
// are gone, replaced by the capability registry (src/servers/capabilities/).
|
||||
//
|
||||
// They were not wrong, they were unscalable in one specific way: a hardcoded allow-list answers "which
|
||||
// paths" but never "why", so onboarding anyone who needed anything other than music meant editing an
|
||||
// array in a middleware file and hoping the socket half got edited too. The registry makes the two doors
|
||||
// read the same declaration, and the boot-time totality check makes a THIRD door impossible to add
|
||||
// without noticing. See capabilities/totality.ts for the incident that motivated it.
|
||||
|
||||
function pathAllowed(path: string, prefixes: string[]): boolean {
|
||||
return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
// Per-origin access rules, enforced globally by originScopeMiddleware (mounted in hono.ts):
|
||||
// - paths: this Origin may reach ONLY these path prefixes; anything else is 403.
|
||||
// Origins with no rule keep full access. Rules no-op for env values that are unset.
|
||||
//
|
||||
// App rules are derived, not written: an app may reach /api/auth (it has to sign in) plus the one
|
||||
// feature it is named for — OFFICER_VAULT_ORIGIN gets /api/auth + /api/vault.
|
||||
type OriginRule = { paths?: string[] };
|
||||
const ORIGIN_RULES: Record<string, OriginRule> = {};
|
||||
|
||||
for (const { slug, origin } of APP_ORIGIN_LIST) {
|
||||
ORIGIN_RULES[origin] = PLATFORM_APPS.has(slug)
|
||||
? {} // no path scoping; the capability backstop is what limits a platform app's caller
|
||||
: { paths: ['/api/auth', ...(APP_SCOPE_OVERRIDES[slug] ?? [`/api/${slug.toLowerCase()}`])] };
|
||||
}
|
||||
|
||||
// Last, so the web origin wins if an app ever declares the same one. No path scoping, for the same reason
|
||||
// as PLATFORM_APPS above: the web app is the whole platform, and what its caller may reach is decided by
|
||||
// their capabilities rather than by their Origin.
|
||||
if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = {};
|
||||
|
||||
// ── TEMPORARY: origin checking switched off ──
|
||||
// ALLOW_ANY_ORIGIN=true accepts every Origin, everywhere, and skips the per-origin path scoping.
|
||||
// ALLOW_ANY_ORIGIN_MUSIC=true is the narrower version, /api/music only — which is not enough on its own,
|
||||
// because an app has to reach /api/auth to sign in before it ever calls its own feature.
|
||||
//
|
||||
// What still holds with these on: every protected route requires a valid token (userMiddleware), and the
|
||||
// capability backstop below still confines a non-owner account to what its ROLE has been granted,
|
||||
// whatever Origin it claims — that one is deliberately NOT disabled, since it is account-based, not
|
||||
// origin-based.
|
||||
//
|
||||
// What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>`
|
||||
// is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary.
|
||||
// Both flags and their call sites come out once the tailnet is the perimeter.
|
||||
// Defaults to ON — origin checking is off unless ALLOW_ANY_ORIGIN is explicitly 'false'. That is a
|
||||
// deliberate inversion of the usual fail-closed rule, and it is safe only because of where this runs: the
|
||||
// perimeter is the tailnet, devices are admitted by hand, and a valid token is still required on every
|
||||
// protected route. Set ALLOW_ANY_ORIGIN=false to put the checks back.
|
||||
const ALLOW_ANY_ORIGIN = (process.env.ALLOW_ANY_ORIGIN ?? 'true') !== 'false';
|
||||
const ALLOW_ANY_ORIGIN_MUSIC = process.env.ALLOW_ANY_ORIGIN_MUSIC === 'true';
|
||||
|
||||
export function isOriginCheckDisabled(path?: string): boolean {
|
||||
if (ALLOW_ANY_ORIGIN) return true;
|
||||
return ALLOW_ANY_ORIGIN_MUSIC && !!path && pathAllowed(path, ['/api/music']);
|
||||
}
|
||||
|
||||
/** @deprecated use isOriginCheckDisabled — kept so existing call sites read the same. */
|
||||
export const isMusicOriginExempt = isOriginCheckDisabled;
|
||||
|
||||
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
|
||||
if (IS_DEV_BUILD) return true;
|
||||
if (ALLOW_ANY_ORIGIN) return true;
|
||||
|
||||
if (origin) {
|
||||
if (origin.startsWith('chrome-extension://')) {
|
||||
return CHROME_EXTENSIONS.includes(origin);
|
||||
}
|
||||
|
||||
if (APP_ORIGINS.includes(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return WEB_ORIGINS.includes(origin);
|
||||
}
|
||||
|
||||
if (host) {
|
||||
return WEB_HOSTS.includes(host);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const originValidationMiddleware: MiddlewareHandler = function (ctx, next) {
|
||||
const origin = ctx.get('origin') as string | undefined;
|
||||
const host = ctx.req.header('host');
|
||||
if (!isOriginAllowed(origin, host)) {
|
||||
throw errors.FORBIDDEN('Invalid origin');
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
function resolveOrigin(headerOrigin: string | undefined, referer: string | undefined): string | undefined {
|
||||
if (headerOrigin) return headerOrigin;
|
||||
if (referer) {
|
||||
try {
|
||||
return new URL(referer).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
|
||||
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
|
||||
// alike — regardless of which routers mount originMiddleware. Two layers:
|
||||
// 1. Capability backstop (origin-INDEPENDENT): a valid NON-owner token may reach only what its ROLE
|
||||
// has been granted, no matter the origin. This is the airtight rule — it holds even if a client
|
||||
// omits or forges the Origin header. It replaced a hardcoded "/api/auth + /api/music" list on
|
||||
// 2026-08-07; that list was why a Member could not reach /api/gitea and no UI could change it.
|
||||
// 2. Per-origin rules (ORIGIN_RULES): path scoping for the single-feature apps. Redundant with the
|
||||
// backstop for the account dimension, but blocks unknown-path access from an app origin.
|
||||
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
|
||||
// protected routes). Only a VALID non-owner token is constrained.
|
||||
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
const path = ctx.req.path;
|
||||
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
|
||||
|
||||
// Resolve the caller once (if any); a missing/invalid/revoked credential stays null. This goes through
|
||||
// the shared resolver rather than verifying a JWT here, so an API key is the same caller at this door as
|
||||
// it is at userMiddleware — the two must never disagree about who someone is. See auth-token.ts.
|
||||
const authorization = ctx.req.header('authorization');
|
||||
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
|
||||
const payload = token ? await resolveAuthToken(token) : null;
|
||||
const isOwner = payload ? await isSuperAdmin(payload) : false;
|
||||
|
||||
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
|
||||
// non-owner token may reach only what its ROLE has been granted, whatever Origin it claims and whether
|
||||
// or not it sends one. Exempt paths (signin, the public pages, the Bitwarden door) are skipped because
|
||||
// they are served above the account gate — the same list the boot check uses, deliberately.
|
||||
//
|
||||
// Non-/api paths are not ours: the DAV sync door authenticates with its own app password and carries no
|
||||
// platform account, so there is nothing here to resolve.
|
||||
if (payload && !isOwner && path.startsWith('/api') && !isExemptApiPath(path)) {
|
||||
const { allowed, reason } = await isApiRequestAllowed(payload.id, ctx.req.method, path);
|
||||
if (!allowed) throw errors.FORBIDDEN(reason ?? 'Not permitted for this account');
|
||||
}
|
||||
|
||||
// 2. Per-origin rules. Skipped entirely while origin checking is off (the account backstop above is
|
||||
// account-based, not origin-based, so it deliberately still applies).
|
||||
if (isOriginCheckDisabled(path)) return next();
|
||||
|
||||
const rule = origin ? ORIGIN_RULES[origin] : undefined;
|
||||
if (rule) {
|
||||
if (rule.paths && !pathAllowed(path, rule.paths)) {
|
||||
throw errors.FORBIDDEN('Origin not permitted for this resource');
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { resolveAuthToken } from '@@/auth-token';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
|
||||
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
||||
import { getUserById, isTokenBlacklisted } from 'officerdb';
|
||||
|
||||
@@ -24,16 +23,9 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
}
|
||||
if (!token) throw errors.UNAUTHORIZED();
|
||||
|
||||
// Validate origin for authenticated routes.
|
||||
// TEMPORARY, opt-in: ALLOW_ANY_ORIGIN_MUSIC=true drops the Origin check for /api/music only, so a
|
||||
// client that can't present the app's custom-scheme origin can still reach the music API. Auth is
|
||||
// untouched — a valid token is still required, and the non-owner account backstop in
|
||||
// originScopeMiddleware still applies. Delete this and the env var once the tailnet is the perimeter.
|
||||
const origin = ctx.get('origin') as string | undefined;
|
||||
const host = ctx.req.header('host');
|
||||
if (!isMusicOriginExempt(ctx.req.path) && !isOriginAllowed(origin, host)) {
|
||||
throw errors.FORBIDDEN('Invalid origin');
|
||||
}
|
||||
// There was an Origin check here until 2026-08-13. It is gone with the rest of origin validation —
|
||||
// it had defaulted to off, so it ran on no real install. A valid token is required below, and the
|
||||
// capability gate in hono.ts confines a non-owner to what their role grants.
|
||||
|
||||
try {
|
||||
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
|
||||
|
||||
@@ -12,8 +12,10 @@ import { parseTailLine } from './progress';
|
||||
|
||||
export const activityRouter = createRouter();
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? '';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const HOME_DIR = homedir();
|
||||
const ANNOUNCED_PATH = join(DATA_PATH, 'activity', 'announced.json');
|
||||
const ALLOWED_ROOTS = ['/tmp', DATA_PATH, HOME_DIR].filter(Boolean);
|
||||
const ACTIVE_WINDOW_MS = 120_000; // a task file touched within this is considered "active"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createRouter } from '../../create-router';
|
||||
import {
|
||||
authAudit,
|
||||
originMiddleware,
|
||||
originValidationMiddleware,
|
||||
userMiddleware,
|
||||
bodyParser,
|
||||
signinRateLimiter,
|
||||
@@ -25,10 +24,11 @@ export const authRouter = createRouter();
|
||||
|
||||
authRouter.use(bodyParser());
|
||||
// After the body parser (it reads the claimed identity out of the body) and before everything else, so
|
||||
// that a probe rejected by origin validation or the rate limiter is recorded too. Observes only.
|
||||
// that a probe rejected by the rate limiter is recorded too. Observes only.
|
||||
authRouter.use(authAudit);
|
||||
// Extracts the Origin (or derives it from Referer) for the handlers that log it. Not a check — origin
|
||||
// validation was removed on 2026-08-13.
|
||||
authRouter.use(originMiddleware);
|
||||
authRouter.use(originValidationMiddleware);
|
||||
|
||||
authRouter.use('/', async (ctx) => ctx.json({ officerAuthServer: 'ok' }));
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ const TEST_USERS: number[] = [];
|
||||
export const signinHandler: Handler = async function (ctx) {
|
||||
const { email, password } = ctx.get('body');
|
||||
// A caller that sends neither Origin nor Referer — a server-to-server client, curl — leaves this
|
||||
// undefined. It used to be unreachable: originValidationMiddleware rejected those requests before this
|
||||
// handler ran, so the value was always a string by the time anything touched it. That is no longer
|
||||
// guaranteed (ALLOW_ANY_ORIGIN lets them through), and `undefined` reached both a SQL parameter and
|
||||
// `.startsWith`. Normalising to '' keeps every downstream use honest: no passkey is registered against
|
||||
// the empty origin, so an origin-less caller falls through to password auth, which is what it wants.
|
||||
// undefined. It was once unreachable: origin validation rejected those requests before this handler
|
||||
// ran, so the value was always a string by the time anything touched it. Then the flag that disabled
|
||||
// that check defaulted to on, and `undefined` reached both a SQL parameter and `.startsWith`. Origin
|
||||
// validation is gone entirely as of 2026-08-13, so undefined is now the ordinary case rather than the
|
||||
// edge one. Normalising to '' keeps every downstream use honest: no passkey is registered against the
|
||||
// empty origin, so an origin-less caller falls through to password auth, which is what it wants.
|
||||
const origin = (ctx.get('origin') as string | undefined) ?? '';
|
||||
|
||||
// Panic lockdown active → refuse all logins (looks like a normal failed login).
|
||||
|
||||
@@ -3,7 +3,15 @@ import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
|
||||
const DISCORD_WEBHOOK_URL = process.env.DISCORD_BUG_REPORT_WEBHOOK;
|
||||
// Bug reports land on disk and nowhere else.
|
||||
//
|
||||
// There was a Discord webhook here until 2026-08-13, behind DISCORD_BUG_REPORT_WEBHOOK. It was a
|
||||
// personal notification channel living in deployment config, on a self-hosted platform whose owner is
|
||||
// the only person filing reports — and it was never in .env.example, so the setup script wrote a
|
||||
// variable nothing documented.
|
||||
//
|
||||
// Nothing is lost from the report itself: the disk write below always happened first, and the webhook
|
||||
// was only a ping about it.
|
||||
|
||||
export const bugReportRouter = createRouter();
|
||||
|
||||
@@ -36,73 +44,9 @@ bugReportRouter.post('/', async (ctx) => {
|
||||
|
||||
await Bun.write(join(reportDir, 'report.json'), JSON.stringify(report, null, 2));
|
||||
|
||||
let screenshotBuffer: Buffer | null = null;
|
||||
if (screenshot instanceof File) {
|
||||
screenshotBuffer = Buffer.from(await screenshot.arrayBuffer());
|
||||
await Bun.write(join(reportDir, 'screenshot.png'), screenshotBuffer);
|
||||
}
|
||||
|
||||
if (DISCORD_WEBHOOK_URL) {
|
||||
await sendToDiscord(report, screenshotBuffer);
|
||||
await Bun.write(join(reportDir, 'screenshot.png'), Buffer.from(await screenshot.arrayBuffer()));
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true, id: dirName });
|
||||
});
|
||||
|
||||
type BugReport = {
|
||||
description: string;
|
||||
context: {
|
||||
url?: string;
|
||||
userAgent?: string;
|
||||
viewport?: { width: number; height: number };
|
||||
apiError?: { status: number; message: string } | null;
|
||||
} | null;
|
||||
reporter: { id: number; email: string; name: string | null };
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
async function sendToDiscord(report: BugReport, screenshot: Buffer | null) {
|
||||
const embed = {
|
||||
title: 'Bug Report',
|
||||
description: report.description,
|
||||
color: 0xed4245,
|
||||
fields: [
|
||||
{ name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true },
|
||||
{ name: 'URL', value: report.context?.url ?? 'N/A', inline: false },
|
||||
{
|
||||
name: 'Viewport',
|
||||
value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A',
|
||||
inline: true,
|
||||
},
|
||||
{ name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true },
|
||||
],
|
||||
timestamp: report.createdAt,
|
||||
};
|
||||
|
||||
if (report.context?.apiError) {
|
||||
embed.fields.push({
|
||||
name: 'Last API Error',
|
||||
value: `${report.context.apiError.status}: ${report.context.apiError.message}`,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('payload_json', JSON.stringify({ embeds: [embed] }));
|
||||
|
||||
if (screenshot) {
|
||||
form.append('files[0]', new Blob([new Uint8Array(screenshot)], { type: 'image/png' }), 'screenshot.png');
|
||||
}
|
||||
|
||||
const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
console.error('[bug-report] Discord webhook failed:', res.status, await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
function shortenUA(ua?: string): string {
|
||||
if (!ua) return 'N/A';
|
||||
const browser = ua.match(/(Chrome|Firefox|Safari|Edge|Brave|OPR)\/[\d.]+/)?.[0] ?? '';
|
||||
const os = ua.match(/\(([^)]+)\)/)?.[1]?.split(';')[0] ?? '';
|
||||
return [browser, os].filter(Boolean).join(' — ') || ua.slice(0, 80);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'officerdb';
|
||||
import { introduceAgentPanel } from '../agent-handoff/deliver';
|
||||
import { logger } from './logger';
|
||||
import { OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
|
||||
/**
|
||||
* The browser's half of the address book: name a panel, look up what a panel is, rename, remove.
|
||||
@@ -23,7 +24,7 @@ import { logger } from './logger';
|
||||
/** A name has to survive being typed into a prompt and into a shell, so keep it boring. */
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
const API_ORIGIN = `http://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const API_ORIGIN = OFFICER_API_URL;
|
||||
|
||||
export function registerAgentPanelRoutes(router: Hono<any>): void {
|
||||
// GET /chat/agent-panels?dashboardId=… — the address book for one dashboard.
|
||||
|
||||
@@ -31,9 +31,10 @@ import { readSttConfig } from '../server-settings/stt';
|
||||
/**
|
||||
* Whose transcripts a request may read.
|
||||
*
|
||||
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument whenever
|
||||
* HOME_DIR is set, which is how every read in this router used to resolve to the owner's `~/.claude` no matter
|
||||
* who asked. Throws rather than falling back, for the same reason `resolveTurnIdentity` refuses: there is no
|
||||
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument and
|
||||
* always answers the owner, which is how every read in this router used to resolve to the owner's
|
||||
* `~/.claude` no matter who asked. Throws rather than falling back, for the same reason
|
||||
* `resolveTurnIdentity` refuses: there is no
|
||||
* safe home to substitute, and the owner's is the one wrong answer.
|
||||
*
|
||||
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
|
||||
|
||||
@@ -23,8 +23,8 @@ import { DATA_PATH } from '../../data-path';
|
||||
//
|
||||
// ── Why this takes a home instead of an email ──
|
||||
//
|
||||
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever
|
||||
// HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S
|
||||
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discarded its argument
|
||||
// whenever HOME_DIR was set — which was always, on a real install. Every read therefore resolved to the OWNER'S
|
||||
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
|
||||
// that were a property rather than an assumption. A member reaching these functions would have been handed the
|
||||
// owner's conversation list.
|
||||
|
||||
@@ -86,7 +86,7 @@ const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
/**
|
||||
* Where a turn runs, relative to the caller's own home.
|
||||
*
|
||||
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set — so every
|
||||
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument — so every
|
||||
* `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is
|
||||
* the only account" as though that were a property rather than an assumption.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveBaseCwd } from '../chat/websocket';
|
||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||
import type { TurnMessage, MessageCost } from '../chat/types';
|
||||
import * as jobManager from './pipeline-job-manager';
|
||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
|
||||
const DEFAULT_MODEL = 'claude-code';
|
||||
|
||||
@@ -96,9 +97,8 @@ type RunStepParams = {
|
||||
};
|
||||
|
||||
async function refreshProxyToken(): Promise<void> {
|
||||
const port = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/refresh`, { method: 'POST' });
|
||||
await fetch(`${ANTHROPIC_PROXY_URL}/refresh`, { method: 'POST' });
|
||||
} catch {
|
||||
// Best effort — proxy may not be running (e.g. using API key directly)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sign } from '@@/jwt';
|
||||
import { PORT, OFFICER_API_URL } from '../../officer-url.mjs';
|
||||
|
||||
const { PORT = '5000', PUBLIC_URL } = process.env;
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
const PUBLIC_HOST = (() => {
|
||||
try {
|
||||
@@ -24,7 +25,7 @@ type TaskApiUser = { id: number; email: string; username?: string | null };
|
||||
export async function buildTaskApiEnv(user: TaskApiUser): Promise<Record<string, string>> {
|
||||
const token = await sign({ id: user.id, email: user.email, username: user.username ?? '' }, '12h');
|
||||
return {
|
||||
OFFICER_API_URL: `http://127.0.0.1:${PORT}`,
|
||||
OFFICER_API_URL,
|
||||
OFFICER_API_HOST: PUBLIC_HOST ?? `127.0.0.1:${PORT}`,
|
||||
OFFICER_AUTH_TOKEN: token,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import { capabilityAvailability } from '../../app-store/availability';
|
||||
// `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the
|
||||
// app registry and the route guards all read it, so it is the frontend's whole view of the permission
|
||||
// model — and it must never be the frontend's ENFORCEMENT of it. Hiding a dock icon is a courtesy; the
|
||||
// 403 in origin-validation is the lock.
|
||||
// 403 from the capability gate is the lock.
|
||||
//
|
||||
// Everything else here is owner-only and edits the policy itself.
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'offic
|
||||
import type { UserRole } from 'officerdb';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { OS_USERS_ENABLED } from '@@/os-user';
|
||||
import { validatePublicKey } from '@@/os-user-ssh';
|
||||
import { provisionOsAccount } from './provision-os';
|
||||
import { validatePassword } from '../auth/validate-password';
|
||||
@@ -97,9 +96,7 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
//
|
||||
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
|
||||
// account was made does not cost anybody their password and dashboards.
|
||||
const os = OS_USERS_ENABLED
|
||||
? await provisionOsAccount({ userId: user.id, email, username, inboundKey })
|
||||
: { osUser: null, sshPublicKey: null, error: null };
|
||||
const os = await provisionOsAccount({ userId: user.id, email, username, inboundKey });
|
||||
|
||||
if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`);
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { Handler } from 'hono';
|
||||
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { OS_USERS_ENABLED } from '@@/os-user';
|
||||
import { deprovisionOsAccount } from '@@/os-user-deprovision';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
|
||||
// Owner-only management of the other accounts. Everything here is gated by ownerGate in
|
||||
// users-router.ts; these handlers assume the caller is the Super Admin.
|
||||
@@ -54,9 +55,6 @@ export const listUsersHandler: Handler = async function (ctx) {
|
||||
roles: USER_ROLES,
|
||||
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||
ownerId: OWNER_USER_ID,
|
||||
// So the UI offers the Linux-account controls only where they can work. On a host without the feature
|
||||
// they would be a button that always reports the same refusal.
|
||||
osUsersEnabled: OS_USERS_ENABLED,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -104,6 +102,57 @@ export const deleteUserHandler: Handler = async function (ctx) {
|
||||
const existing = await getUserById(id);
|
||||
if (!existing) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// ── The Linux side goes FIRST, and its failure stops the delete ──
|
||||
//
|
||||
// This used to be the whole handler: remove the row, cascade the database, done. Measured on the
|
||||
// production host on 2026-08-12, immediately after deleting a member through this route: their Linux
|
||||
// account was still alive with a working login shell, their rootless Docker daemon was still running a
|
||||
// healthy postgres container, and 454 MB of their data was intact — while the platform had forgotten
|
||||
// they existed. `useradd` hands out the lowest free uid, so that number was queued up to be reissued to
|
||||
// the next member along with everything still owned by it.
|
||||
//
|
||||
// Ordered this way round because the row is what remembers there is anything to clean up. Delete it
|
||||
// first and a failed deprovision is unrecoverable through the UI: no row, no osUser, nothing to retry
|
||||
// against. Keeping the account on failure is also the safer half of the trade — an account that still
|
||||
// exists is inert, whereas a freed uid whose files still carry it is the hazard itself.
|
||||
if (existing.osUser) {
|
||||
const deprovisioned = await deprovisionOsAccount({ email: existing.email, osUser: existing.osUser });
|
||||
if (!deprovisioned.ok) {
|
||||
// Loud on purpose. A missing Docker install warns into a log; this one names the account, the stage
|
||||
// and the freed range — which after a failed release is the only surviving record of it.
|
||||
console.error(
|
||||
`[users] DEPROVISION FAILED for ${existing.email} at stage '${deprovisioned.stage}': ${deprovisioned.error}` +
|
||||
(deprovisioned.freed
|
||||
? ` — uid ${deprovisioned.freed.uid}, subuid ${deprovisioned.freed.subUid?.start ?? 'none'}` +
|
||||
` ${deprovisioned.freed.subUid?.count ?? ''}`
|
||||
: ''),
|
||||
);
|
||||
throw errors.INTERNAL_SERVER_ERROR(
|
||||
`Could not remove ${existing.email}'s Linux account: ${deprovisioned.error} ` +
|
||||
`The platform account was NOT deleted, so this can be retried.`,
|
||||
);
|
||||
}
|
||||
for (const warning of deprovisioned.warnings) console.warn(`[users] ${existing.email}: ${warning}`);
|
||||
if (deprovisioned.freed) {
|
||||
// The audit line. `scripts/assert-uid-free.sh --check` takes exactly these arguments, and after
|
||||
// `userdel` this log is the only place the freed subuid range still exists.
|
||||
//
|
||||
// DATA_PATH is spelled out rather than left to the operator, because this line exists to be COPIED
|
||||
// and sudo's env_reset drops it — the version without it fell back to a hardcoded default and made
|
||||
// the checker report CLEAN without reading a single member tree.
|
||||
const { osUser, uid, subUid } = deprovisioned.freed;
|
||||
const range = subUid ? `${subUid.start} ${subUid.count}` : null;
|
||||
console.info(
|
||||
range
|
||||
? `[users] deprovisioned ${osUser} — verify with: sudo DATA_PATH=${DATA_PATH} ` +
|
||||
`./scripts/assert-uid-free.sh --check ${osUser} ${uid} ${range}`
|
||||
: `[users] deprovisioned ${osUser} (uid ${uid}) — it had no /etc/subuid range, so only the uid ` +
|
||||
`half is verifiable: sudo DATA_PATH=${DATA_PATH} ./scripts/assert-uid-free.sh --check ` +
|
||||
`${osUser} ${uid} <start> <count> will refuse without real numbers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed
|
||||
// to them. There is no undo, which is why the UI asks first.
|
||||
await deleteUser(id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { updateUser } from 'officerdb';
|
||||
import { OS_USERS_ENABLED, ensureOsUser, osUserHome } from '@@/os-user';
|
||||
import { ensureOsUser, osUserHome } from '@@/os-user';
|
||||
import { provisionSshAccess } from '@@/os-user-ssh';
|
||||
import { seedShellConfig } from '@@/os-user-shell';
|
||||
import { provisionClaudeCli } from '@@/os-user-claude';
|
||||
@@ -12,7 +12,7 @@ import { provisionUserDirs } from '@@/data-path';
|
||||
// it has to happen at — the same reasoning as app-store/members.ts. The list of reasons a retry is needed is
|
||||
// not exotic:
|
||||
//
|
||||
// - the host was not set up for it when the account was made (`OFFICER_OS_USERS` off, no sudoers entry)
|
||||
// - the host was not set up for it when the account was made (no sudoers entry for the service user)
|
||||
// - an ancestor directory was not traversable, which is the one everybody hits once
|
||||
// - the owner wants to replace the inbound SSH key
|
||||
//
|
||||
@@ -42,10 +42,6 @@ export async function provisionOsAccount(params: {
|
||||
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
|
||||
inboundKey?: string | null;
|
||||
}): Promise<OsProvisionOutcome> {
|
||||
if (!OS_USERS_ENABLED) {
|
||||
return { osUser: null, sshPublicKey: null, error: 'per-user Linux accounts are not enabled on this server' };
|
||||
}
|
||||
|
||||
// First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at.
|
||||
try {
|
||||
provisionUserDirs(params.email);
|
||||
|
||||
@@ -15,8 +15,8 @@ usersRouter.use(originMiddleware);
|
||||
// Self-update. Any signed-in account may change its own name, username and avatar.
|
||||
usersRouter.put('/', updateUserHandler);
|
||||
|
||||
// Everything below manages OTHER accounts and is the owner's alone. The global capability backstop in
|
||||
// originScopeMiddleware already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
|
||||
// Everything below manages OTHER accounts and is the owner's alone. The global capability gate in
|
||||
// hono.ts already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
|
||||
// not grantable — but that router-level rule cannot see the one exception beside it: `PUT /` is
|
||||
// declared `selfService` so every account can edit its own profile. This gate is what keeps that
|
||||
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
|
||||
|
||||
@@ -15,7 +15,6 @@ import { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey }
|
||||
// • serves the native broker/unlock-key endpoints,
|
||||
// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored
|
||||
// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten.
|
||||
// The origin scoping (OFFICER_VAULT_ORIGIN → /api/vault) is enforced globally by originScopeMiddleware.
|
||||
|
||||
export const vaultRouter = createRouter();
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { resolveAuthToken } from '../../auth-token';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { isOriginAllowed } from '../../_middlewares';
|
||||
import { getVaultServerWsUrl } from './sidecar-server';
|
||||
import { getValidAccessToken } from './token-store';
|
||||
|
||||
@@ -140,14 +139,13 @@ export const vaultWebsocket = {
|
||||
|
||||
const PREFIX = '/api/vault';
|
||||
|
||||
// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated; the platform JWT rides the
|
||||
// query (?access_token= for SignalR, or ?token=). The session is validated in `open` (deferred). The
|
||||
// device never sends a Vaultwarden token — we inject the stored one upstream.
|
||||
// Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query
|
||||
// (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device
|
||||
// never sends a Vaultwarden token — we inject the stored one upstream.
|
||||
//
|
||||
// There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation.
|
||||
// It had defaulted to allow-everything, so it refused nothing on a real install.
|
||||
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
|
||||
const origin = req.headers.get('origin') ?? undefined;
|
||||
const host = req.headers.get('host') ?? undefined;
|
||||
if (!isOriginAllowed(origin, host)) return new Response('Forbidden', { status: 403 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || '';
|
||||
if (!platformToken) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dirname, join } from 'node:path';
|
||||
import { DATA_PATH } from '../data-path';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ROOT } from '../data-path';
|
||||
|
||||
// Where the app store puts the containers it provisions.
|
||||
//
|
||||
@@ -17,11 +17,6 @@ import { DATA_PATH } from '../data-path';
|
||||
// than configured separately, because a second environment variable that must agree with the first is a
|
||||
// second thing to get wrong — and on a correct install `data/` is always a direct child of the root.
|
||||
//
|
||||
// (This development machine predates the convention and has it inverted: the whole project sits inside
|
||||
// `~/dockers/officer.dev/`, so the root derives to `officer.dev` and the app store's directory would be
|
||||
// `~/dockers/officer.dev/dockers`. Which is ugly, and correct — it is still isolated, still under one
|
||||
// root, and still not mixed in with anything else. New installs get the clean shape.)
|
||||
//
|
||||
// ── Why this is not `~/dockers` ──
|
||||
//
|
||||
// That is where a seasoned user already keeps their own estate — 47 services on this machine alone. Two
|
||||
@@ -37,11 +32,13 @@ import { DATA_PATH } from '../data-path';
|
||||
// So this directory is exclusively ours to write, and everything in it was put there by an install.
|
||||
|
||||
/**
|
||||
* The install root — the parent of `data/`. On a conventional install, `~/officerdev`.
|
||||
* The install root — on a conventional install, `~/officerdev`.
|
||||
*
|
||||
* Derived, not configured: see above.
|
||||
* Derived, not configured: see above. It moved to `../data-path` on 2026-08-12, when the direction
|
||||
* inverted — it used to be `dirname(DATA_PATH)`, back when DATA_PATH was the environment variable that
|
||||
* anchored everything. Re-exported here because this file is where callers expect to find it.
|
||||
*/
|
||||
export const OFFICER_ROOT = dirname(DATA_PATH);
|
||||
export { OFFICER_ROOT };
|
||||
|
||||
/** Where provisioned services live, one directory each. Created on first install, not at boot. */
|
||||
export const DOCKERS_DIR = join(OFFICER_ROOT, 'dockers');
|
||||
|
||||
@@ -9,9 +9,10 @@ import type { CatalogueEntry } from './catalogue';
|
||||
//
|
||||
// ── What this deliberately does NOT do ──
|
||||
//
|
||||
// It does not install anything. Today nothing in `scripts/` installs Docker either — `setup.sh` runs
|
||||
// `setup-dockers.sh`, which invokes `docker compose` without ever checking it exists, so a fresh host
|
||||
// without Docker fails partway through setup with a bare "command not found". That is a real gap, and
|
||||
// It does not install anything. Today nothing in `scripts/` installs Docker either — the host installer
|
||||
// `scripts/setup/setup.sh` runs `scripts/setup/setup-dockers.sh`, which invokes `docker compose` without
|
||||
// ever checking it exists, so a fresh host without Docker fails partway through setup with a bare
|
||||
// "command not found". That is a real gap, and
|
||||
// the intended fix is a per-sidecar `setup.sh` that ensures its own dependencies — which is also the
|
||||
// shape a sidecar needs once it lives in its own repository and ships independently.
|
||||
//
|
||||
|
||||
@@ -9,7 +9,7 @@ import { verify } from './jwt';
|
||||
// ── One resolver, two doors ──
|
||||
//
|
||||
// Identity is decided in TWO independent middlewares: `userMiddleware`, which every protected router
|
||||
// mounts, and `originScopeMiddleware`, which runs globally in hono.ts and re-verifies the token itself
|
||||
// mounts, and `capabilityGateMiddleware`, which runs globally in hono.ts and re-verifies the token itself
|
||||
// because it must also cover routes that never mount `userMiddleware`. They have to agree about who a
|
||||
// caller is, and the way they stop agreeing is somebody teaching one of them a credential format the
|
||||
// other has never heard of — the second door would then see an unrecognisable token, resolve nobody, and
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom
|
||||
// A confined capability touches the filesystem or runs a process, and is safe only because the
|
||||
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
|
||||
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
|
||||
// `getOwnerHomeDir` ignores the email it is passed whenever HOME_DIR is set.
|
||||
// `getOwnerHomeDir` ignores the email it is passed, always.
|
||||
//
|
||||
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
|
||||
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
|
||||
|
||||
@@ -1,12 +1,70 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
import { chmodSync, mkdirSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
// ── The install root, and why it is derived rather than configured ──
|
||||
//
|
||||
// An install is one directory with everything under it:
|
||||
//
|
||||
// $OFFICER_ROOT/
|
||||
// platform/ the repo — the working directory every process runs in
|
||||
// data/ DATA_PATH
|
||||
// capabilities/ OFFICER_ITEMS_DIR
|
||||
// dockers/ what the app store provisions
|
||||
//
|
||||
// These were three environment variables until 2026-08-12, which meant three answers that had to agree
|
||||
// with each other and with the layout on disk. They are one fact now: the root is the parent of the
|
||||
// working directory, and everything else is a fixed name under it. Nothing to set and nothing to
|
||||
// disagree.
|
||||
//
|
||||
// This depends on the working directory being the repo, which is why pm2 pins `cwd` in
|
||||
// ecosystem.profile.cjs — read the comment there before changing either. `assertInstallLayout` below
|
||||
// is the check that says so out loud instead of silently writing to the wrong place.
|
||||
export const OFFICER_ROOT = resolve(process.cwd(), '..');
|
||||
|
||||
export const DATA_PATH = join(OFFICER_ROOT, 'data');
|
||||
|
||||
// Unified, file-based store for all agent items, living outside the repo. Every skill/tool/task/
|
||||
// process/extension is a directory under one of these type subfolders — no scope tiers, no DB.
|
||||
export const OFFICER_ITEMS_DIR = process.env.OFFICER_ITEMS_DIR ?? join(process.cwd(), 'officer-items');
|
||||
export const OFFICER_ITEMS_DIR = join(OFFICER_ROOT, 'capabilities');
|
||||
|
||||
/**
|
||||
* Refuse to boot when the working directory is not the platform repo.
|
||||
*
|
||||
* Same posture as `assertCapabilityTotality` and `assertSecretsClosed`: a prerequisite that silently
|
||||
* not holding is worse than one that fails. Every path in this file hangs off `resolve(process.cwd(), '..')`,
|
||||
* so a process started from the wrong directory does not error — it computes a plausible root somewhere
|
||||
* else and writes managed homes, capabilities and agent runs into it. The install looks empty and the
|
||||
* data looks lost, with nothing naming the cause.
|
||||
*
|
||||
* `ecosystem.profile.cjs` already pins `cwd` for exactly this reason. This is the check that the pin
|
||||
* still works, which is the part that was missing when the ecosystem files moved directory.
|
||||
*/
|
||||
export function assertInstallLayout(): void {
|
||||
const manifest = join(process.cwd(), 'package.json');
|
||||
let name: string | undefined;
|
||||
try {
|
||||
name = JSON.parse(readFileSync(manifest, 'utf8')).name;
|
||||
} catch {
|
||||
// Absent or unreadable is the same answer as wrong: this is not the repo.
|
||||
}
|
||||
|
||||
if (name === 'officer') return;
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Officer must run from the platform repo, but the working directory is ${process.cwd()}`,
|
||||
'',
|
||||
` expected a directory containing the platform's package.json ("officer")`,
|
||||
` found ${name ? `package.json for "${name}"` : 'no readable package.json'}`,
|
||||
'',
|
||||
'Every path is derived from this — the install root is its parent, and data/, capabilities/ and',
|
||||
`dockers/ hang off that. Continuing would write to ${OFFICER_ROOT} instead of the real install.`,
|
||||
'',
|
||||
'Under pm2 this means the `cwd` pin in ecosystem.profile.cjs no longer points at the repo.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
export type ItemType = 'skills' | 'tools' | 'tasks' | 'processes' | 'extensions' | 'agents';
|
||||
|
||||
@@ -33,14 +91,39 @@ export const SEED_PATH = resolve(import.meta.dir, '../../seed');
|
||||
|
||||
// The managed home under DATA_PATH. A remnant of the first architecture, where every user ran inside
|
||||
// their own Docker container and this was that container's home — seeded by provisioning, described to
|
||||
// the agent by a generated CLAUDE.md. Both of those are gone, and nothing executes here any more:
|
||||
// terminals, chats and task runs all use getOwnerHomeDir below. It survives only as that function's
|
||||
// fallback for when HOME_DIR is unset, and in pipeline-executor.
|
||||
// the agent by a generated CLAUDE.md. Both of those are gone, and the OWNER's sessions never come here
|
||||
// any more — terminals, chats and task runs all use getOwnerHomeDir below.
|
||||
//
|
||||
// It is not dead, though: user-home.ts returns it for a NON-owner, where it is deliberately the same
|
||||
// path as osUserHome, and pipeline-executor still calls it.
|
||||
//
|
||||
// It was also getOwnerHomeDir's fallback until 2026-08-12, which is the only reason an unset HOME_DIR
|
||||
// used to run the owner's terminals in a directory nobody meant.
|
||||
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||
|
||||
// Where the owner's sessions actually run: their real login home when HOME_DIR is set, so platform
|
||||
// terminals/chats/tasks share config and credentials with the shell they use outside Officer.
|
||||
export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email);
|
||||
// Where the owner's sessions actually run: their real login home, so platform terminals/chats/tasks
|
||||
// share config and credentials with the shell they use outside Officer.
|
||||
//
|
||||
// `homedir()` is right here for one reason, and it is worth stating because everything below rests on
|
||||
// it: the server process runs AS the owner. It is not a general "whose home is this" helper — a member
|
||||
// never reaches this function, because their sessions go through os-user.ts and setpriv. It ignores the
|
||||
// email it is passed, which it also did before; the callers that must not are already commented as such.
|
||||
//
|
||||
// ── Why this is captured once, and not read per call ──
|
||||
//
|
||||
// Measured on bun 1.3.10: BOTH `os.homedir()` and `os.userInfo().homedir` return $HOME when it is set,
|
||||
// rather than reading the password file. And `sidecar/claude/user-instance.ts` assigns `process.env.HOME`
|
||||
// on its way to spawning an agent. So a lazy read here would hand back whichever home was most recently
|
||||
// spawned into — the owner's on the first call and something else afterwards.
|
||||
//
|
||||
// This module imports nothing but node builtins, so it is evaluated before any of that can run. The
|
||||
// value is the owner's home, taken while $HOME still means what it says.
|
||||
const OWNER_HOME = homedir();
|
||||
|
||||
// This was HOME_DIR in .env until 2026-08-12, whose absence fell back to getHomeDir — the managed home
|
||||
// under DATA_PATH, not a login home at all. So forgetting to set it did not fail; it quietly ran every
|
||||
// terminal somewhere else.
|
||||
export const getOwnerHomeDir = (_email: string): string => OWNER_HOME;
|
||||
|
||||
// The directory skeleton a new account gets under DATA_PATH.
|
||||
//
|
||||
|
||||
+10
-14
@@ -57,8 +57,7 @@ import { agentStatusRouter } from './api/agent-status/router';
|
||||
import { chatRouter } from './api/chat/chat';
|
||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||
import { CustomError } from './custom-errors';
|
||||
import { userMiddleware, bodyParser, isOriginAllowed, originScopeMiddleware } from './_middlewares';
|
||||
import { isMusicOriginExempt } from './_middlewares/origin-validation';
|
||||
import { userMiddleware, bodyParser, capabilityGateMiddleware } from './_middlewares';
|
||||
|
||||
export { Hono };
|
||||
export { createRouter };
|
||||
@@ -66,14 +65,12 @@ export type { HonoVariables };
|
||||
|
||||
export const honoServer = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
// Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is
|
||||
// not a loosening: the check it replaced defaulted to off, so this is what every real install already
|
||||
// did. The perimeter is the tailnet and the lock is a valid token on every protected route, plus the
|
||||
// capability gate below.
|
||||
const corsMiddleware = cors({
|
||||
origin: (origin, c) => {
|
||||
const host = c.req.header('host');
|
||||
// TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a
|
||||
// browser client is not blocked by CORS after userMiddleware has already let it through.
|
||||
if (isMusicOriginExempt(c.req.path)) return origin ?? '*';
|
||||
return isOriginAllowed(origin, host) ? origin : '';
|
||||
},
|
||||
origin: (origin) => origin ?? '*',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
@@ -92,17 +89,16 @@ const isDavPath = (path: string) =>
|
||||
|
||||
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||
|
||||
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
|
||||
// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset.
|
||||
honoServer.use(originScopeMiddleware);
|
||||
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
|
||||
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
|
||||
honoServer.use(capabilityGateMiddleware);
|
||||
|
||||
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||
honoServer.route('/api/auth', authRouter);
|
||||
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
||||
honoServer.route('/api/waitlist', waitlistRouter);
|
||||
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. Origin
|
||||
// gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
|
||||
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
||||
honoServer.route('/api/vault', vaultRouter);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Where the app is, and the only place that knows.
|
||||
//
|
||||
// ── Why this file is .mjs ──
|
||||
//
|
||||
// Every process is bun except `officer-pty`, which pm2 launches with node (see
|
||||
// ecosystem.config.cjs). Node cannot import TypeScript, so a .ts module here would have left the pty
|
||||
// sidecar with its own copy — which is exactly the thing this file exists to end. Plain JS is
|
||||
// importable by both, and `allowJs` in tsconfig.json means the TS callers still get types.
|
||||
//
|
||||
// ── Why there is no default ──
|
||||
//
|
||||
// There were twenty-two, and they disagreed: 5000 in the app and nineteen sidecars, 9010 in
|
||||
// user-instance.ts, 9000 in .env.example. Each was defensible where it was written and none was
|
||||
// visible from the others.
|
||||
//
|
||||
// A default is a guess at a value that .env always supplies. The one case it covers — PORT genuinely
|
||||
// unset — is not a machine anyone wants running: it means .env was not loaded, so POSTGRES_URL is
|
||||
// missing too and nothing works anyway. What a default buys there is a process that starts, binds
|
||||
// somewhere unexpected and fails later for a reason that does not name the cause.
|
||||
//
|
||||
// So: no default anywhere, and this throws. Same posture as jwt.ts with JWT_SECRET.
|
||||
|
||||
const raw = process.env.PORT;
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
[
|
||||
'PORT is not set.',
|
||||
'',
|
||||
'Officer reads it from .env, which bun auto-loads from the working directory. If this is a pm2',
|
||||
'process, the `cwd` pin in ecosystem.profile.cjs is what puts it there — a PORT this empty',
|
||||
'usually means .env was never loaded at all, and POSTGRES_URL is missing too.',
|
||||
'',
|
||||
'Set PORT in .env (officer-setup writes 9000), or start from the repo root.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = Number(raw);
|
||||
|
||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error(`PORT must be an integer between 1 and 65535, but it is ${JSON.stringify(raw)}`);
|
||||
}
|
||||
|
||||
/** The port the app binds. Everything else addresses it; nothing else binds. */
|
||||
export const PORT = parsed;
|
||||
|
||||
// The app serves HTTP and WebSocket on ONE listener, so these are the same port in two protocols.
|
||||
// Sidecars append their own path — `/api/sidecar/register` for the registration socket.
|
||||
//
|
||||
// The env overrides are kept because they are explicit escape hatches rather than defaults: nothing
|
||||
// sets either today, and a value that is present is a deliberate act rather than a guess.
|
||||
export const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${PORT}`;
|
||||
export const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${PORT}`;
|
||||
|
||||
// ── The Anthropic proxy: PORT + 1, derived, not configured ──
|
||||
//
|
||||
// This is the ONE sidecar that binds a fixed port. Every other one binds `port: 0`, lets the kernel
|
||||
// choose, and reports what it got back over the registration socket — which works because their
|
||||
// consumer is the platform.
|
||||
//
|
||||
// The proxy cannot do that. Its consumer is `claude`, spawned by a DIFFERENT pm2 process
|
||||
// (officer-agent), which needs ANTHROPIC_BASE_URL at spawn time and has no channel to ask the proxy
|
||||
// what port it landed on. Two independent processes with nothing between them have to agree in
|
||||
// advance, so the number has to be predictable rather than discovered.
|
||||
//
|
||||
// It was ANTHROPIC_PROXY_PORT, defaulting to 5051 in four separate files: the one that binds it and
|
||||
// three that guessed the same constant to find it. 5051 was chosen against nothing and could collide
|
||||
// with anything the owner installs later, with the symptom being chat failing while the rest of the
|
||||
// platform looked healthy.
|
||||
//
|
||||
// PORT + 1 keeps the predictability and removes both problems. There is no variable to set, no second
|
||||
// number to keep in agreement with the first, and the pair moves together when the install moves.
|
||||
export const ANTHROPIC_PROXY_PORT = PORT + 1;
|
||||
export const ANTHROPIC_PROXY_URL = `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`;
|
||||
@@ -8,7 +8,7 @@ import { osUserHome, runAs } from './os-user';
|
||||
// A single `/usr/local/bin/claude` would be less disk and one version to reason about, and the argument for
|
||||
// it is real: the private part of Claude is the credential in `~/.claude`, not the executable. It is still
|
||||
// the wrong shape here. `claude` updates itself — that is why the owner's own install goes through
|
||||
// Anthropic's installer rather than npm (`scripts/setup.sh:853`) — and a root-owned binary is one a member
|
||||
// Anthropic's installer rather than npm (`scripts/setup/setup.sh:861`) — and a root-owned binary is one a member
|
||||
// cannot update, which turns "my agent is a version behind" into a request to the owner. Per-member also
|
||||
// means the account's agent keeps working exactly as the tool ships, with no platform-shaped exception to
|
||||
// explain. Same command the owner ran, run as them, in their home.
|
||||
@@ -29,7 +29,7 @@ import { osUserHome, runAs } from './os-user';
|
||||
// report whether the credential has appeared, so the UI can render the one-line instruction instead of an
|
||||
// agent that fails for reasons nobody can see.
|
||||
|
||||
/** Anthropic's own installer — the same one `scripts/setup.sh` uses for the owner, chosen for auto-update. */
|
||||
/** Anthropic's own installer — the same one `scripts/setup/setup.sh` uses for the owner, chosen for auto-update. */
|
||||
const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { guardDeletable, guardMemberTree, parseSubUidEntry } from './os-user-deprovision';
|
||||
|
||||
// The guards that stand between a database column and `sudo userdel` / `sudo chown -R`.
|
||||
//
|
||||
// Everything else in os-user-deprovision.ts needs a machine with accounts to delete on. These three are pure
|
||||
// precisely so the dangerous decisions can be tested without one — they are the part where being wrong is not
|
||||
// recoverable, and the manual teardown that produced the spec is not a thing anyone should repeat to check a
|
||||
// refactor.
|
||||
|
||||
describe('guardDeletable', () => {
|
||||
const home = '/data/member@example.com/home';
|
||||
|
||||
test('accepts an account the platform made', () => {
|
||||
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: home, expectedHome: home })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('refuses root, and every other system account', () => {
|
||||
// The scenario: a `users` row whose osUser column says 'root'. Nothing else in the sequence would stop it
|
||||
// — `loginctl`, `pkill` and `userdel` would all simply do as they were told.
|
||||
const result = guardDeletable({ osUser: 'root', uid: 0, passwdHome: '/root', expectedHome: home });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('system account');
|
||||
|
||||
expect(guardDeletable({ osUser: 'daemon', uid: 1, passwdHome: home, expectedHome: home }).ok).toBe(false);
|
||||
expect(guardDeletable({ osUser: 'nobody', uid: 999, passwdHome: home, expectedHome: home }).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses a same-named account that is not ours', () => {
|
||||
// A human account that happens to share a member's username. Its home is its own, so it fails the only
|
||||
// test that proves ownership. This is `ensureOsUser`'s adoption rule read backwards.
|
||||
const result = guardDeletable({ osUser: 'green', uid: 1002, passwdHome: '/home/green', expectedHome: home });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('not an account the platform created');
|
||||
});
|
||||
|
||||
test('refuses an account with no passwd home at all', () => {
|
||||
expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: null, expectedHome: home }).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('the uid floor is not sufficient on its own', () => {
|
||||
// Both halves are required: a high uid with a foreign home is still somebody else's account.
|
||||
expect(guardDeletable({ osUser: 'green', uid: 4000, passwdHome: '/srv/green', expectedHome: home }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardMemberTree', () => {
|
||||
test('resolves a normal account directory', () => {
|
||||
const result = guardMemberTree('/data', 'member@example.com');
|
||||
expect(result).toEqual({ ok: true, tree: '/data/member@example.com' });
|
||||
});
|
||||
|
||||
test('refuses traversal out of DATA_PATH', () => {
|
||||
// The path this produces is the argument to `chown -R` and, under destroy, to a recursive delete. The
|
||||
// email reaches join() from a database row.
|
||||
expect(guardMemberTree('/data', '../../etc').ok).toBe(false);
|
||||
expect(guardMemberTree('/data', '..').ok).toBe(false);
|
||||
expect(guardMemberTree('/data', 'a/../../b').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses a nested path even without traversal', () => {
|
||||
// A member tree is one level down. Anything deeper is not an account directory, whatever it is.
|
||||
expect(guardMemberTree('/data', 'a/b').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses DATA_PATH itself', () => {
|
||||
expect(guardMemberTree('/data', '').ok).toBe(false);
|
||||
expect(guardMemberTree('/data', '.').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses an empty DATA_PATH', () => {
|
||||
// Unset DATA_PATH would otherwise make every member tree a path under the process's cwd.
|
||||
expect(guardMemberTree('', 'member@example.com').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSubUidEntry', () => {
|
||||
const file = 'pastilhas:100000:65536\ngreen:231072:65536\nofficer_jg:165536:65536\n';
|
||||
|
||||
test('reads the range for the named account only', () => {
|
||||
expect(parseSubUidEntry(file, 'green')).toEqual({ start: 231072, count: 65536 });
|
||||
expect(parseSubUidEntry(file, 'officer_jg')).toEqual({ start: 165536, count: 65536 });
|
||||
});
|
||||
|
||||
test('a missing entry is null, not a guess', () => {
|
||||
// Normal: rootless Docker is tolerated when it fails, and accounts predating it have no entry. A guessed
|
||||
// range would make the audit check a different range than the one that was actually freed.
|
||||
expect(parseSubUidEntry(file, 'nobody')).toBeNull();
|
||||
expect(parseSubUidEntry('', 'green')).toBeNull();
|
||||
});
|
||||
|
||||
test('does not match on a prefix', () => {
|
||||
// 'green' must not match the 'green2' line — the ranges are different and the wrong one verifies nothing.
|
||||
expect(parseSubUidEntry('green2:300000:65536\n', 'green')).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a malformed line rather than producing NaN', () => {
|
||||
// NaN would flow into `assert-uid-free.sh --check` as an argument and quietly scan nothing.
|
||||
expect(parseSubUidEntry('green:notanumber:65536\n', 'green')).toBeNull();
|
||||
expect(parseSubUidEntry('green:231072:0\n', 'green')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { userInfo } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { DATA_PATH } from './data-path';
|
||||
import { lookupOsUser, osUserHome } from './os-user';
|
||||
|
||||
// Taking a member's Linux account away again.
|
||||
//
|
||||
// The specification, and every measured claim in it, is `docs/deprovision-os-account.md` — written from a
|
||||
// manual teardown on the production host on 2026-08-11. Read it before changing the ORDER of anything here.
|
||||
//
|
||||
// ── Why this is not the mirror image of provisioning ──
|
||||
//
|
||||
// `provisionOsAccount` may fail freely: an account that did not get its Linux side is merely unusable, and
|
||||
// the owner presses retry. This one cannot. `useradd` allocates the lowest free uid, so the moment `userdel`
|
||||
// returns, that number is available to the next member — while the previous member's home, keys and
|
||||
// container storage may still be owned by it. A half-finished deprovision does not leave a broken account;
|
||||
// it leaves a **trap**, and the next person to create an account walks into it.
|
||||
//
|
||||
// Hence the one rule the sequence exists to enforce:
|
||||
//
|
||||
// Sever the data from the uid BEFORE releasing the uid. If severing fails, DO NOT release.
|
||||
//
|
||||
// The manual teardown got exactly this backwards — it ran `userdel` first and cleaned up afterwards — and
|
||||
// the window it opened is the reason this file's steps are ordered rather than grouped.
|
||||
//
|
||||
// ── The subuid half ──
|
||||
//
|
||||
// A member's rootless Docker files are not owned by their uid. Container processes map through
|
||||
// `/etc/subuid`, so on the production host `green`'s postgres data directory was owned by 231141
|
||||
// (their range start + 70, postgres's uid inside the Alpine image). `userdel` frees the whole range along
|
||||
// with the uid. So "nothing is owned by uid 1002" can be true while hundreds of megabytes are still owned by
|
||||
// ids a future member's containers will map onto.
|
||||
//
|
||||
// `chown -R` fixes both at once — it rewrites every file it walks regardless of who owned it — which is why
|
||||
// severing is a single operation rather than one pass per id space. But the range still has to be CAPTURED
|
||||
// before `userdel`, because that is the last moment anyone can ask what it was. It is returned to the caller
|
||||
// for exactly that reason; `scripts/assert-uid-free.sh --check` takes it as an argument.
|
||||
|
||||
/** What to do with the member's files. `preserve` is the default and the only one with a call site. */
|
||||
export type DeprovisionPolicy = 'preserve' | 'destroy';
|
||||
|
||||
/**
|
||||
* The identity of the account as it was before deletion.
|
||||
*
|
||||
* Returned even on failure when it was captured, because it is unrecoverable afterwards: `userdel` removes
|
||||
* the `/etc/subuid` entry, and nothing on the machine then remembers which range the account held.
|
||||
*/
|
||||
export type FreedIdentity = {
|
||||
osUser: string;
|
||||
uid: number;
|
||||
/** Null when the account had no `/etc/subuid` entry — rootless Docker was never provisioned for it. */
|
||||
subUid: { start: number; count: number } | null;
|
||||
};
|
||||
|
||||
export type DeprovisionResult =
|
||||
| {
|
||||
ok: true;
|
||||
/** False when there was nothing to do — no Linux account by that name. Idempotent re-runs land here. */
|
||||
removed: boolean;
|
||||
freed: FreedIdentity | null;
|
||||
/** Non-fatal residue. The uid is safe to reissue; something cosmetic outlived the teardown. */
|
||||
warnings: string[];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
/** Which step refused. `sever` in particular means the account is intact and MUST stay that way. */
|
||||
stage: 'guard' | 'reap' | 'sever' | 'release';
|
||||
freed: FreedIdentity | null;
|
||||
};
|
||||
|
||||
async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> {
|
||||
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
|
||||
}
|
||||
|
||||
async function plain(args: string[]): Promise<{ ok: boolean; out: string }> {
|
||||
const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
|
||||
}
|
||||
|
||||
/** The account this process runs as — who severed data is reassigned to. */
|
||||
const serviceUser = (): string => userInfo().username;
|
||||
|
||||
/**
|
||||
* The account's `/etc/subuid` range, read before anything is removed.
|
||||
*
|
||||
* Absent is normal, not an error: rootless Docker is provisioned per member but tolerated when it fails, and
|
||||
* an account made before that existed has no entry at all.
|
||||
*/
|
||||
export function parseSubUidEntry(fileContent: string, osUser: string): { start: number; count: number } | null {
|
||||
for (const line of fileContent.split('\n')) {
|
||||
const [name, start, count] = line.split(':');
|
||||
if (name !== osUser) continue;
|
||||
const parsedStart = Number(start);
|
||||
const parsedCount = Number(count);
|
||||
if (!Number.isInteger(parsedStart) || !Number.isInteger(parsedCount) || parsedCount <= 0) return null;
|
||||
return { start: parsedStart, count: parsedCount };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many processes the uid still owns.
|
||||
*
|
||||
* `pgrep` exits 1 on no match, which is the expected answer here — so the exit code says nothing and the
|
||||
* line count is the measurement.
|
||||
*/
|
||||
async function processCount(uid: number): Promise<number> {
|
||||
const found = await plain(['pgrep', '-u', String(uid)]);
|
||||
if (!found.out) return 0;
|
||||
return found.out.split('\n').filter((line) => line.trim()).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to touch an account that is not ours to touch.
|
||||
*
|
||||
* This is `ensureOsUser`'s adoption rule pointed the other way, and it is the most important function in the
|
||||
* file. Adoption asks "may I take this account over"; if it is wrong, a member gets somebody else's home.
|
||||
* This asks "may I DELETE this account" — and if it is wrong, `userdel root` is a single sudo away, driven by
|
||||
* a string in a database row.
|
||||
*
|
||||
* The passwd home is the proof of ownership. `ensureOsUser` refuses to adopt an account whose home is not the
|
||||
* one it was going to confine, so any account the platform created or adopted has `home == osUserHome(email)`.
|
||||
* Anything else on this machine, by any name, does not — including every system account.
|
||||
*
|
||||
* Exported so it can be tested against the cases that matter without a machine to delete users on.
|
||||
*/
|
||||
export function guardDeletable(params: {
|
||||
osUser: string;
|
||||
uid: number;
|
||||
passwdHome: string | null;
|
||||
expectedHome: string;
|
||||
}): { ok: true } | { ok: false; error: string } {
|
||||
if (params.uid < 1000) {
|
||||
return { ok: false, error: `refusing to deprovision '${params.osUser}': uid ${params.uid} is a system account` };
|
||||
}
|
||||
if (params.passwdHome !== params.expectedHome) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`refusing to deprovision '${params.osUser}': its home is ${params.passwdHome ?? 'unknown'}, not ` +
|
||||
`${params.expectedHome}. This is not an account the platform created.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The member's tree must be a direct child of `DATA_PATH`, spelled with no traversal.
|
||||
*
|
||||
* The email arrives from a database row and reaches `join()`, so `../..` in it would walk out of `DATA_PATH`
|
||||
* — and this path is the argument to a recursive `chown` and, under `destroy`, to a recursive delete. Cheap
|
||||
* to assert, and the failure it prevents has no upper bound.
|
||||
*/
|
||||
export function guardMemberTree(
|
||||
dataPath: string,
|
||||
email: string,
|
||||
): { ok: true; tree: string } | { ok: false; error: string } {
|
||||
if (!dataPath) return { ok: false, error: 'DATA_PATH is empty; refusing to resolve a member tree' };
|
||||
const tree = resolve(join(dataPath, email));
|
||||
const parent = resolve(dataPath);
|
||||
if (tree === parent || !tree.startsWith(`${parent}/`) || tree.slice(parent.length + 1).includes('/')) {
|
||||
return { ok: false, error: `refusing to operate on ${tree}: not a direct child of ${parent}` };
|
||||
}
|
||||
return { ok: true, tree };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member's Linux account, severing their data from the uid first.
|
||||
*
|
||||
* Idempotent. An account that is already gone returns `{ ok: true, removed: false }`, and every step
|
||||
* tolerates having been done before — so a partial failure can be retried by calling this again rather than
|
||||
* by finishing it by hand.
|
||||
*
|
||||
* Never throws; returns a result. But unlike the provisioning functions, `ok: false` here is not
|
||||
* cosmetic — see the note at the top of the file, and `stage` on the failure result.
|
||||
*/
|
||||
export async function deprovisionOsAccount(params: {
|
||||
email: string;
|
||||
osUser: string;
|
||||
policy?: DeprovisionPolicy;
|
||||
}): Promise<DeprovisionResult> {
|
||||
const policy: DeprovisionPolicy = params.policy ?? 'preserve';
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Absent is success. This is what a re-run after a completed deprovision looks like, and what an account
|
||||
// that never had a Linux side looks like — neither is a problem to report.
|
||||
const ids = await lookupOsUser(params.osUser);
|
||||
if (!ids) return { ok: true, removed: false, freed: null, warnings };
|
||||
|
||||
const passwd = await plain(['getent', 'passwd', params.osUser]);
|
||||
const passwdHome = passwd.ok ? (passwd.out.split(':')[5] ?? null) : null;
|
||||
const expectedHome = osUserHome(params.email);
|
||||
|
||||
const allowed = guardDeletable({ osUser: params.osUser, uid: ids.uid, passwdHome, expectedHome });
|
||||
if (!allowed.ok) return { ok: false, stage: 'guard', freed: null, error: allowed.error };
|
||||
|
||||
const treeGuard = guardMemberTree(DATA_PATH, params.email);
|
||||
if (!treeGuard.ok) return { ok: false, stage: 'guard', freed: null, error: treeGuard.error };
|
||||
|
||||
// ── Capture, before anything can destroy the evidence ──
|
||||
//
|
||||
// Read now because `userdel` removes the /etc/subuid entry with the account, and after that there is no way
|
||||
// to ask what range it held. Carried on every return path below, including the failures, so an operator
|
||||
// auditing a partial teardown still has the numbers `assert-uid-free.sh --check` needs.
|
||||
const subuidFile = await sudo(['cat', '/etc/subuid']);
|
||||
const freed: FreedIdentity = {
|
||||
osUser: params.osUser,
|
||||
uid: ids.uid,
|
||||
subUid: subuidFile.ok ? parseSubUidEntry(subuidFile.out, params.osUser) : null,
|
||||
};
|
||||
if (!subuidFile.ok) warnings.push('could not read /etc/subuid; the freed range is unknown and unverifiable');
|
||||
|
||||
// ── 1. Linger off, before terminating anything ──
|
||||
//
|
||||
// Lingering keeps a systemd user manager alive with no login session. Terminate first and linger can bring
|
||||
// it straight back; disable first and nothing can respawn in the gap. A non-lingering account makes this a
|
||||
// no-op, which is why its failure is a warning rather than a stop.
|
||||
const linger = await sudo(['loginctl', 'disable-linger', params.osUser]);
|
||||
if (!linger.ok) warnings.push(`disable-linger reported: ${linger.out}`);
|
||||
|
||||
// ── 2. Terminate, then PROVE it ──
|
||||
//
|
||||
// Measured on the production host: `terminate-user` is not a barrier. A three-hour-old `/bin/zsh -i` owned
|
||||
// by the member survived it, and survived /run/user/<uid> being removed. `userdel` refuses while any
|
||||
// process owned by the account lives, so trusting this call works on a quiet account and fails on a member
|
||||
// who left a shell open — which is the normal case, not the edge one.
|
||||
await sudo(['loginctl', 'terminate-user', params.osUser]);
|
||||
|
||||
const reaped = await reapProcesses(ids.uid);
|
||||
if (!reaped.ok) return { ok: false, stage: 'reap', freed, error: reaped.error };
|
||||
|
||||
// ── 3. Sever the data from the uid, BEFORE releasing it ──
|
||||
const severed = await severMemberTree({ tree: treeGuard.tree, policy });
|
||||
if (!severed.ok) {
|
||||
// Deliberately returns here rather than continuing. An account left intact is inert; a freed uid whose
|
||||
// files are still owned by it is the hazard this whole file exists to prevent.
|
||||
return { ok: false, stage: 'sever', freed, error: severed.error };
|
||||
}
|
||||
|
||||
// ── 4. Release ──
|
||||
//
|
||||
// Never `-r`. It would delete the home, which contradicts `preserve` and would make `destroy` a consequence
|
||||
// of a flag rather than of an explicit decision. Plain `userdel` was measured to remove the passwd, shadow
|
||||
// and group entries and both the /etc/subuid and /etc/subgid ranges.
|
||||
const del = await sudo(['userdel', params.osUser]);
|
||||
if (!del.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'release',
|
||||
freed,
|
||||
error:
|
||||
`userdel failed for ${params.osUser}: ${del.out}. Their data has already been reassigned to ` +
|
||||
`${serviceUser()}, so the account is inert — but it still exists. Retry.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Residue: reported, not fatal. /run/user/<uid> is a tmpfs systemd normally reaps with the session; if it
|
||||
// outlives one, it is empty, cleared at reboot, and no reason to call a correct teardown a failure.
|
||||
if (existsSync(`/run/user/${ids.uid}`)) {
|
||||
warnings.push(`/run/user/${ids.uid} still exists; it is tmpfs and clears on reboot`);
|
||||
}
|
||||
if (existsSync(`/var/lib/systemd/linger/${params.osUser}`)) {
|
||||
warnings.push(`linger marker for ${params.osUser} survived disable-linger`);
|
||||
}
|
||||
|
||||
return { ok: true, removed: true, freed, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill everything the uid owns, escalating, and refuse to return success while any of it lives.
|
||||
*
|
||||
* The bounded waits are the point. `pkill` returns as soon as the signal is delivered, not when the process
|
||||
* has gone, so a check that follows it immediately reads the state before the kill took effect.
|
||||
*/
|
||||
async function reapProcesses(uid: number): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// Re-asserted here rather than trusted from the caller. This is the one function that signals by uid alone
|
||||
// — the name is not in the argv — so if the number were ever wrong, it would be wrong about somebody else's
|
||||
// processes with nothing else to catch it.
|
||||
if (uid < 1000) return { ok: false, error: `refusing to signal uid ${uid}: not a member account` };
|
||||
|
||||
if ((await processCount(uid)) === 0) return { ok: true };
|
||||
|
||||
await sudo(['pkill', '-u', String(uid)]);
|
||||
for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) {
|
||||
await Bun.sleep(200);
|
||||
}
|
||||
|
||||
if ((await processCount(uid)) > 0) {
|
||||
await sudo(['pkill', '-9', '-u', String(uid)]);
|
||||
for (let attempt = 0; attempt < 10 && (await processCount(uid)) > 0; attempt++) {
|
||||
await Bun.sleep(200);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await processCount(uid);
|
||||
if (remaining > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${remaining} process(es) owned by uid ${uid} survived SIGKILL; not releasing the account`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Break the link between the member's files and their uid.
|
||||
*
|
||||
* `preserve` reassigns; `destroy` reassigns and then deletes. Destroying via reassignment rather than via
|
||||
* `sudo rm -rf` is deliberate: after the chown the service user owns every byte and can delete the tree
|
||||
* itself, so a recursive delete as root — with a path built from a database column — never has to exist in
|
||||
* this codebase.
|
||||
*
|
||||
* `-h` because a symlink must be re-owned rather than followed. Measured on this host: `chown -R` already
|
||||
* behaves this way (a symlink pointing outside the tree was left untouched, and the link's own ownership was
|
||||
* rewritten), but the flag states it in the argv instead of resting on traversal semantics nobody documented
|
||||
* — and if it ever did follow, the target would be whatever a member chose to point at.
|
||||
*
|
||||
* Re-owning the symlinks is also what makes the audit meaningful: `find -uid` uses `lstat`, so a link left
|
||||
* owned by the freed uid is a failing check.
|
||||
*/
|
||||
async function severMemberTree(params: {
|
||||
tree: string;
|
||||
policy: DeprovisionPolicy;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// Nothing to sever is success — an account whose directories were already removed by hand still needs its
|
||||
// passwd entry released, and refusing here would leave that undone forever.
|
||||
if (!existsSync(params.tree)) return { ok: true };
|
||||
|
||||
const who = serviceUser();
|
||||
const chowned = await sudo(['chown', '-h', '-R', `${who}:${who}`, params.tree]);
|
||||
if (!chowned.ok) return { ok: false, error: `could not reassign ${params.tree} to ${who}: ${chowned.out}` };
|
||||
|
||||
// ── Ownership is not the only link to the uid ──
|
||||
//
|
||||
// `confineUserTree` grants the member a NAMED ACL entry on their whole tree — `u:<uid>:rwx` plus a
|
||||
// `default:` copy inherited by everything either party creates afterwards. `chown` does not remove those:
|
||||
// they are xattrs rather than ownership, and they record the uid NUMERICALLY.
|
||||
//
|
||||
// Measured on this host: after `chown -h -R` to the service user, `user:<uid>:rwx` was still present on the
|
||||
// directory, on its children, and in their defaults. So the tree reads as the platform's while still
|
||||
// granting the freed uid read and write on every byte of it — and the next account allocated that number
|
||||
// inherits the previous member's home, keys, credential and container storage. Severing ownership without
|
||||
// severing this just moves the hazard somewhere ownership checks cannot see it, including `find -uid`.
|
||||
//
|
||||
// `-b` removes every ACL rather than the member's entries alone. The service user owns all of it now, so a
|
||||
// named entry granting themselves what ownership already grants is redundant, and "no ACLs" is a much
|
||||
// cheaper thing to verify than "no ACL naming one particular id".
|
||||
//
|
||||
// `-P` is the physical walk. It is already the default for `setfacl -R` — verified here, a symlink out of
|
||||
// the tree was not followed — but it is stated for the same reason `chown` above carries `-h`: a member
|
||||
// chooses what their symlinks point at, and this argv should not depend on a traversal default holding.
|
||||
const stripped = await sudo(['setfacl', '-R', '-P', '-b', params.tree]);
|
||||
if (!stripped.ok) {
|
||||
return { ok: false, error: `reassigned ${params.tree} but could not clear its ACLs: ${stripped.out}` };
|
||||
}
|
||||
|
||||
if (params.policy === 'preserve') return { ok: true };
|
||||
|
||||
try {
|
||||
await rm(params.tree, { recursive: true, force: true });
|
||||
} catch (ex) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `reassigned ${params.tree} but could not delete it: ${ex instanceof Error ? ex.message : String(ex)}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { osUserHome } from './os-user';
|
||||
//
|
||||
// ── What it is ──
|
||||
//
|
||||
// `shell-skel/zshrc` → `~/.zshrc`, and the platform's own `scripts/starship.toml` → `~/.config/starship.toml`
|
||||
// `shell-skel/zshrc` → `~/.zshrc`, and the platform's own `scripts/setup/starship.toml` → `~/.config/starship.toml`
|
||||
// so a member's prompt is the same one the owner's install deploys. That file is the single source for both:
|
||||
// setup.sh copies it for the owner and this copies it for everybody else, so the two cannot drift.
|
||||
//
|
||||
@@ -31,7 +31,7 @@ import { osUserHome } from './os-user';
|
||||
/** Where the templates live, relative to this file. */
|
||||
const SKEL_DIR = join(import.meta.dir, 'shell-skel');
|
||||
/** The prompt config the owner's own install uses — one file, both audiences. */
|
||||
const STARSHIP_SRC = join(import.meta.dir, '../../scripts/starship.toml');
|
||||
const STARSHIP_SRC = join(import.meta.dir, '../../scripts/setup/starship.toml');
|
||||
|
||||
type SudoResult = { ok: boolean; out: string };
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@ import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
|
||||
// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever
|
||||
// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong.
|
||||
|
||||
/** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */
|
||||
export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1';
|
||||
|
||||
const MAX_USERNAME = 32;
|
||||
|
||||
/**
|
||||
@@ -484,23 +481,23 @@ export async function findReadableSecrets(projectDir: string): Promise<string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to boot with OS users enabled while a secret in the project tree is readable by them.
|
||||
* Refuse to boot while a secret in the project tree is readable by other accounts on this machine.
|
||||
*
|
||||
* Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
|
||||
* silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a
|
||||
* member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
|
||||
* bypass every capability check in the codebase.
|
||||
*
|
||||
* A no-op when the feature is off, so an existing install is unaffected until the owner opts in.
|
||||
* Unconditional. It was a no-op unless `OFFICER_OS_USERS` was set, which made the guarantee opt-in — and
|
||||
* a security prerequisite that only holds when someone remembers a flag is not a prerequisite.
|
||||
*/
|
||||
export async function assertSecretsClosed(projectDir: string): Promise<void> {
|
||||
if (!OS_USERS_ENABLED) return;
|
||||
const readable = await findReadableSecrets(projectDir);
|
||||
if (!readable.length) return;
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:',
|
||||
'These files are readable by other accounts on this machine:',
|
||||
'',
|
||||
...readable.map((p) => ` • ${p}`),
|
||||
'',
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { startRadicale, davPaths } from './radicale';
|
||||
import { listCollections, listEvents, listContacts } from './collections';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
// The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the
|
||||
// collection storage under DATA_PATH/dav, and exposes two very different doors.
|
||||
@@ -22,8 +24,6 @@ import { listCollections, listEvents, listContacts } from './collections';
|
||||
// machine-facing interface. Same split officer-email already uses.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const DATA_PATH = process.env.DATA_PATH ?? `${process.cwd()}/data`;
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
|
||||
@@ -35,7 +35,7 @@ console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`);
|
||||
|
||||
// Capture original HOME before user-instance overrides it
|
||||
const HOST_HOME = process.env.HOME!;
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
// Tear a persistent session down after this long with no new turn (see PersistentSession below).
|
||||
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { loadState, flushAndSave, acquireLock, releaseLock, getState } from './state';
|
||||
import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// ── Startup ──
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { join } from 'node:path';
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { getState, updateState } from './state';
|
||||
import { ANTHROPIC_PROXY_PORT as PROXY_PORT } from '../../officer-url.mjs';
|
||||
|
||||
const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051');
|
||||
const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
|
||||
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
|
||||
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
|
||||
/**
|
||||
* A resumable session, and whose it is.
|
||||
|
||||
@@ -17,6 +17,8 @@ import * as claudeManager from './claude-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { sign } from '../../jwt';
|
||||
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
|
||||
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
|
||||
// being told who to run as by the main server — one less thing that has to come from `officer` before
|
||||
@@ -60,12 +62,15 @@ async function resolveOwner() {
|
||||
const dbUser = await resolveOwner();
|
||||
const email = dbUser.email;
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
// Same officer instance for both, so the fallback port has to agree. It used to default to 5000 for the
|
||||
// WebSocket and 9010 for the REST base, which would have split them apart if PORT were ever unset.
|
||||
const OFFICER_PORT = process.env.PORT ?? '9010';
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${OFFICER_PORT}`;
|
||||
const OFFICER_API_URL = process.env.OFFICER_API_URL ?? `http://127.0.0.1:${OFFICER_PORT}`;
|
||||
// Both URLs address the SAME officer instance — this file binds nothing, and the app serves HTTP and
|
||||
// WebSocket on one listener. So the fallback port has to agree, and twice it did not: 5000 for the
|
||||
// WebSocket against 9010 for the REST base here, and then 9010 here against 5000 in the app and every
|
||||
// other sidecar. The second one was the worse half, because chat is the only thing that would have
|
||||
// broken and nothing else would have looked wrong.
|
||||
//
|
||||
// 9000 everywhere now, matching server.tsx and .env.example. 9010 was the old installer's default
|
||||
// (scripts/setup-old/setup.sh), which is why it was the only one of the three that ever matched a real
|
||||
// machine.
|
||||
const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
|
||||
|
||||
// Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them
|
||||
@@ -75,7 +80,9 @@ const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.u
|
||||
// perfect parity with terminal sessions (same config, credentials and transcript store,
|
||||
// interchangeable via `claude --resume`). That absence of isolation is precisely why `chat` is an
|
||||
// `execution` capability and can never be granted: this is a shell, not a feature flag.
|
||||
const homeDir = process.env.HOME_DIR ?? homedir();
|
||||
// Evaluated here, ABOVE the `process.env.HOME = homeDir` below: homedir() reads $HOME, so a
|
||||
// read placed after that assignment would return whatever was last spawned into.
|
||||
const homeDir = homedir();
|
||||
const globalToolsDir = join(DATA_PATH, 'tools');
|
||||
|
||||
// The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled).
|
||||
@@ -186,10 +193,9 @@ function generateMcpConfig(): string {
|
||||
// Resolved lazily rather than once at boot: PM2 starts the proxy and the agent together, and
|
||||
// `ensureProxySecret` persists on a 30s debounce, so on a first-ever boot the secret can be briefly
|
||||
// absent. Re-checked before every spawn until it lands.
|
||||
const ANTHROPIC_PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||
|
||||
function ensureAnthropicEnv(): void {
|
||||
process.env.ANTHROPIC_BASE_URL ??= `http://127.0.0.1:${ANTHROPIC_PROXY_PORT}`;
|
||||
process.env.ANTHROPIC_BASE_URL ??= ANTHROPIC_PROXY_URL;
|
||||
// Without this, every platform chat session is capped at 200K context while the same `claude` in a
|
||||
// terminal gets Opus 5's full 1M — for no reason other than the proxy hop above.
|
||||
//
|
||||
|
||||
@@ -5,7 +5,7 @@ type AnyCommand = SidecarCommand;
|
||||
type AnyEvent = SidecarEvent;
|
||||
|
||||
type SidecarConnectorConfig = {
|
||||
apiUrl: string; // ws://127.0.0.1:5000/api/sidecar/register
|
||||
apiUrl: string; // ws://127.0.0.1:9000/api/sidecar/register
|
||||
name: string;
|
||||
capabilities: string[];
|
||||
onCommand: (cmd: AnyCommand, reply: (msg: AnyEvent) => void) => void;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { initEmailIdle, stopEmailIdle } from './email-idle';
|
||||
import { broadcastEmailNew } from './routes';
|
||||
import { startEmailServer } from './http';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// The sidecar used to reach BACK into the platform's queue over this socket to get a sync run —
|
||||
// enqueueViaWs / listJobsViaWs and a pending-response map. Syncs run in this process now
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user