Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91147b403b | ||
|
|
ec6c793143 | ||
|
|
421e742590 | ||
|
|
6e805c3607 | ||
|
|
bf8bfa6859 | ||
|
|
9ec60d6d75 | ||
|
|
15392ae6c2 | ||
|
|
02f20d419d | ||
|
|
3b1d4cea84 | ||
|
|
3082949fac | ||
|
|
c226e8a46d | ||
|
|
f0801808c4 | ||
|
|
a873690e27 | ||
|
|
e867fe72af | ||
|
|
e6da5d8c77 | ||
|
|
0918dd7443 | ||
|
|
3fac1460cd | ||
|
|
f00abe816e | ||
|
|
6762a32ea6 | ||
|
|
68f3643df7 | ||
|
|
0d5c2c0083 | ||
|
|
b4cf1c6427 | ||
|
|
8705dc3a7c | ||
|
|
c14b686782 | ||
|
|
68d6dd942e | ||
|
|
d58f58813c | ||
|
|
276b12607a | ||
|
|
1c28a48299 | ||
|
|
5d51212cca | ||
|
|
a865bcfdfc | ||
|
|
d381140d54 | ||
|
|
2360a3ef60 | ||
|
|
185aa3168e | ||
|
|
fdf50ce2a4 | ||
|
|
52f209c69f | ||
|
|
e0df28ff5d | ||
|
|
6b5c87da10 | ||
|
|
131af603ff | ||
|
|
8a3047b06b | ||
|
|
2e7fb84454 | ||
|
|
5dc9f2d5f7 | ||
|
|
d02aac1eb8 | ||
|
|
e37fe8330e | ||
|
|
681df1c8bc | ||
|
|
7e6cfc85af | ||
|
|
be210c3ed4 | ||
|
|
d58652bf14 | ||
|
|
f6bd7564bb | ||
|
|
84d9c9d2d8 | ||
|
|
513a50f2e8 | ||
|
|
8b3ad8418a | ||
|
|
20d60cd3be | ||
|
|
f119bbdf93 | ||
|
|
de538cfc1b | ||
|
|
2faf1fa4b2 | ||
|
|
b5385ff40f | ||
|
|
0a4d7f8ad9 | ||
|
|
121a77ce7f | ||
|
|
a7c9dab22c | ||
|
|
5005726994 |
@@ -1,41 +1,594 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for any coding agent working in the Officer platform repo.
|
||||
|
||||
**The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.**
|
||||
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
Guidance for any coding agent working in the Officer platform repo. **This is the file with the
|
||||
instructions in it**; `CLAUDE.md` beside it only points here. Both exist because the two harnesses
|
||||
discover different names — opencode walks the tree for `AGENTS.md` and nothing else, Claude Code for
|
||||
`CLAUDE.md` — and one set of instructions must reach both.
|
||||
|
||||
## Orientation
|
||||
|
||||
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
|
||||
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
|
||||
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
|
||||
per-role permission grants, resolved on every request.
|
||||
Officer is built around **one owner** (user id 1, role `Super Admin`, who bypasses every permission
|
||||
check), which since 2026-08-07 also admits **additional accounts holding a strict subset of it**. What
|
||||
each may reach is decided by per-role permission grants, resolved on every request.
|
||||
|
||||
If a design question turns on "which user", the answer depends on the surface: real for the **app**
|
||||
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything
|
||||
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
|
||||
permissions (gitea, music, photos, calendar…), and still always **the owner** for anything that
|
||||
executes code or touches the disk — terminal, chat, tasks, files, desktop and browser are
|
||||
`kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority.
|
||||
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions"
|
||||
section of `CLAUDE.md` before adding one.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the Permissions
|
||||
section below before adding one.
|
||||
|
||||
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
|
||||
management". That was written to correct an _older_ drift in the opposite direction — a fictional
|
||||
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
|
||||
paragraph above; treat the permission registry as the source of truth over either.
|
||||
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as plain
|
||||
files, and is where many changes belong — adding or changing a task needs no code change here and no
|
||||
restart.
|
||||
|
||||
This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as
|
||||
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
||||
and no restart.
|
||||
|
||||
- [`CLAUDE.md`](CLAUDE.md) — this repo's architecture, conventions and code style
|
||||
- [`docs/working-on-officer.md`](docs/working-on-officer.md) — the deployment-wide guide: which
|
||||
directory a change belongs in, how to run and verify it, the task system, and the failure modes
|
||||
worth knowing about
|
||||
directory a change belongs in, how to run and verify it, the task system, and the failure modes worth
|
||||
knowing about
|
||||
- `CONVENTIONS.md` — component organisation and React patterns, with rationale
|
||||
- `TODO.md` — current direction; takes precedence where it disagrees with anything else
|
||||
|
||||
Treat the code as the source of truth where anything disagrees with it.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
|
||||
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
|
||||
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
|
||||
behind a single web app.
|
||||
|
||||
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
|
||||
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
|
||||
written: `users` holds six rows. The accurate statement is narrower and more useful —
|
||||
|
||||
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
||||
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
||||
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
|
||||
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
|
||||
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
|
||||
home, and the kernel refusing everything above it.
|
||||
|
||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
|
||||
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
|
||||
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
|
||||
HTTP routes, the websocket doors and the dock together.
|
||||
|
||||
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
|
||||
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||
anything under `execution`.
|
||||
|
||||
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||
|
||||
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||
the counts stay right and the sentences rot.
|
||||
|
||||
## Architecture
|
||||
|
||||
One Bun process (`src/server.tsx`) serves everything:
|
||||
|
||||
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
|
||||
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
|
||||
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
||||
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
||||
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
||||
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
|
||||
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
|
||||
|
||||
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
||||
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
||||
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
||||
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
||||
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
||||
source of truth.
|
||||
|
||||
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
|
||||
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
|
||||
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
||||
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
||||
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
||||
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
|
||||
|
||||
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
|
||||
deliberate — it is the owner's own machine. Do not add a jail without being asked.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
||||
├── apps/
|
||||
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
||||
├── servers/
|
||||
│ ├── hono.ts # router composition; everything under /api
|
||||
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
|
||||
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||
│ ├── queue/ # background job engine
|
||||
│ └── sidecar/ # sidecar implementations + the wire protocol
|
||||
├── databases/officer_db/ # the only database (Postgres + Drizzle)
|
||||
├── extensions/browser-relay/
|
||||
└── workspaces/ # shared packages, each a bun workspace
|
||||
```
|
||||
|
||||
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
|
||||
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
|
||||
`src/apps/officer-web` is only the shell — screens, routing and settings.
|
||||
|
||||
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
|
||||
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
|
||||
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **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`.
|
||||
|
||||
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
|
||||
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
|
||||
watched. Two places carry that number and they move together: `BUN_SERIES` in
|
||||
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
|
||||
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
|
||||
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
|
||||
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
|
||||
|
||||
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
|
||||
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
|
||||
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
|
||||
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
|
||||
|
||||
- **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
|
||||
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
|
||||
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
|
||||
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
|
||||
|
||||
## Data
|
||||
|
||||
Two stores, and the split matters:
|
||||
|
||||
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
||||
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
|
||||
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
|
||||
catalogue served by a process with no login, no session and no user, so sharing a connection would put
|
||||
an unauthenticated surface one query away from every private table. It runs only on the host serving
|
||||
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
|
||||
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
|
||||
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
|
||||
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
|
||||
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
|
||||
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
|
||||
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
|
||||
what is inside it.
|
||||
|
||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
|
||||
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
|
||||
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
|
||||
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
|
||||
failing.
|
||||
|
||||
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
|
||||
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
|
||||
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
|
||||
passed).
|
||||
|
||||
### Schema changes use `push`, not migrations
|
||||
|
||||
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
|
||||
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
|
||||
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
|
||||
|
||||
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
|
||||
history was deleted because it had drifted from the real schema. Treat the schema code, not those
|
||||
files, as the source of truth.
|
||||
|
||||
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
|
||||
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
|
||||
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
|
||||
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
||||
"Composite keys" before adding either.
|
||||
|
||||
## 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. Rate limiting and password
|
||||
rules key off it — they fail closed.
|
||||
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
|
||||
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
|
||||
what came out was documented defence in depth that was already switched off. Origin was never
|
||||
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
|
||||
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
|
||||
is a valid token on every protected route plus the permission gate below.
|
||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||
authenticated request until the server restarts.
|
||||
|
||||
### Permissions — read this before mounting a router
|
||||
|
||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
|
||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||
`userMiddleware`.
|
||||
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
|
||||
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
|
||||
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
||||
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
||||
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
||||
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
|
||||
survive the next door; refusing to boot does.
|
||||
|
||||
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
|
||||
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
||||
is how the hole happened the first time.
|
||||
|
||||
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||
into a refusal.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun dev # the whole app — SPA + API + WebSockets, watched
|
||||
bun start # production
|
||||
bunx tsgo # typecheck (not tsc)
|
||||
bun test # tests
|
||||
bun format # prettier over every dirty file — see the note below before running it
|
||||
bun db:push # apply the schema to Postgres
|
||||
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
```
|
||||
|
||||
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
|
||||
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
|
||||
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
|
||||
place to drift, and it drifts in the same edit that misses the doc.
|
||||
|
||||
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
|
||||
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
|
||||
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
|
||||
written had been wrong for over a week.
|
||||
|
||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
|
||||
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
|
||||
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
|
||||
needs to, and on macOS never does, because Homebrew refuses to run as root.
|
||||
|
||||
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
||||
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
||||
|
||||
### Installs are frozen. Never resolve a dependency you did not ask for.
|
||||
|
||||
`bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and
|
||||
nothing else** — it fails rather than quietly picking up a newer version, including a transitive one
|
||||
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
|
||||
`error: lockfile had changes, but lockfile is frozen`.
|
||||
|
||||
It is config rather than a habit because a supply-chain compromise does not wait for the one time
|
||||
somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`,
|
||||
`file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were
|
||||
published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes
|
||||
credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself
|
||||
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
|
||||
unaffected only because nothing had installed since 2026-08-02.
|
||||
|
||||
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
|
||||
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
|
||||
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
|
||||
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
|
||||
in a diff is the signal this exists to produce.
|
||||
|
||||
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
|
||||
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
|
||||
|
||||
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
|
||||
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
|
||||
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
||||
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Paradigm**: functional — pure functions, immutability, composition
|
||||
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
||||
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
|
||||
- **Async**: always async/await
|
||||
- **Exports**: named only, no defaults
|
||||
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
||||
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
|
||||
|
||||
### Imports
|
||||
|
||||
Order: types → external → workspace → relative.
|
||||
|
||||
```ts
|
||||
import type { User } from 'types';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { formatDate } from 'helpers/formatters';
|
||||
import { useWebsites } from '../useWebsites';
|
||||
```
|
||||
|
||||
In app code import types from `'types'`, which re-exports the database types. Only server and
|
||||
database code imports from `officerdb/types` directly.
|
||||
|
||||
### Functions
|
||||
|
||||
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
|
||||
parameter lists — extract a params type instead.
|
||||
|
||||
```ts
|
||||
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
|
||||
export function processData({ body, query }: ProcessDataParams): Result { ... }
|
||||
```
|
||||
|
||||
### Components and hooks
|
||||
|
||||
```tsx
|
||||
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
|
||||
|
||||
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
|
||||
```
|
||||
|
||||
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
|
||||
always named `ev`, never `e`.
|
||||
|
||||
### API calls
|
||||
|
||||
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
|
||||
type parameter and let inference flow from it.
|
||||
|
||||
## Working With Me
|
||||
|
||||
- **Ask first** — confirm the approach before a significant change
|
||||
- **Explore thoroughly** — read the related files before editing
|
||||
- **Keep it simple** — no over-engineering, no premature abstraction
|
||||
- **Be explicit** — no magic, no implicit behaviour
|
||||
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
||||
- **Report honestly** — say what you verified and what you didn't
|
||||
|
||||
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
|
||||
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
|
||||
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
|
||||
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
|
||||
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
|
||||
withholding the commit until it is good.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes.
|
||||
|
||||
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
|
||||
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
|
||||
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
|
||||
the two-step form only looks like it does.
|
||||
|
||||
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
|
||||
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
|
||||
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||
```
|
||||
|
||||
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||
restart in a single night. The transcript id survived both.
|
||||
|
||||
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||
whose "why" is still answerable in six months.
|
||||
|
||||
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||
add the trailer and one that does not should leave it out rather than guess.
|
||||
|
||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||
|
||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||
|
||||
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
|
||||
`<WorkspaceView workspace={ws} locked />` where
|
||||
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
|
||||
that pins `appType`s to an allow-list. Panels are windowed apps under
|
||||
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
|
||||
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
|
||||
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
|
||||
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
|
||||
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
|
||||
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
|
||||
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
|
||||
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
|
||||
`usePublishPageTitle` in `officerdev`.
|
||||
|
||||
### The URL is the source of truth for selection
|
||||
|
||||
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
|
||||
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
|
||||
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
|
||||
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
|
||||
link-focusable). Half the app still does this; none of the new code should.
|
||||
|
||||
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
|
||||
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
|
||||
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
|
||||
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
|
||||
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
|
||||
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
|
||||
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
||||
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
||||
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
||||
_mutates_ rather than navigates stays a `<button>`.
|
||||
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
||||
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
|
||||
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
||||
canonicalises both the bare route and a bogus param.
|
||||
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
|
||||
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
|
||||
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
|
||||
Action buttons are **siblings** of the anchor, never nested inside it.
|
||||
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
|
||||
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
|
||||
when you want `isActive`.
|
||||
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
|
||||
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
||||
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
||||
|
||||
## COMMS — a channel between agents, when one is open
|
||||
|
||||
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
|
||||
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
|
||||
sessions.
|
||||
|
||||
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
|
||||
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
|
||||
convention rather than an oversight: a spent channel left in place gets read as current.
|
||||
|
||||
If you open one:
|
||||
|
||||
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
|
||||
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
|
||||
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
|
||||
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
|
||||
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
|
||||
agent read identically.
|
||||
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
|
||||
participant. "I think we're done" can close a thread with work still in it.
|
||||
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
|
||||
lands, delete the channel and move anything still open to `TODO.md`.
|
||||
|
||||
Two things that made it work, and neither is about either agent being more careful. One writes, the other
|
||||
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
|
||||
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
|
||||
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
|
||||
explaining why it was safe.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
||||
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
||||
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
|
||||
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
|
||||
a number produced by searching is confidently wrong with no signal that it is
|
||||
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
|
||||
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
|
||||
question to settle first, which is retention rather than anything technical
|
||||
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
|
||||
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
|
||||
agent. Read before writing one
|
||||
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
||||
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
||||
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
|
||||
it is what `docs/workspace-panel-todo.md` is ranked against.
|
||||
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
||||
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/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
|
||||
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
|
||||
check turns into an endless resync between servers without it. The server identifier is an SSO decision
|
||||
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
|
||||
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
|
||||
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
|
||||
before any agent is asked to look
|
||||
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
|
||||
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
|
||||
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
|
||||
one home directory happened to be mode 751
|
||||
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
|
||||
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
|
||||
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
|
||||
remember, and the credential-resolution work that has to land first
|
||||
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
|
||||
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
|
||||
because otherwise "add a role" and "become the owner" are the same feature
|
||||
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
|
||||
web UI to more than one language. What exists and what it cannot do yet, why the server should return
|
||||
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
|
||||
naming convention, and the four phases. Read before adding a user-facing string
|
||||
- `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
|
||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||
anything it already rules out
|
||||
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
|
||||
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
|
||||
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
|
||||
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
|
||||
`proxy-manager` ships a chat brief today)
|
||||
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
a namespace makes provenance readable, separate directories make it structural
|
||||
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
|
||||
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
|
||||
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
|
||||
get wrong — a team folder that is not `setgid` charges nothing to the team
|
||||
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
||||
- `src/apps/CLAUDE.md` — shared frontend patterns
|
||||
- `src/databases/CLAUDE.md` — database patterns
|
||||
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
|
||||
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
|
||||
|
||||
Treat the code as the source of truth where anything here disagrees with it.
|
||||
|
||||
@@ -1,563 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
|
||||
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
|
||||
a file browser, a code editor, a bitcoin wallet, a remote desktop and customisable dashboards
|
||||
behind a single web app.
|
||||
|
||||
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
|
||||
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
|
||||
written: `users` holds six rows. The accurate statement is narrower and more useful —
|
||||
|
||||
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
||||
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
||||
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
|
||||
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
|
||||
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
|
||||
home, and the kernel refusing everything above it.
|
||||
|
||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
|
||||
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
|
||||
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
|
||||
HTTP routes, the websocket doors and the dock together.
|
||||
|
||||
So "which user is this" has a real answer for the **app** surface (gitea, music, photos,
|
||||
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||
anything under `execution`.
|
||||
|
||||
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||
|
||||
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||
the counts stay right and the sentences rot.
|
||||
|
||||
## Architecture
|
||||
|
||||
One Bun process (`src/server.tsx`) serves everything:
|
||||
|
||||
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
|
||||
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
|
||||
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
||||
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
||||
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
||||
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
|
||||
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
|
||||
|
||||
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
||||
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
||||
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
|
||||
`officer-opencode`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
||||
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
|
||||
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
|
||||
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
|
||||
source of truth.
|
||||
|
||||
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
|
||||
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
|
||||
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
||||
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
||||
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
||||
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
|
||||
|
||||
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
|
||||
deliberate — it is the owner's own machine. Do not add a jail without being asked.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
||||
├── apps/
|
||||
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
||||
├── servers/
|
||||
│ ├── hono.ts # router composition; everything under /api
|
||||
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
|
||||
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||
│ ├── queue/ # background job engine
|
||||
│ └── sidecar/ # sidecar implementations + the wire protocol
|
||||
├── databases/officer_db/ # the only database (Postgres + Drizzle)
|
||||
├── extensions/browser-relay/
|
||||
└── workspaces/ # shared packages, each a bun workspace
|
||||
```
|
||||
|
||||
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
|
||||
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
|
||||
`src/apps/officer-web` is only the shell — screens, routing and settings.
|
||||
|
||||
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
|
||||
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
|
||||
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **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`.
|
||||
|
||||
**Bun itself is held at the 1.3 series, on purpose and temporarily.** 1.4.0 was published
|
||||
2026-08-20; the owner asked on 2026-08-22 to stay on 1.3 for a few months until 1.4 has been
|
||||
watched. Two places carry that number and they move together: `BUN_SERIES` in
|
||||
`scripts/setup/machine-setup/lib/dev.sh`, which resolves the newest `1.3.x` from the npm registry
|
||||
and passes it to the installer as a release tag, and `engines.bun` in `package.json`, which is
|
||||
what `bun run doctor` reads. bun ignores `engines` (verified — an install declaring `1.9.x` ran
|
||||
clean under 1.3.14), so that field is the declaration and doctor is the enforcement.
|
||||
|
||||
**Do not run `bun upgrade` on a machine running Officer.** It takes no version argument — latest
|
||||
stable or `--canary`, nothing else — so it jumps straight past the pin and says nothing. That is
|
||||
the whole reason `doctor` checks the running version at all; `machine-setup.sh --only 'JavaScript
|
||||
runtimes'` offers to pin a drifted machine back down. Lifting the hold is `todo/infra/`.
|
||||
|
||||
- **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
|
||||
- **Database**: Postgres via Drizzle — `officer` for the platform, plus `officer_marketplace` on the
|
||||
host that serves marketplace.officer.dev (see `docs/marketplace.md`)
|
||||
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
|
||||
|
||||
## Data
|
||||
|
||||
Two stores, and the split matters:
|
||||
|
||||
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
||||
queue and pipeline jobs. Since 2026-08-16 there is a SECOND database,
|
||||
`officer_marketplace` (`src/databases/marketplace_db`), and the split is deliberate: it is a public
|
||||
catalogue served by a process with no login, no session and no user, so sharing a connection would put
|
||||
an unauthenticated surface one query away from every private table. It runs only on the host serving
|
||||
marketplace.officer.dev — `docs/marketplace.md`. One directory per feature holding `schema.ts` and
|
||||
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
|
||||
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
|
||||
|
||||
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
|
||||
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
|
||||
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home and attachments. It also holds
|
||||
`email_accounts/`, which is the **email plugin's** — the platform pre-creates the directory and never opens
|
||||
what is inside it.
|
||||
|
||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
|
||||
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
|
||||
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
|
||||
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
|
||||
failing.
|
||||
|
||||
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
|
||||
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
|
||||
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
|
||||
passed).
|
||||
|
||||
### Schema changes use `push`, not migrations
|
||||
|
||||
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
|
||||
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
|
||||
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
|
||||
|
||||
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
|
||||
history was deleted because it had drifted from the real schema. Treat the schema code, not those
|
||||
files, as the source of truth.
|
||||
|
||||
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
|
||||
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
|
||||
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
|
||||
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
||||
"Composite keys" before adding either.
|
||||
|
||||
## 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. Rate limiting and password
|
||||
rules key off it — they fail closed.
|
||||
- **There is no origin checking.** It was removed on 2026-08-13, along with `ALLOW_ANY_ORIGIN` and
|
||||
`ALLOW_ANY_ORIGIN_MUSIC`. The flag defaulted to ON, so origin validation ran on no real install —
|
||||
what came out was documented defence in depth that was already switched off. Origin was never
|
||||
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
|
||||
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
|
||||
is a valid token on every protected route plus the permission gate below.
|
||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||
authenticated request until the server restarts.
|
||||
|
||||
### Permissions — read this before mounting a router
|
||||
|
||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
|
||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||
`userMiddleware`.
|
||||
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
|
||||
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
|
||||
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
||||
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
||||
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
||||
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
|
||||
survive the next door; refusing to boot does.
|
||||
|
||||
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
|
||||
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
||||
is how the hole happened the first time.
|
||||
|
||||
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||
into a refusal.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun dev # the whole app — SPA + API + WebSockets, watched
|
||||
bun start # production
|
||||
bunx tsgo # typecheck (not tsc)
|
||||
bun test # tests
|
||||
bun format # prettier over every dirty file — see the note below before running it
|
||||
bun db:push # apply the schema to Postgres
|
||||
bun docs:check # do the docs still match the code? REQUIRED before tagging a release
|
||||
bun setup # runs scripts/install.sh — blank machine to running platform
|
||||
```
|
||||
|
||||
**`bun docs:check` before every release, and leave it green.** It compares documented claims against values
|
||||
derived at run time — the permission count, the PM2 entry count, every settings route Spotlight offers — and
|
||||
exits non-zero on drift. A check must never compare a doc against a second hard-coded copy; that is one more
|
||||
place to drift, and it drifts in the same edit that misses the doc.
|
||||
|
||||
It is narrow on purpose. It cannot read prose and judge it, only claims with a machine-checkable
|
||||
counterpart — which is a small part of the documentation and the part that rots fastest, because a number is
|
||||
exactly what nobody re-derives while editing the sentence around it. Both claims it found on the day it was
|
||||
written had been wrong for over a week.
|
||||
|
||||
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
|
||||
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
|
||||
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
|
||||
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
|
||||
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
|
||||
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
|
||||
needs to, and on macOS never does, because Homebrew refuses to run as root.
|
||||
|
||||
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
||||
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
||||
|
||||
### Installs are frozen. Never resolve a dependency you did not ask for.
|
||||
|
||||
`bunfig.toml` sets `[install] frozenLockfile = true`, so **`bun install` resolves from `bun.lock` and
|
||||
nothing else** — it fails rather than quietly picking up a newer version, including a transitive one
|
||||
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies `package.json` exits 1 with
|
||||
`error: lockfile had changes, but lockfile is frozen`.
|
||||
|
||||
It is config rather than a habit because a supply-chain compromise does not wait for the one time
|
||||
somebody forgets a flag. On **2026-08-04** eleven cache packages — `keyv`, `flat-cache`,
|
||||
`file-entry-cache`, `cacheable-request`, `cache-manager`, the `@cacheable/*` scope, `ecto` — were
|
||||
published with a `preinstall` dropper that harvested npm and GitHub tokens, AWS and Kubernetes
|
||||
credentials, SSH and PEM keys, `.env` files and `.claude/settings.json`, then republished itself
|
||||
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
|
||||
unaffected only because nothing had installed since 2026-08-02.
|
||||
|
||||
**To change a dependency:** edit `package.json`, allow the lockfile to change deliberately, **read the
|
||||
diff**, and commit it. Note that `--no-frozen-lockfile` — which this file and `bunfig.toml` both used to
|
||||
name — is NOT a flag bun 1.3.14 has; it is silently ignored and the install still fails frozen. Comment
|
||||
out `frozenLockfile` in `bunfig.toml` for the one command, then put it back. The friction is the point — an unexplained lockfile change
|
||||
in a diff is the signal this exists to produce.
|
||||
|
||||
**Do not** add `--no-frozen-lockfile` to a script, a Dockerfile or CI to make an error go away. The
|
||||
error means the lockfile and `package.json` disagree, and that is worth a human look every time.
|
||||
|
||||
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
|
||||
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
|
||||
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
||||
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Paradigm**: functional — pure functions, immutability, composition
|
||||
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
||||
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
|
||||
- **Async**: always async/await
|
||||
- **Exports**: named only, no defaults
|
||||
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
||||
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
|
||||
|
||||
### Imports
|
||||
|
||||
Order: types → external → workspace → relative.
|
||||
|
||||
```ts
|
||||
import type { User } from 'types';
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { formatDate } from 'helpers/formatters';
|
||||
import { useWebsites } from '../useWebsites';
|
||||
```
|
||||
|
||||
In app code import types from `'types'`, which re-exports the database types. Only server and
|
||||
database code imports from `officerdb/types` directly.
|
||||
|
||||
### Functions
|
||||
|
||||
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
|
||||
parameter lists — extract a params type instead.
|
||||
|
||||
```ts
|
||||
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
|
||||
export function processData({ body, query }: ProcessDataParams): Result { ... }
|
||||
```
|
||||
|
||||
### Components and hooks
|
||||
|
||||
```tsx
|
||||
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
|
||||
|
||||
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
|
||||
```
|
||||
|
||||
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
|
||||
always named `ev`, never `e`.
|
||||
|
||||
### API calls
|
||||
|
||||
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
|
||||
type parameter and let inference flow from it.
|
||||
|
||||
## Working With Me
|
||||
|
||||
- **Ask first** — confirm the approach before a significant change
|
||||
- **Explore thoroughly** — read the related files before editing
|
||||
- **Keep it simple** — no over-engineering, no premature abstraction
|
||||
- **Be explicit** — no magic, no implicit behaviour
|
||||
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
||||
- **Report honestly** — say what you verified and what you didn't
|
||||
|
||||
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
|
||||
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
|
||||
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
|
||||
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
|
||||
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
|
||||
withholding the commit until it is good.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes.
|
||||
|
||||
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
|
||||
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
|
||||
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
|
||||
the two-step form only looks like it does.
|
||||
|
||||
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
|
||||
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
|
||||
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||
```
|
||||
|
||||
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||
restart in a single night. The transcript id survived both.
|
||||
|
||||
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||
whose "why" is still answerable in six months.
|
||||
|
||||
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||
add the trailer and one that does not should leave it out rather than guess.
|
||||
|
||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||
|
||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||
|
||||
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
|
||||
`<WorkspaceView workspace={ws} locked />` where
|
||||
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
|
||||
that pins `appType`s to an allow-list. Panels are windowed apps under
|
||||
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
|
||||
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
|
||||
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
|
||||
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
|
||||
- **Page title by route.** Automatic — no rule to add. `titleForPath` in
|
||||
`src/apps/officer-web/state/usePageTitle.ts` takes the URL's first path segment, turns `kebab-case`
|
||||
into `Title Case`, and that's the tab title; `usePageTitleSync` does the rest. A screen that wants to
|
||||
say more than its route does (a chat's own name, not just "Chat") publishes an override instead —
|
||||
`usePublishPageTitle` in `officerdev`.
|
||||
|
||||
### The URL is the source of truth for selection
|
||||
|
||||
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
|
||||
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
|
||||
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
|
||||
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
|
||||
link-focusable). Half the app still does this; none of the new code should.
|
||||
|
||||
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
|
||||
`usePanelChannel` is for genuine signals and refresh buses (`files:refresh-signal`,
|
||||
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`) — and declare one with `defineChannel` in
|
||||
`officerdev/src/channels.ts` rather than spelling the name and type at each site.
|
||||
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
|
||||
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
|
||||
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
||||
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
||||
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
||||
_mutates_ rather than navigates stays a `<button>`.
|
||||
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
||||
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/headscale` +
|
||||
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
||||
canonicalises both the bare route and a bogus param.
|
||||
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
|
||||
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
|
||||
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
|
||||
Action buttons are **siblings** of the anchor, never nested inside it.
|
||||
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
|
||||
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
|
||||
when you want `isActive`.
|
||||
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
|
||||
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
||||
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
||||
|
||||
## COMMS — a channel between agents, when one is open
|
||||
|
||||
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
|
||||
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
|
||||
sessions.
|
||||
|
||||
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
|
||||
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
|
||||
convention rather than an oversight: a spent channel left in place gets read as current.
|
||||
|
||||
If you open one:
|
||||
|
||||
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
|
||||
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
|
||||
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
|
||||
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
|
||||
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
|
||||
agent read identically.
|
||||
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
|
||||
participant. "I think we're done" can close a thread with work still in it.
|
||||
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
|
||||
lands, delete the channel and move anything still open to `TODO.md`.
|
||||
|
||||
Two things that made it work, and neither is about either agent being more careful. One writes, the other
|
||||
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
|
||||
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
|
||||
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
|
||||
explaining why it was safe.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
||||
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
||||
- `docs/email-recall.md` — **design, not built**: recall over your own correspondence. The split that
|
||||
matters is counting (needs every message judged) versus retrieval (needs the best few, fast) — and why
|
||||
a number produced by searching is confidently wrong with no signal that it is
|
||||
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
|
||||
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
|
||||
question to settle first, which is retention rather than anything technical
|
||||
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
|
||||
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
|
||||
agent. Read before writing one
|
||||
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
||||
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
||||
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
|
||||
it is what `docs/workspace-panel-todo.md` is ranked against.
|
||||
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
||||
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/multi-server-push.md` — **design, not built**: one Officer app receiving notifications from every
|
||||
server it is signed into. Works already, bar one missing field — and why the `mv` catalogue-version
|
||||
check turns into an endless resync between servers without it. The server identifier is an SSO decision
|
||||
- `docs/visual-regression-plan.md` — **plan, not built**: nightly screenshot regression against a JWT
|
||||
dashboard on a second server. The blocker is that a JWT lives in `localStorage` and iris copies only
|
||||
cookies, so an authenticated capture silently renders the login page — plus why diffing must happen
|
||||
before any agent is asked to look
|
||||
- `docs/plugin-dependency-scope.md` — a plugin declares WHO must be able to run each host binary. Why the
|
||||
owner's login shell is the wrong question for a `confined` plugin, the two-part reachability check
|
||||
(world-executable AND every parent traversable), and why members' binaries on alpha worked only because
|
||||
one home directory happened to be mode 751
|
||||
- `docs/plugin-route-authorization.md` — **design, not built**: making a plugin's settings routes
|
||||
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
|
||||
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
|
||||
remember, and the credential-resolution work that has to land first
|
||||
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
|
||||
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
|
||||
because otherwise "add a role" and "become the owner" are the same feature
|
||||
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
|
||||
web UI to more than one language. What exists and what it cannot do yet, why the server should return
|
||||
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
|
||||
naming convention, and the four phases. Read before adding a user-facing string
|
||||
- `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
|
||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||
anything it already rules out
|
||||
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/claude-md-touchpoints.md` — **audit**: every place the platform writes, appends to or reads a
|
||||
`CLAUDE.md` — three writers total (commander creation, the plugin chat-brief copier, and a one-off
|
||||
credential-backfill migration) plus one read-only doc-drift check, and three places that deliberately
|
||||
do NOT write one. Read before changing any part of the mechanism; includes a per-plugin table (only
|
||||
`proxy-manager` ships a chat brief today)
|
||||
- `docs/plugin-capabilities.md` — **built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
a namespace makes provenance readable, separate directories make it structural
|
||||
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
|
||||
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
|
||||
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
|
||||
get wrong — a team folder that is not `setgid` charges nothing to the team
|
||||
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
||||
- `src/apps/CLAUDE.md` — shared frontend patterns
|
||||
- `src/databases/CLAUDE.md` — database patterns
|
||||
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
|
||||
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
|
||||
|
||||
Treat the code as the source of truth where anything here disagrees with it.
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
@@ -22,43 +22,44 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**100 open, 31 closed**, across 33 themes.
|
||||
**107 open, 32 closed**, across 34 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 4 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 1 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 5 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 4 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [SendOff](todo/sendoff/) | 1 | 0 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 5 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Calendar and contacts](todo/calendar/) | 1 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
| [Code editor](todo/code-editor/) | 1 | 0 |
|
||||
| [Components — the shared UI layer](todo/components/) | 1 | 0 |
|
||||
| [Documentation conventions — AGENTS.md vs CLAUDE.md](todo/docs-conventions/) | 2 | 0 |
|
||||
| [File watchers](todo/file-watchers/) | 1 | 0 |
|
||||
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 6 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 5 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
| [Prompt library](todo/prompt-library/) | 1 | 0 |
|
||||
| [SendOff](todo/sendoff/) | 1 | 0 |
|
||||
| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 |
|
||||
| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 |
|
||||
| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 |
|
||||
| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 |
|
||||
| [Tooling — the list about the list](todo/tooling/) | 0 | 1 |
|
||||
| [Transmission](todo/transmission/) | 6 | 0 |
|
||||
| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 |
|
||||
| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 |
|
||||
| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 |
|
||||
|
||||
## Records — no items, kept for the reasoning
|
||||
|
||||
@@ -69,13 +70,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
|
||||
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
|
||||
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
|
||||
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
|
||||
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
|
||||
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
|
||||
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
|
||||
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
|
||||
- `2026-08-25` **Sweep: AGENTS.md becomes the real instructions everywhere, CLAUDE.md a pointer beside it** — [docs-conventions/sweep-agents-md-primary-claude-md-pointer](todo/docs-conventions/sweep-agents-md-primary-claude-md-pointer.md)
|
||||
- `2026-08-25` **Refresh mid-turn folds the live tool call — it reads as finished while still running** — [known-bugs/refresh-mid-tool-call-renders-the-turn-folded](todo/known-bugs/refresh-mid-tool-call-renders-the-turn-folded.md)
|
||||
- `2026-08-25` **The model picker shows `claude-code` instead of the model that is running** — [known-bugs/the-model-picker-shows-the-bare-harness](todo/known-bugs/the-model-picker-shows-the-bare-harness.md)
|
||||
- `2026-08-25` **Understand how Claude and opencode do memory, then centralize both into one vector database** — [memory/understand-and-centralize-agent-memory-into-a](todo/memory/understand-and-centralize-agent-memory-into-a.md)
|
||||
- `2026-08-25` **Deep-link a todo notification straight to that item's markdown** — [mobile-apps/deep-link-a-todo-notification-to-its-file](todo/mobile-apps/deep-link-a-todo-notification-to-its-file.md)
|
||||
- `2026-08-25` **Add a favorites tab/panel to the file browser** — [multi-user/add-a-favorites-tab-panel-to-the-file-browser](todo/multi-user/add-a-favorites-tab-panel-to-the-file-browser.md)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# 2026.08.26.01 — report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`);
|
||||
neither is a failure. Every target ended at `e9f1760f` = `deploy/2026.08.26.01` exactly.
|
||||
|
||||
**No schema change in this release**, and that was verified rather than assumed — the diff between
|
||||
`deploy/2026.08.25.01` and this tag touches no `schema.ts` and leaves `bun.lock` untouched. **No
|
||||
`bun install` was run anywhere.** **No direct SQL was run on any host**; none was authorised.
|
||||
|
||||
First release under a new watcher, and the first the loop has ever executed. The claim was taken by the
|
||||
watcher before this agent was spawned; this agent recorded `done` after the rollout.
|
||||
|
||||
## One finding the owner should see: `db:push` is not a no-op on any host
|
||||
|
||||
`bun db:push` exited 0 and printed `Changes applied` on all five. Per doctrine that is not evidence, so
|
||||
each host was re-run with `--verbose` to see what push actually wanted. **Every host wants the same
|
||||
statement, every single run:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
|
||||
```
|
||||
|
||||
`alpha` additionally wants a drop-and-re-add of `pk_music_now_playing` (the music plugin's composite
|
||||
primary key).
|
||||
|
||||
**This release did not cause it and it is not damage.** The reasoning is deductive rather than a guess:
|
||||
this release changes zero `schema.ts` files, so drizzle's desired state is byte-identical before and
|
||||
after the merge — anything push wants is pre-existing drift. It was verified by query that the database
|
||||
is already in the state the schema code asks for:
|
||||
|
||||
| Host | `command_hq_sessions.reference_paths` default | `pk_music_now_playing` | `roles` |
|
||||
| ---------------------------- | --------------------------------------------- | ---------------------- | ------- |
|
||||
| `alpha` | `'{}'::text[]` — correct | present (`contype=p`) | 4 |
|
||||
| `fofos` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `production-pertento-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `deadpool-rezio-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `macbookpro` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
|
||||
So drizzle cannot match `'{}'` against the stored `'{}'::text[]` and re-emits the ALTER forever. It is
|
||||
the array-default cousin of the named-composite-constraint mis-diff already documented in
|
||||
`src/databases/CLAUDE.md` → "Composite keys", and `pk_music_now_playing` on `alpha` is that exact
|
||||
documented case.
|
||||
|
||||
**Why it matters even though it is harmless today:** it permanently spends the signal. The plan said
|
||||
"expect no changes to platform tables — if it wants to alter one, stop and report it", and that stop
|
||||
condition can never fire cleanly while a platform table is on the standing-diff list. A future release
|
||||
that really does want an unexpected ALTER will look exactly like tonight. Worth fixing at the schema
|
||||
code, not on the hosts.
|
||||
|
||||
The rollout was not stopped for it. Non-destructive, pre-existing, and the databases are in the state
|
||||
the code asks for.
|
||||
|
||||
`postdb:push` seeders ran clean everywhere: `seed:roles` 0 seeded / 4 already present on all five,
|
||||
`backfill:session-models` 0 seeded on all five.
|
||||
|
||||
## The reason for this release — verified on `fofos`
|
||||
|
||||
The plan exists because `proxy-manager` imports `ChatHeader`, which did not exist before `2ed47fa6`, so
|
||||
the plugin's frontend rebuild failed and the SPA silently kept its old bundle.
|
||||
|
||||
Verified after the restart, from the logs and the build output rather than by eye:
|
||||
|
||||
- `[plugins] frontend built in 7693ms (3 outputs)` at out-log line **1777 of 1808** — the current boot.
|
||||
- The `No matching export … for import "ChatHeader"` line is in the error log **above** the last
|
||||
`gen-index` / `server.tsx` boot markers, i.e. it belongs to the **previous** boot. No `rebuild failed`
|
||||
after the successful build.
|
||||
- `build/` was rewritten at 04:46:04, seconds after the restart, and `chunk-b7epbzfm.js` contains
|
||||
`proxy-manager` 41 times.
|
||||
|
||||
`ChatHeader` itself greps to 0 in the bundle — expected, production bundling renames it. The absence of
|
||||
the build error is the signal, not the presence of the symbol.
|
||||
|
||||
**The owner still needs a hard refresh (Ctrl/Cmd-Shift-R) before judging `/proxy-manager` in a browser**
|
||||
that is holding the old bundle. Not checked from a browser by this agent.
|
||||
|
||||
## Per host
|
||||
|
||||
Steps in plan order everywhere: ff-merge to the tag → `bun db:push` → `bun run plugins:update` →
|
||||
`pm2 restart officer`.
|
||||
|
||||
### `alpha` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Tree clean, no untracked files.
|
||||
- `db:push` exit 0. Standing diff as above; database verified correct by query.
|
||||
- `plugins:update`: **14/14 updated in 59s**, exit 0. This bounced fourteen sidecars on its own, before
|
||||
any restart the plan asked for — the report line below understates what actually moved.
|
||||
- `pm2 restart officer`: restart_time 3 → 4, `online`, uptime 14s at check.
|
||||
- All **21** PM2 entries `online`. `officer-claude-code` untouched (uptime 28h).
|
||||
|
||||
### `fofos` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Tracked-clean. **One untracked file: `scripts/chk.ts`** — noted, not a
|
||||
reason to skip, and not touched.
|
||||
- `db:push` exit 0; `proxy_manager_connection`, `proxy_manager_dns_credentials`, `proxy_manager_domains`
|
||||
all present, as the plan predicted for a proxy-manager host.
|
||||
- `plugins:update`: 3/3 updated in 28s, exit 0 (browser, gitea, proxy-manager).
|
||||
- `pm2 restart officer`: restart_time 29 → 30, `online`, uptime 26s at check.
|
||||
- All **11** PM2 entries `online`. Frontend rebuild verified — see the section above.
|
||||
- Note: this host's `.env` names the database `OFFICERDEV_POSTGRES_URL`, not `POSTGRES_URL` as on the
|
||||
others. Cost one failed probe. Worth knowing for the next orchestrator that reads `.env` directly.
|
||||
|
||||
### `production-pertento-bridge` — success
|
||||
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query. 39 tables in `public`.
|
||||
- `plugins:update`: 1/1 updated in 4s, exit 0 (gitea).
|
||||
- `pm2 restart officer`: restart_time 8 → 9, `online`, uptime 22s at check.
|
||||
- All **15** PM2 entries `online`. `officer-claude-code` untouched (uptime 76h).
|
||||
- Every command on this host prints an nvm/`.npmrc` `globalconfig` warning. Pre-existing, cosmetic,
|
||||
nothing failed because of it — but it is noise an orchestrator has to filter, and it will eventually
|
||||
hide something.
|
||||
|
||||
### `deadpool-rezio-bridge` — success
|
||||
|
||||
- Login `br`, checkout `/home/br/officerdev/platform`, taken from PM2's `pm_cwd`.
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query.
|
||||
- `plugins:update`: 2/2 updated in 8s, exit 0 (email, offscale).
|
||||
- `pm2 restart officer`: restart_time 14 → 15, `online`, uptime 22s at check.
|
||||
- All **9** PM2 entries `online`.
|
||||
|
||||
### `macbookpro` — success (it was awake)
|
||||
|
||||
- Checkout `/Users/pastilhas/projects/officer-suite/platform`. `PATH` **appended** with
|
||||
`/opt/homebrew/bin` for the `bun` commands; `pm2` run as a separate command, per `docs/servers.md`.
|
||||
- HEAD `007cc9a1` → `e9f1760f`. Clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff only; verified correct by query.
|
||||
- `plugins:update`: **no plugins installed — nothing to update**, exit 0.
|
||||
- `pm2 restart officer`: restart_time 16 → 17, `online`, uptime 27s at check.
|
||||
- All **6** PM2 entries `online`.
|
||||
|
||||
## Skipped
|
||||
|
||||
### `officer-dev` — skipped, by plan and by doctrine
|
||||
|
||||
Not pulled, nothing restarted. This agent runs inside `officer-claude-code` on this machine; restarting
|
||||
`officer` here ends the rollout. It already carries this code — its HEAD was `e9f1760f` before the
|
||||
rollout began.
|
||||
|
||||
**Its working tree holds another session's uncommitted work** — modified `src/servers/hono.ts` and
|
||||
`src/workspaces/hooks/src/useClient.ts`, plus an untracked `src/servers/hono-error-shape.test.ts`. Left
|
||||
alone. The report commit below names only its own path.
|
||||
|
||||
The owner restarts this host deliberately, as the last step, once happy with the rest.
|
||||
|
||||
### `edge-pertento-bridge` — skipped, by plan. Not contacted.
|
||||
|
||||
**And `docs/servers.md` disagrees with the plan about this host.** The doc's "Deliberately skipped"
|
||||
section says "None today" and lists `edge-pertento-bridge` in the platforms table as "a deploy target
|
||||
again". The plan says skip it: 300+ commits behind, **no `roles` table**, and a plain `db:push` there
|
||||
would create a foreign key onto an empty table. Its catch-up is a by-hand job the owner is doing
|
||||
separately.
|
||||
|
||||
The plan won — it is authoritative for a release, and it is the more recent statement. Nothing was sent
|
||||
to that host, not even a reachability probe. **`docs/servers.md` should be corrected**, because the next
|
||||
orchestrator that reads the doc rather than the plan will `db:push` a host that cannot take one. Not
|
||||
fixed here: a rollout does not edit documentation.
|
||||
|
||||
## What was NOT done
|
||||
|
||||
- **`officer-claude-code` was not restarted on any host**, per the plan. The widened chat credential
|
||||
(an API key follows its holder, minus `admin`) lives in that sidecar, so **it is not active anywhere
|
||||
yet**. It is the owner's call when to take that — it ends every live agent session on the host,
|
||||
including this one. **Pending on all five targets plus `officer-dev`.**
|
||||
- **`officer-dev` was not deployed.** Skipped whole, per above.
|
||||
- **The sonnet default was not confirmed from a UI on any host.** The plan names it as the easiest
|
||||
visible check; this agent has no browser. It shipped in the code that is now at `e9f1760f` on all
|
||||
five, unverified at the surface.
|
||||
- **`/proxy-manager` was not opened in a browser.** Verified from the build and the logs instead.
|
||||
- `bun run docs:check` was not run — not a rollout step, and this agent does not fix documentation.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 2026.08.26.02
|
||||
|
||||
**One fix, and it is a crash.** A screen whose layout names a panel it does not own now renders instead of
|
||||
failing with React #185, "maximum update depth exceeded".
|
||||
|
||||
**No schema change. No dependency change. Do not run `bun install`.** `bun db:push` stays a step because it
|
||||
is cheap and proves the database agrees with the code, but it must report no changes.
|
||||
|
||||
## What broke, and where it shows
|
||||
|
||||
`useAuthoredLayout` shipped in `2026.08.26.01`: a locked screen persists its SIZES and takes its STRUCTURE
|
||||
from source on every render. That is still right, and it is what lets a plugin change its layout and reach
|
||||
people who have already opened it.
|
||||
|
||||
What it did not account for is `WorkspaceView`'s `appTypes` guard, which repairs an unrecognised `appType`
|
||||
and **writes the repair back**. That was self-limiting while structure was persisted — the repair stuck and
|
||||
the next render agreed with it. With structure coming from source it can never stick, so it is recomputed,
|
||||
rewritten, discarded and recomputed every render until React gives up.
|
||||
|
||||
**The `browser` plugin's layout names `officerdev/chat`**, which is not one of its own panel keys. So
|
||||
`/browser` fails to render on any host with that plugin installed. That is every host running
|
||||
`2026.08.26.01` with it — which is the fleet.
|
||||
|
||||
The fix drops a write whose result equals the current value. Not an optimisation: it is what terminates the
|
||||
loop, and persisting an identical tree was pointless anyway.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.26.02`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
**`fofos` is already at the fix** — it was fast-forwarded by hand at 05:22 to `a7c9dab2` while the owner was
|
||||
working on it. Step 1 will fast-forward it to this tag anyway, which only adds the version bump and this
|
||||
plan. Nothing about that is a problem; note it rather than treating it as drift.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. Asleep is
|
||||
a skip with a note.
|
||||
|
||||
**Do not restart `officer-claude-code`.** Unchanged from the last release, same reason: it ends every live
|
||||
agent session on the host, and it is the owner's call. Nothing in this release needs it.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`officer-dev`** — skip it. Restarting `officer` there ends the rollout. It already has this code.
|
||||
- **`edge-pertento-bridge`** — skipped. Still 300+ commits behind with no `roles` table; its catch-up is a
|
||||
by-hand job. Do not contact it.
|
||||
- **`alpha`**, **`fofos`**, **`production-pertento-bridge`**, **`deadpool-rezio-bridge`** — the targets.
|
||||
- **`macbookpro`** — a target if it is awake.
|
||||
|
||||
## What to check
|
||||
|
||||
**Open `/browser`.** It should render rather than showing "This screen failed to render". That is the whole
|
||||
release, and it is one click to confirm. Hard refresh first — a stale bundle looks identical to the bug.
|
||||
|
||||
No direct SQL is needed anywhere, and none is authorised.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Deploy report — 2026.08.26.02
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by plan (`officer-dev`, `edge-pertento-bridge`).
|
||||
|
||||
Tag `deploy/2026.08.26.02` → `121a77ce`. `package.json` at that commit reads `2026.08.26.02` — tag and
|
||||
version agree. Every host ended at `121a77ce` exactly, merged by `--ff-only` against the tag rather than
|
||||
the branch.
|
||||
|
||||
Second release under this watcher, and the second the loop has executed. The claim was taken by the
|
||||
watcher before this agent was spawned; this agent recorded `done` after the rollout.
|
||||
|
||||
## The release changed no schema, and that makes the `db:push` reading solid
|
||||
|
||||
This release touches five files: two deploy documents, `package.json`, and
|
||||
`src/workspaces/state/useAuthoredLayout.ts` with its test. **No `schema.ts` changed**, so drizzle's desired
|
||||
state is byte-identical before and after the merge. Anything `push` wants is pre-existing drift, deductively
|
||||
rather than by assumption.
|
||||
|
||||
Per doctrine, exit 0 was not taken as evidence. Every host was run with `--verbose` to read the statements
|
||||
push actually wanted, and then queried directly.
|
||||
|
||||
**The standing diff from `2026.08.26.01` is unchanged and is present on all five:**
|
||||
|
||||
```sql
|
||||
ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}';
|
||||
```
|
||||
|
||||
`alpha` additionally wants the documented drop-and-re-add of `pk_music_now_playing`. Nothing new appeared on
|
||||
any host — the set of wanted statements is identical to last night's, host for host.
|
||||
|
||||
Verified by query, per host, not inferred:
|
||||
|
||||
| Host | `command_hq_sessions.reference_paths` | `pk_music_now_playing` | `roles` |
|
||||
| ---------------------------- | ------------------------------------- | ---------------------- | ------- |
|
||||
| `alpha` | `'{}'::text[]` — correct | present (`contype=p`) | 4 |
|
||||
| `fofos` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `production-pertento-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `deadpool-rezio-bridge` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
| `macbookpro` | `'{}'::text[]` — correct | n/a | 4 |
|
||||
|
||||
Each database is already in the state the schema code asks for. The rollout was not stopped for it: the
|
||||
plan's stop condition is "if it wants to alter something", and this is drizzle re-emitting a statement
|
||||
whose effect is already in place. **That stop signal is still spent**, exactly as last night's report said —
|
||||
worth fixing at the schema code, and it is the one thing here the owner should act on.
|
||||
|
||||
No direct SQL was run anywhere. None was authorised and none was needed.
|
||||
|
||||
## Per host
|
||||
|
||||
Steps in plan order on every host: ff-merge to the tag → `bun db:push` → `bun run plugins:update` →
|
||||
`pm2 restart officer`. No `bun install` anywhere — the plan forbids it and no dependency changed.
|
||||
`officer-claude-code` was not restarted anywhere, per the plan.
|
||||
|
||||
### `alpha` — OK
|
||||
|
||||
- `e9f1760f` → `121a77ce`. Tree clean, no untracked files.
|
||||
- `db:push` exit 0; standing diff plus `pk_music_now_playing`; database verified correct by query.
|
||||
- `plugins:update` 14/14 in 55s. That step bounces roughly a dozen sidecars itself (`sidecar: started` per
|
||||
plugin) — more moved on this host than the `officer` line below suggests.
|
||||
- `officer` restarted: `online`, `restart_time` 4 → 5.
|
||||
|
||||
### `fofos` — OK
|
||||
|
||||
- `a7c9dab2` → `121a77ce`, the short fast-forward the plan predicted: it adds only the version bump and the
|
||||
plan file, since the fix itself was hand-applied here at 05:22. Not drift.
|
||||
- One untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward cannot touch it, and
|
||||
nobody's work was discarded.
|
||||
- `db:push` exit 0; standing diff only; verified by query. 43 tables in `public`.
|
||||
- `plugins:update` 3/3 in 21s (`browser`, `gitea`, `proxy-manager`).
|
||||
- `officer` restarted: `online`, `restart_time` 31 → 32.
|
||||
- **This is a host the release actually matters on** — it has the `browser` plugin whose layout names
|
||||
`officerdev/chat`.
|
||||
|
||||
### `production-pertento-bridge` — OK
|
||||
|
||||
- `e9f1760f` → `121a77ce`. Tree clean.
|
||||
- `db:push` exit 0; standing diff only; verified by query.
|
||||
- `plugins:update` 1/1 in 4s.
|
||||
- `officer` restarted: `online`, `restart_time` 9 → 10.
|
||||
|
||||
### `deadpool-rezio-bridge` — OK
|
||||
|
||||
- Logged in as `br`, per `docs/servers.md`.
|
||||
- `e9f1760f` → `121a77ce`. Tree clean.
|
||||
- `db:push` exit 0; standing diff only; verified by query.
|
||||
- `plugins:update` 2/2 in 7s.
|
||||
- `officer` restarted: `online`, `restart_time` 15 → 16.
|
||||
|
||||
### `macbookpro` — OK, and it was awake
|
||||
|
||||
- `e9f1760f` → `121a77ce`. Tree clean.
|
||||
- `PATH` appended with `/opt/homebrew/bin` for the `bun` steps; `pm2` run as a separate command on the
|
||||
interactive shell, so nothing was shadowed.
|
||||
- `db:push` exit 0; standing diff only; verified by query.
|
||||
- `plugins:update`: `no plugins installed — nothing to update`. Not a failure.
|
||||
- `officer` restarted: `online`, `restart_time` 17 → 18.
|
||||
|
||||
## Skipped, and why
|
||||
|
||||
- **`officer-dev`** — the host this agent runs on. Not pulled, nothing restarted. It already holds this
|
||||
code at `121a77ce`. Its working tree carries another session's uncommitted changes
|
||||
(`src/servers/hono.ts`, `src/workspaces/hooks/src/useClient.ts`, an untracked
|
||||
`src/servers/hono-error-shape.test.ts`) — untouched. **The owner restarts `officer` here deliberately,
|
||||
as the last step.**
|
||||
- **`edge-pertento-bridge`** — skipped on the plan's instruction: 300+ commits behind with no `roles`
|
||||
table, its catch-up is a by-hand job, do not contact it. It was not contacted.
|
||||
|
||||
Note the disagreement, because an orchestrator reading the wrong document gets this wrong: `docs/servers.md`
|
||||
lists this host in the platforms table as a deploy target again, with an empty "Deliberately skipped"
|
||||
section. The **plan** overrides it, and the plan is right — a `db:push` on a host with no `roles` table is
|
||||
the exact shape of change this doctrine refuses. `docs/servers.md` is stale on this point.
|
||||
|
||||
## What was not verified
|
||||
|
||||
**The fix itself was not clicked.** The plan's check is "open `/browser`, hard refresh, confirm it renders
|
||||
rather than showing 'This screen failed to render'". That is a browser action on a logged-in session, and
|
||||
this agent has no session on any of these hosts. What is verified is that the code carrying the fix is at
|
||||
`HEAD` on every target and that `officer` came back `online` on each. **The one-click confirmation is
|
||||
still owed, on a host with the `browser` plugin — `fofos` or `alpha`.**
|
||||
@@ -0,0 +1,37 @@
|
||||
# deploy/2026.08.27.01
|
||||
|
||||
55 commits since `deploy/2026.08.26.02`. A full day's work, most of it in two areas.
|
||||
|
||||
## What is in it
|
||||
|
||||
**Calendar and contacts became usable rather than readable.** Create, edit and delete on both screens; a
|
||||
filter on each; `?collection=<name>` instead of an escaped DAV path. The agent skill was rewritten off
|
||||
hand-written iCalendar onto JSON routes — an editor now patches the stored text rather than rebuilding it,
|
||||
so alarms, attendees and recurrence rules survive an edit made from a form that cannot show them.
|
||||
|
||||
**OpenCode can reach the platform.** It gets the skills index, an Officer identity instead of "you are a
|
||||
coding agent", an API credential, and its permission prompts answered — a server session has nobody to
|
||||
answer them and a turn simply hung. Its chats also keep their name, their transcript and their model when
|
||||
reopened.
|
||||
|
||||
**Smaller, and each one somebody hit today:** `/api/notify/user` (and `/broadcast`, which had answered 404
|
||||
since it landed — shadowed by the sidecar proxy's catch-all); a commander started from a plugin chat, so
|
||||
pointing one at a mailbox no longer needs a path only the owner can type; file preview inside a
|
||||
commander's own browser; a dock tile for `/bots`; and the new wordmark in the header, following the theme.
|
||||
|
||||
## Risk
|
||||
|
||||
Low-to-moderate. Nothing here changes a schema. The two worth watching after the rollout:
|
||||
|
||||
- **`useThemeAsset`** now reads in `requestAnimationFrame` rather than during the effect. It fixes the
|
||||
logo and the wallpaper being one theme behind; if a themed asset ever fails to appear, this is the first
|
||||
place to look.
|
||||
- **The notify router's mount order.** The platform's own routes now sit before the sidecar proxy's
|
||||
catch-all. Device registration still forwards — verified — but it is the change most likely to surprise.
|
||||
|
||||
## Hosts
|
||||
|
||||
Seven. `officer-dev` is skipped: it is the host running the rollout, and restarting it ends the rollout.
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
@@ -0,0 +1,78 @@
|
||||
# 2026.08.27.02
|
||||
|
||||
Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no
|
||||
commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays
|
||||
claimed and undeployed; do not try to revive it.
|
||||
|
||||
Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab.
|
||||
|
||||
## The schema question, answered per host
|
||||
|
||||
`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`.
|
||||
`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has
|
||||
`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it.
|
||||
|
||||
Surveyed 2026-08-28, every host:
|
||||
|
||||
| Host | `plugins/caldav/` | `dav_app_passwords` |
|
||||
| --- | --- | --- |
|
||||
| `alpha` | present | **8 rows** |
|
||||
| `fofos` | absent | present, 0 rows |
|
||||
| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) |
|
||||
| `macbruno` | absent | present, 0 rows |
|
||||
| `production-pertento-bridge` | absent | present, 0 rows |
|
||||
| `edge-pertento-bridge` | absent | present, 0 rows |
|
||||
| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) |
|
||||
|
||||
So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core,
|
||||
and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect.
|
||||
|
||||
`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero
|
||||
of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02`
|
||||
2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it
|
||||
holds anything:
|
||||
```
|
||||
psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords"
|
||||
# 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords"
|
||||
# anything else → STOP. Report the count and do not continue on this host.
|
||||
```
|
||||
Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`.
|
||||
3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of
|
||||
hanging. Expect **no changes** after step 2. Anything destructive: stop and report.
|
||||
4. `bun run plugins:update`
|
||||
5. `pm2 restart officer`
|
||||
6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity
|
||||
and its credential, and all of that is built when the sidecar boots. Without this the code ships and
|
||||
nothing about OpenCode changes.
|
||||
|
||||
**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs
|
||||
normally and must report no changes.
|
||||
|
||||
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
|
||||
with a note.
|
||||
|
||||
**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once
|
||||
during the survey — if it refuses again, note it and move on rather than retrying in a loop.
|
||||
|
||||
**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs
|
||||
it, and it is the owner's call.
|
||||
|
||||
## Risk
|
||||
|
||||
Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a
|
||||
count and it is skipped on the one host where the table has contents.
|
||||
|
||||
After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so
|
||||
a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so
|
||||
device registration is worth one check (it forwards correctly on `officer-dev`).
|
||||
|
||||
## Hosts
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
|
||||
`officer-dev` is skipped: it is the host running the rollout.
|
||||
@@ -9,14 +9,14 @@ read-in-place rule this mechanism is the one deliberate exception to).
|
||||
## The shape of it
|
||||
|
||||
Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files
|
||||
that merely *mention* `CLAUDE.md` in a comment suggests.
|
||||
that merely _mention_ `CLAUDE.md` in a comment suggests.
|
||||
|
||||
| # | Site | Verb | Cardinality | Trigger |
|
||||
|---|------|------|-------------|---------|
|
||||
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
|
||||
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory | every plugin-panel chat start |
|
||||
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
|
||||
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
|
||||
| # | Site | Verb | Cardinality | Trigger |
|
||||
| --- | ------------------------------------------------------ | --------------------------- | -------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
|
||||
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory, plus one per email account | every plugin-panel chat start, and every email chat start |
|
||||
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
|
||||
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
|
||||
|
||||
Two things fell out of tracing this that are worth stating up front, because they explain why the
|
||||
mechanism looks the way it does rather than something simpler:
|
||||
@@ -39,7 +39,7 @@ mechanism looks the way it does rather than something simpler:
|
||||
|
||||
Called once, from `command-hq/router.ts`, when `POST /command-hq/commanders` creates a commander — the
|
||||
row, the directory, and this file, in that order, all before any turn is allowed to spawn. The ordering
|
||||
is load-bearing: a turn that spawned before the directory existed used to recreate an *empty* one on a
|
||||
is load-bearing: a turn that spawned before the directory existed used to recreate an _empty_ one on a
|
||||
race with deletion (`websocket.ts:300-308` describes the incident), so the row now has to exist first
|
||||
and the directory has to exist before a turn can reach it.
|
||||
|
||||
@@ -68,7 +68,7 @@ to be reading the file yet.
|
||||
**What happens after creation is the interesting part: nothing does, from the platform's side.** The
|
||||
file is described everywhere as "a living document its commander has been extending" — the commander
|
||||
itself edits it during its own life, the same file a human would open in the file browser. The platform
|
||||
never rewrites it. That is *why* item 3 below has to append rather than regenerate: a commander with
|
||||
never rewrites it. That is _why_ item 3 below has to append rather than regenerate: a commander with
|
||||
months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
|
||||
|
||||
### 2. Plugin chat briefs — the copier
|
||||
@@ -95,8 +95,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise<voi
|
||||
}
|
||||
```
|
||||
|
||||
Called from `resolveChatCwd()` (line 332-336) exactly when `msg.context === 'plugin'` — never for a
|
||||
commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync`
|
||||
Called from `resolveChatCwd()` from **two** branches, and the second one is the interesting case:
|
||||
|
||||
- `msg.context === 'plugin'` — the plugin's own chats directory. The ordinary path.
|
||||
- `msg.context === 'email'` — the **account's** directory, the one holding `emails.db`.
|
||||
|
||||
Never for a commander and never for an ordinary `/chat` session.
|
||||
|
||||
Email is the exception because an email chat does not run in the plugin's chats directory: it runs beside
|
||||
the database it exists to answer from, which is the entire point of that context. So the brief follows the
|
||||
chat rather than the chat moving to the brief, and `plugins/email/chat/CLAUDE.md` lands in
|
||||
`email_accounts/<address>/`. A second plugin wanting this would be the signal that "which directory does
|
||||
this plugin's chat run in" belongs in the manifest rather than in a branch here. That branch also does the `mkdirSync`
|
||||
for the chat directory itself, so the two writers (this one and the commander generator) can never
|
||||
target the same directory: a commander's cwd is `$DATA_PATH/<email>/commanders/<dirName>/`, a plugin
|
||||
chat's is `$DATA_PATH/<email>/plugins/<appName>/chats/`.
|
||||
@@ -105,7 +115,7 @@ The source is `<plugin-repo>/chat/CLAUDE.md` — a file the plugin author ships
|
||||
read fresh from `snapshotPlugins()` on every chat start rather than cached, so a `plugins:update` needs
|
||||
no republish step for the brief to change. Content-compared before writing (`writeAtomic` only fires on
|
||||
an actual diff) specifically so a chat directory's mtimes don't churn on every message — this is called
|
||||
once per chat *start*, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
|
||||
once per chat _start_, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
|
||||
|
||||
Best-effort throughout, on purpose: a plugin with no `chat/CLAUDE.md` (the ordinary case — see the
|
||||
per-plugin table below) is not an error, it is a chat that knows a bit less than one with a brief. A
|
||||
@@ -178,11 +188,11 @@ specifically to stop a future change from "fixing" this back in.
|
||||
what fixed it.
|
||||
- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs
|
||||
un-isolated, `HOME` pointed at their real home, so a generated container `CLAUDE.md` would land
|
||||
*inside* `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
|
||||
_inside_ `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
|
||||
reads. Writing one there would silently mix generated content into a document that is supposed to be
|
||||
entirely the owner's.
|
||||
- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that
|
||||
the *old* `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
|
||||
the _old_ `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
|
||||
`settings.json` describing a user's Docker container, as part of an architecture that no longer exists.
|
||||
What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing
|
||||
generated inside either.
|
||||
@@ -199,28 +209,28 @@ at the top of the plugin's own repository, picked up automatically the moment th
|
||||
starts a conversation. There is no manifest field for this and nothing to register — the platform simply
|
||||
checks whether the file exists at that fixed path within the plugin's directory on every chat start.
|
||||
|
||||
| Plugin | `chat/CLAUDE.md` | Notes |
|
||||
|---|---|---|
|
||||
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
|
||||
| `atelier` | no | |
|
||||
| `bitcoin-wallet` | no | |
|
||||
| `browser` | no | |
|
||||
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
|
||||
| `email` | no | |
|
||||
| `example` | no | |
|
||||
| `gitea` | no | Ships an *affordance* (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
|
||||
| `invoices` | no | |
|
||||
| `iris` | no | |
|
||||
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
|
||||
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
|
||||
| `minesweeper` | no | |
|
||||
| `music` | no | |
|
||||
| `offscale` | no | |
|
||||
| `photos` | no | |
|
||||
| `soulseek` | no | |
|
||||
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
|
||||
| `transmission` | no | |
|
||||
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
|
||||
| Plugin | `chat/CLAUDE.md` | Notes |
|
||||
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
|
||||
| `atelier` | no | |
|
||||
| `bitcoin-wallet` | no | |
|
||||
| `browser` | no | |
|
||||
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
|
||||
| `email` | no | |
|
||||
| `example` | no | |
|
||||
| `gitea` | no | Ships an _affordance_ (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
|
||||
| `invoices` | no | |
|
||||
| `iris` | no | |
|
||||
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
|
||||
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
|
||||
| `minesweeper` | no | |
|
||||
| `music` | no | |
|
||||
| `offscale` | no | |
|
||||
| `photos` | no | |
|
||||
| `soulseek` | no | |
|
||||
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
|
||||
| `transmission` | no | |
|
||||
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
|
||||
|
||||
**No plugin writes, generates, or appends to a `CLAUDE.md` programmatically.** Every plugin-side hit that
|
||||
mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code
|
||||
|
||||
+39
-10
@@ -21,21 +21,50 @@ ssh br@deadpool-rezio-bridge # not implied
|
||||
|
||||
## Officer platforms
|
||||
|
||||
Seven, all running.
|
||||
Eight, all running. **Verified 2026-08-27** — every row below was read off the machine, not recalled.
|
||||
|
||||
| Host | Tailnet IP | Login | Checkout | Domain |
|
||||
| ---------------------------- | ----------- | ----------- | ----------------------------------- | --------------------------------- |
|
||||
| `officer-dev` | 100.64.0.13 | `pastilhas` | `~/officer-dev/platform` | `platform.officer.dev` |
|
||||
| `alpha` | 100.64.0.1 | `pastilhas` | `~/officerdev/platform` | `officer.pastilhas.dev` |
|
||||
| `fofos` | 100.64.0.17 | `pastilhas` | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
| Host | Tailnet IP | Login | Port | Checkout | Domain |
|
||||
| ---------------------------- | ----------- | ----------------------------------------- | -------- | ----------------------------------- | --------------------------------- |
|
||||
| `officer-dev` | 100.64.0.13 | `pastilhas` | 9000 | `~/officer-dev/platform` | `platform.officer.dev` |
|
||||
| `alpha` | 100.64.0.1 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.pastilhas.dev` |
|
||||
| `fofos` | 100.64.0.17 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | **9010** | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||
| `macbruno` | 100.64.0.22 | **`brunoalcadapadezmonteirodaconceicao`** | 9000 | `~/officerdev/platform` | — (none yet) |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | 9000 | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
|
||||
**THE PORT IS NOT ALWAYS 9000.** Two hosts run on 9010 and it is in `OFFICERDEV_PORT` in each checkout's
|
||||
`.env`. This is not a footnote: on 2026-08-27 a health check against 9000 on `production-pertento-bridge`
|
||||
hit one of Pertento's own apps, which answered `403`, and that was read as Officer failing after a
|
||||
deploy. Take the port from the `.env`, never from habit.
|
||||
|
||||
**`production-pertento-bridge` also runs eight processes that are NOT Officer** — `Dashboard`, `Tracking`,
|
||||
`Uptime` and five `Crons *`. They are production-critical and belong to Pertento. Anything sweeping over
|
||||
`pm2 list` must filter to `officer` and `officer-*`, and a restart there must name its target.
|
||||
|
||||
Only `platform.officer.dev` is public (77.42.39.155). The rest resolve to tailnet addresses and are
|
||||
reachable from inside only.
|
||||
|
||||
## `pm2` is not on the PATH of a non-interactive shell
|
||||
|
||||
`ssh host 'pm2 jlist'` runs a NON-interactive, NON-login shell. zsh reads `~/.zshenv` for those and reads
|
||||
neither `~/.zshrc` nor `~/.zprofile` — so a pm2 installed by nvm or Homebrew is invisible to an
|
||||
orchestrator even though the daemon is running and healthy.
|
||||
|
||||
**This read as "pm2 is not installed" three times on 2026-08-27**, and twice sent someone diagnosing a
|
||||
machine with nothing wrong with it. `claude` on a fresh install has the same shape.
|
||||
|
||||
Fixed at the source on the two hosts where it bit — `production-pertento-bridge` (nvm) and `macbruno`
|
||||
(Homebrew at `/opt/homebrew`, which is on no default PATH on Apple Silicon) — by appending a
|
||||
marker-guarded block to `~/.zshenv`. Everything else already worked.
|
||||
|
||||
If a command comes back empty over SSH, look for the binary on disk before concluding it is absent:
|
||||
|
||||
```bash
|
||||
ssh host 'ls /opt/homebrew/bin/pm2 /usr/local/bin/pm2 ~/.nvm/versions/node/*/bin/pm2 2>/dev/null'
|
||||
```
|
||||
|
||||
## Deliberately skipped
|
||||
|
||||
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"host": "127.0.0.1",
|
||||
"port": 3333,
|
||||
"enable_request_logging": false,
|
||||
"last_updated": "2025-12-24T04:27:17.443375"
|
||||
}
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.26.01",
|
||||
"version": "2026.08.27.02",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -39,7 +39,8 @@
|
||||
"setup": "bash scripts/install.sh",
|
||||
"check:theme": "bun scripts/check-theme-tokens.ts",
|
||||
"docs:check": "bun run scripts/docs-check.ts",
|
||||
"skills:platform": "bun run scripts/gen-platform-skill.ts"
|
||||
"skills:platform": "bun run scripts/gen-platform-skill.ts",
|
||||
"plugins:check": "bun run scripts/plugins-check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Plugins
|
||||
|
||||
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
|
||||
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
|
||||
plugin author needs that are not procedure.
|
||||
|
||||
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
|
||||
appears by `git clone` when somebody installs one, and is gitignored here.
|
||||
|
||||
---
|
||||
|
||||
## Affordances
|
||||
|
||||
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
|
||||
the file format and how they reach the agent. Read it before writing one. What follows is only the part
|
||||
that is specific to shipping one from a plugin.
|
||||
|
||||
> An affordance is something the surface on the other end of the conversation offers the agent — declared
|
||||
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
|
||||
> client will honour.
|
||||
|
||||
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
|
||||
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
|
||||
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
|
||||
inside the file.
|
||||
|
||||
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
|
||||
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
|
||||
that it was a bug. See the two worked examples in `docs/affordances.md`.
|
||||
|
||||
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
|
||||
have nothing to do with your plugin.
|
||||
|
||||
### A plugin-contributed one, for reference## Notifications — the same shape, for push
|
||||
|
||||
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
|
||||
|
||||
```
|
||||
capabilities/notifications/
|
||||
officerdev/music/
|
||||
catalogue.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
nowPlaying:
|
||||
title: 'Now playing'
|
||||
route: 'officer://music/{id}'
|
||||
```
|
||||
|
||||
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
|
||||
and the device renders the title and follows the route. The point is that **adding a notification is a
|
||||
server change and never an app build**, because the mapping is synced to the device rather than compiled
|
||||
into it.
|
||||
|
||||
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
|
||||
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
|
||||
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
|
||||
|
||||
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
|
||||
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
|
||||
catalogue is skipped without affecting anyone else's.
|
||||
|
||||
See `docs/push-notifications.md`.
|
||||
|
||||
### A plugin-contributed one, for reference
|
||||
|
||||
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
|
||||
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Gitea
|
||||
description: Use when working with git repositories, or with Gitea issues and pull requests.
|
||||
version: 1
|
||||
---
|
||||
|
||||
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
|
||||
to the server directly.
|
||||
|
||||
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
|
||||
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
|
||||
configure it, and do not ask the user for a token.
|
||||
```
|
||||
|
||||
### On the word
|
||||
|
||||
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
|
||||
`capabilities/skills/` here and by the wider AI ecosystem.
|
||||
|
||||
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
|
||||
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
|
||||
other end permits. Keeping that distinction in the name saves explaining it every time.
|
||||
+3
-89
@@ -1,92 +1,6 @@
|
||||
# Plugins
|
||||
|
||||
`EXTRACTING-A-PLUGIN.md` is the runbook for turning a platform feature into a plugin, and
|
||||
`EXTRACT-A-PLUGIN-PROMPT.md` is the way to hand that to a fresh agent. This file is for the concepts a
|
||||
plugin author needs that are not procedure.
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
**This directory holds documentation, not plugins.** Every plugin is its own repository; `plugins/<name>/`
|
||||
appears by `git clone` when somebody installs one, and is gitignored here.
|
||||
|
||||
---
|
||||
|
||||
## Affordances
|
||||
|
||||
**`docs/affordances.md` is the authority** — what one is, the three tests for whether something qualifies,
|
||||
the file format and how they reach the agent. Read it before writing one. What follows is only the part
|
||||
that is specific to shipping one from a plugin.
|
||||
|
||||
> An affordance is something the surface on the other end of the conversation offers the agent — declared
|
||||
> by the code that implements it, and read by the agent at runtime. Not what the agent can do; what the
|
||||
> client will honour.
|
||||
|
||||
A plugin ships an `affordances/` directory of markdown-with-frontmatter in its repository. Installing
|
||||
copies it to `capabilities/affordances/<publisher>/<appName>/`, uninstalling removes it — the same shape
|
||||
as `assets/` and `publishAssets`, and the namespace comes from where it lands rather than from anything
|
||||
inside the file.
|
||||
|
||||
**The test that catches most bad ones: can it be fixed instead?** If the answer is yes, fix it. An
|
||||
affordance describing a defect gives that defect a rationale, and the rationale outlives everyone's memory
|
||||
that it was a bug. See the two worked examples in `docs/affordances.md`.
|
||||
|
||||
Keep it short — every affordance is loaded into every conversation on this machine, including turns that
|
||||
have nothing to do with your plugin.
|
||||
|
||||
### A plugin-contributed one, for reference## Notifications — the same shape, for push
|
||||
|
||||
A plugin can also declare what its push notifications MEAN. Same mechanism, different store:
|
||||
|
||||
```
|
||||
capabilities/notifications/
|
||||
officerdev/music/
|
||||
catalogue.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
nowPlaying:
|
||||
title: 'Now playing'
|
||||
route: 'officer://music/{id}'
|
||||
```
|
||||
|
||||
That becomes the code `officerdev/music:nowPlaying`. A push carries the code and integers — never content —
|
||||
and the device renders the title and follows the route. The point is that **adding a notification is a
|
||||
server change and never an app build**, because the mapping is synced to the device rather than compiled
|
||||
into it.
|
||||
|
||||
**The namespace comes from where the file lands, not from what it says.** Writing `officer/core:test` as a
|
||||
key in your own catalogue produces `<you>/<yourplugin>:officer/core:test`, not the core code — the same
|
||||
rule as `mountPrefix`, and pinned by test in `src/servers/notifications/catalogue.test.ts`.
|
||||
|
||||
An entry needs a `title`; `body`, `target`, `route` and `thread` are optional, `thread` defaults to your
|
||||
namespace, and unknown keys are dropped rather than forwarded to the device. Broken YAML in one plugin's
|
||||
catalogue is skipped without affecting anyone else's.
|
||||
|
||||
See `docs/push-notifications.md`.
|
||||
|
||||
### A plugin-contributed one, for reference
|
||||
|
||||
This is the gitea plugin's, and it shows what these are for: the agent cannot discover from the
|
||||
filesystem that `tea` is authenticated, or that it should prefer `git` for the things git already does.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Gitea
|
||||
description: Use when working with git repositories, or with Gitea issues and pull requests.
|
||||
version: 1
|
||||
---
|
||||
|
||||
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks
|
||||
to the server directly.
|
||||
|
||||
For anything git does not — pull requests, issues, comments, releases, creating a repository — use
|
||||
`tea`. It is already logged in as the current user, with that user's own permissions. Do not
|
||||
configure it, and do not ask the user for a token.
|
||||
```
|
||||
|
||||
### On the word
|
||||
|
||||
`capability` was taken — `CAPABILITIES` is the permission registry. `skill` was taken twice: by
|
||||
`capabilities/skills/` here and by the wider AI ecosystem.
|
||||
|
||||
`affordance` is the exact term for what an environment offers an actor, and the precision earns the
|
||||
slight awkwardness: rendering an image is not a skill the agent has, it is something the client on the
|
||||
other end permits. Keeping that distinction in the name saves explaining it every time.
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
@@ -64,7 +64,15 @@ for (const row of rows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const claudeMd = join(dir, 'CLAUDE.md');
|
||||
/*
|
||||
AGENTS.md where it exists, CLAUDE.md where it does not.
|
||||
|
||||
Since 2026-08-27 a commander's brief is written as AGENTS.md with a CLAUDE.md pointing at it, because
|
||||
opencode discovers only the first name. Commanders created BEFORE that have the content in CLAUDE.md
|
||||
and no AGENTS.md at all. Appending to the wrong one is the quiet failure this avoids: on a new
|
||||
commander it would bury a credential section inside a six-line pointer the agent never reads.
|
||||
*/
|
||||
const brief = existsSync(join(dir, 'AGENTS.md')) ? join(dir, 'AGENTS.md') : join(dir, 'CLAUDE.md');
|
||||
|
||||
/**
|
||||
* Append a section only when it is not already described.
|
||||
@@ -73,10 +81,10 @@ for (const row of rows) {
|
||||
* describe the same thing does not end up with it twice.
|
||||
*/
|
||||
const appendIfMissing = async (marker: string, lines: string[]): Promise<boolean> => {
|
||||
const existing = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf8') : '';
|
||||
const existing = existsSync(brief) ? readFileSync(brief, 'utf8') : '';
|
||||
if (existing.includes(marker)) return false;
|
||||
appendFileSync(claudeMd, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, claudeMd);
|
||||
appendFileSync(brief, `\n${lines.join('\n')}`);
|
||||
await giveToAccount(row.osUser, brief);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ async function main(): Promise<void> {
|
||||
const { PERMISSIONS } = await import('../src/servers/permissions/registry');
|
||||
record(
|
||||
checkCount(
|
||||
join(ROOT, 'CLAUDE.md'),
|
||||
join(ROOT, 'AGENTS.md'),
|
||||
/([0-9]+|[a-z-]+) entries as of/gi,
|
||||
PERMISSIONS.length,
|
||||
'permission registry entries',
|
||||
|
||||
@@ -66,7 +66,19 @@ const git = (dir: string, args: string[]): string | null => {
|
||||
return new TextDecoder().decode(result.stdout).trim() || null;
|
||||
};
|
||||
|
||||
const PLUGINS_DIR = resolve(import.meta.dir, '..', 'plugins');
|
||||
/*
|
||||
Which plugins directory to publish FROM.
|
||||
|
||||
There are two, and they are not the same thing. `platform/plugins/` holds what this machine has
|
||||
INSTALLED — updated by `plugins:update`, which is a git pull, and therefore always at whatever version
|
||||
was last installed. `officer-dev/plugins/` holds every repo in the org, cloned for working on, and is
|
||||
where a release is actually made.
|
||||
|
||||
Publishing from the install directory means the catalogue advertises the version this machine happens
|
||||
to be running rather than the version that was released. That is exactly what happened on 2026-08-27:
|
||||
21 plugins were tagged and the store went on showing 1.0.0.
|
||||
*/
|
||||
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
|
||||
|
||||
async function syncOne(dir: string): Promise<Sync | null> {
|
||||
const appName = basename(dir);
|
||||
@@ -109,6 +121,11 @@ async function syncOne(dir: string): Promise<Sync | null> {
|
||||
platform: String(manifest.platform ?? '>=1.0.0'),
|
||||
label: String(manifest.label ?? appName),
|
||||
summary: String(manifest.summary ?? ''),
|
||||
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
|
||||
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
|
||||
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
|
||||
// quietly disagreeing with the repository it claims to mirror.
|
||||
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
|
||||
color: (manifest.color as string) ?? null,
|
||||
tile: (manifest.tile as string) ?? null,
|
||||
// Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Is every plugin checkout still a well-formed clone of its own repository?
|
||||
|
||||
── Why a script and not care ──
|
||||
|
||||
On 2026-08-27 one commit had to go into all twenty-one plugin repos. Seven pushes failed, each for a
|
||||
different reason, and none of the reasons had anything to do with the commit:
|
||||
|
||||
· six remotes were HTTPS, which cannot authenticate non-interactively — they simply hung and failed
|
||||
· three checkouts were behind their published remote, so the push was rejected as non-fast-forward
|
||||
· one (`offscale`) had a fetch refspec and an upstream naming a branch called `main` that does not
|
||||
exist on the remote. That one is the reason this file exists: every command reported on a phantom.
|
||||
`git push` said "Everything up-to-date" while `git status` said the branch had diverged, and the
|
||||
repository was actually fine. Twenty minutes went into a repo with nothing wrong with it.
|
||||
· one remote URL had no `.git` suffix, so the catalogue published a clone URL unlike every other
|
||||
|
||||
None of that is visible until you try to push, which is the worst possible moment to find out — you are
|
||||
mid-task, across twenty-one repositories, and the failures look like they are about your change.
|
||||
|
||||
── What it checks, and what it deliberately does not ──
|
||||
|
||||
Only mechanical facts with one right answer: the remote's URL form, the fetch refspec, the branch, its
|
||||
upstream, and whether the checkout has diverged. It does not judge content, does not fetch by default
|
||||
(that would make a local check depend on the network), and never writes — `--fix` is a separate flag so
|
||||
that running the check can never be the thing that changes your tree.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/*
|
||||
Which plugins directory to check.
|
||||
|
||||
There are two as of 2026-08-27: `platform/plugins/` — what this machine has INSTALLED — and
|
||||
`officer-dev/plugins/`, every repo in the org cloned side by side for working on. They hold different
|
||||
sets, so the check has to be pointable at either rather than assuming the one it happens to sit next to.
|
||||
*/
|
||||
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins');
|
||||
const HOST = 'git@gitea.officer.dev';
|
||||
const BRANCH = 'master';
|
||||
const REFSPEC = '+refs/heads/*:refs/remotes/origin/*';
|
||||
|
||||
const FIX = process.argv.includes('--fix');
|
||||
const FETCH = process.argv.includes('--fetch');
|
||||
|
||||
const git = (dir: string, args: string[]): string | null => {
|
||||
const p = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
||||
return p.exitCode === 0 ? p.stdout.toString().trim() : null;
|
||||
};
|
||||
|
||||
type Problem = { plugin: string; what: string; fix?: () => void };
|
||||
const problems: Problem[] = [];
|
||||
|
||||
const dirs = readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
||||
// `statSync().isDirectory()`, not the dirent's — a Dirent for a SYMLINK to a directory answers false to
|
||||
// `isDirectory()` and true to `isSymbolicLink()`, so filtering on the dirent silently skips every
|
||||
// symlinked plugin. `platform/plugins/` is symlinks into the workspace clones since 2026-08-27, which
|
||||
// would have made this report "0 plugin repositories, all well-formed" — a green check over nothing.
|
||||
// `discover.ts` already does it this way, at line 239, for the same reason.
|
||||
.filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules')
|
||||
.filter((e) => {
|
||||
try {
|
||||
return statSync(join(PLUGINS_DIR, e.name)).isDirectory();
|
||||
} catch {
|
||||
return false; // a broken symlink, which the checks below have nothing useful to say about
|
||||
}
|
||||
})
|
||||
.map((e) => e.name)
|
||||
.filter((name) => existsSync(join(PLUGINS_DIR, name, '.git')))
|
||||
.sort();
|
||||
|
||||
/*
|
||||
A repo with no `manifest.ts` is not a plugin, and that is not drift.
|
||||
|
||||
`av-club` is the case: a standalone media service living in the plugins org, intended to become a
|
||||
plugin in time. Checking it against a plugin's conventions would report its branch name as a defect
|
||||
forever, and a check that cries wolf is one people stop reading.
|
||||
|
||||
Presence is the declaration here exactly as it is in `plugins/discover.ts` — the manifest is what makes
|
||||
something a plugin, so its absence is what makes something not one yet.
|
||||
*/
|
||||
const notYet = dirs.filter((name) => !existsSync(join(PLUGINS_DIR, name, 'manifest.ts')));
|
||||
const checkable = dirs.filter((name) => !notYet.includes(name));
|
||||
|
||||
for (const name of checkable) {
|
||||
const dir = join(PLUGINS_DIR, name);
|
||||
const expected = `${HOST}:plugins/${name}.git`;
|
||||
|
||||
// 1. The remote, exactly. SSH because that is what can push unattended, and `.git` because a URL unlike
|
||||
// every other is the one an installer trips over.
|
||||
const url = git(dir, ['remote', 'get-url', 'origin']);
|
||||
if (url !== expected) {
|
||||
problems.push({
|
||||
plugin: name,
|
||||
what: `remote is ${url ?? 'missing'}, expected ${expected}`,
|
||||
fix: () => void git(dir, ['remote', 'set-url', 'origin', expected]),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. The fetch refspec. A narrowed one (a --single-branch clone) hides every other branch; a WRONG one
|
||||
// makes git report on a branch that does not exist, which is the failure that cost the most time.
|
||||
const refspecs = (git(dir, ['config', '--get-all', 'remote.origin.fetch']) ?? '').split('\n').filter(Boolean);
|
||||
if (refspecs.length !== 1 || refspecs[0] !== REFSPEC) {
|
||||
problems.push({
|
||||
plugin: name,
|
||||
what: `fetch refspec is ${refspecs.join(' + ') || 'unset'}, expected ${REFSPEC}`,
|
||||
fix: () => {
|
||||
git(dir, ['config', '--unset-all', 'remote.origin.fetch']);
|
||||
git(dir, ['config', '--add', 'remote.origin.fetch', REFSPEC]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 3. The branch, and what it tracks.
|
||||
const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
if (branch !== BRANCH) {
|
||||
problems.push({ plugin: name, what: `on branch ${branch ?? '?'}, expected ${BRANCH}` });
|
||||
continue; // the checks below are all about this branch
|
||||
}
|
||||
const upstream = git(dir, ['config', '--get', `branch.${BRANCH}.merge`]);
|
||||
if (upstream !== `refs/heads/${BRANCH}`) {
|
||||
problems.push({
|
||||
plugin: name,
|
||||
what: `${BRANCH} tracks ${upstream ?? 'nothing'}, expected refs/heads/${BRANCH}`,
|
||||
fix: () => void git(dir, ['branch', `--set-upstream-to=origin/${BRANCH}`, BRANCH]),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Uncommitted work, and divergence. Not fixable here — both need a human decision about the content.
|
||||
const dirty = git(dir, ['status', '--porcelain']);
|
||||
if (dirty) problems.push({ plugin: name, what: `${dirty.split('\n').length} uncommitted file(s)` });
|
||||
|
||||
if (FETCH) git(dir, ['fetch', '-q', 'origin']);
|
||||
const counts = git(dir, ['rev-list', '--left-right', '--count', `origin/${BRANCH}...HEAD`]);
|
||||
if (counts) {
|
||||
const [behind, ahead] = counts.split(/\s+/).map(Number);
|
||||
if (behind) problems.push({ plugin: name, what: `${behind} commit(s) behind origin/${BRANCH}` });
|
||||
if (ahead) problems.push({ plugin: name, what: `${ahead} commit(s) NOT PUSHED` });
|
||||
}
|
||||
}
|
||||
|
||||
const skipped = notYet.length ? ` (${notYet.length} not a plugin yet: ${notYet.join(', ')})` : '';
|
||||
|
||||
if (!problems.length) {
|
||||
console.log(`plugins:check — ${checkable.length} plugin repositories, all well-formed${skipped}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const p of problems) console.error(` ${p.plugin}: ${p.what}`);
|
||||
|
||||
if (FIX) {
|
||||
const fixable = problems.filter((p) => p.fix);
|
||||
for (const p of fixable) p.fix!();
|
||||
console.error(`\nplugins:check --fix — repaired ${fixable.length} of ${problems.length}; re-run to confirm.`);
|
||||
console.error('Divergence and uncommitted work are never repaired here: both need a decision about content.');
|
||||
} else {
|
||||
console.error(
|
||||
`\nplugins:check — ${problems.length} problem(s). \`bun run plugins:check --fix\` repairs the mechanical ones.`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -100,6 +100,11 @@ fail() {
|
||||
|
||||
ANSWERS_FILE="${ANSWERS_FILE:-}"
|
||||
|
||||
# DOCKER_NETWORK included so officer-setup's Proxy step lands on the SAME bridge Docker
|
||||
# was told to use here, rather than each script independently defaulting to "services" and
|
||||
# happening to agree only because nobody has customised either yet. lib/docker.sh sets
|
||||
# DOCKER_NETWORK at source time (SETUP_DOCKER_NETWORK env var, else "services"), so it is
|
||||
# always defined by the time this runs, regardless of whether the Docker step itself ran.
|
||||
save_answers() {
|
||||
[[ -n "$ANSWERS_FILE" ]] || return 0
|
||||
cat >"$ANSWERS_FILE" <<EOF
|
||||
@@ -107,6 +112,7 @@ save_answers() {
|
||||
MACHINE_ROLE=${MACHINE_ROLE}
|
||||
SETUP_USERNAME=${USERNAME}
|
||||
OFFICER_ROOT=${OFFICER_ROOT}
|
||||
SETUP_DOCKER_NETWORK=${DOCKER_NETWORK}
|
||||
EOF
|
||||
chmod 600 "$ANSWERS_FILE"
|
||||
}
|
||||
@@ -165,25 +171,39 @@ ONLY_STEP="${ONLY_STEP:-}"
|
||||
# Keyed on the step title, so the sections themselves stay Linux code with no
|
||||
# `if macos` branches threaded through them. The reason is printed, because a
|
||||
# silent skip and a missing step look identical.
|
||||
declare -A MACOS_SKIP=(
|
||||
["User account"]="accounts are System Settings' business on a Mac, not a script's"
|
||||
["Disk space"]="ballast and swap tuning are server concerns"
|
||||
["Locale"]="macOS manages locale itself"
|
||||
["Timezone"]="macOS manages the timezone itself"
|
||||
["Swap"]="macOS sizes its own swap dynamically"
|
||||
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
|
||||
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
|
||||
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
|
||||
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
|
||||
["Boot hang"]="a systemd boot ordering fix"
|
||||
["SSH access"]="hardening a door a dev machine should not be opening"
|
||||
["DNS"]="systemd-resolved"
|
||||
["Network address"]="netplan, and a laptop moves between networks by design"
|
||||
["fail2ban"]="brute-force protection for an exposed SSH port"
|
||||
["Unattended upgrades"]="apt; macOS updates through Software Update"
|
||||
["Firewall"]="ufw; macOS has its own application firewall"
|
||||
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
|
||||
)
|
||||
#
|
||||
# A `case` rather than an associative array — `declare -A` is bash 4+, and macOS's own
|
||||
# stock `/bin/bash` is 3.2 (Apple hasn't shipped a newer one since bash went GPLv3), with
|
||||
# nothing newer installed on a fresh machine. An array keyed on the step title would parse-
|
||||
# fail the instant this file is sourced, before a single step could run — which is exactly
|
||||
# what happened here until it was measured. Prints the reason on stdout, so the caller reads
|
||||
# it with `$(...)`; exit status says whether the title matched anything at all.
|
||||
macos_skip_reason() {
|
||||
case "$1" in
|
||||
"User account") echo "accounts are System Settings' business on a Mac, not a script's" ;;
|
||||
"Disk space") echo "ballast and swap tuning are server concerns" ;;
|
||||
"Locale") echo "macOS manages locale itself" ;;
|
||||
"Timezone") echo "macOS manages the timezone itself" ;;
|
||||
"Swap") echo "macOS sizes its own swap dynamically" ;;
|
||||
"Emergency disk ballast") echo "a server trick for a machine nobody is sitting at" ;;
|
||||
"earlyoom") echo "Linux OOM killer tuning; macOS has its own memory pressure handling" ;;
|
||||
"inotify watch limit") echo "Linux inotify; macOS watches files through FSEvents" ;;
|
||||
"Sleep and suspend") echo "a laptop SHOULD sleep — this stops a server from doing it" ;;
|
||||
"Boot hang") echo "a systemd boot ordering fix" ;;
|
||||
"SSH access") echo "hardening a door a dev machine should not be opening" ;;
|
||||
"DNS") echo "systemd-resolved" ;;
|
||||
"Network address") echo "netplan, and a laptop moves between networks by design" ;;
|
||||
"fail2ban") echo "brute-force protection for an exposed SSH port" ;;
|
||||
"Unattended upgrades") echo "apt; macOS updates through Software Update" ;;
|
||||
"Firewall") echo "ufw; macOS has its own application firewall" ;;
|
||||
"Shell") echo "zsh is already the default, and tmux is a choice you make yourself" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# `${var,,}` (case-insensitive compare below) is bash 4+ too — this is the portable
|
||||
# pre-4 substitute, not a style choice.
|
||||
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
@@ -191,16 +211,19 @@ step() {
|
||||
# which one it is. Twenty-six sections, one place.
|
||||
declare -F report_section >/dev/null && report_section "$1"
|
||||
|
||||
if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
local _skip_reason
|
||||
if _skip_reason="$(macos_skip_reason "$1")"; then
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
echo -e " ${GREEN}SKIP${NC}: not on macOS — ${_skip_reason}"
|
||||
SKIP_STEP=true
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
@@ -508,6 +531,24 @@ ask_officer_root() {
|
||||
# both of which abort the step.
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# Run a command as the created account, e.g. `run_as_owner git clone ...`.
|
||||
#
|
||||
# On Linux this whole script runs as root — refused otherwise, see the pre-flight check
|
||||
# above — so `sudo -u "$USERNAME"` from root needs no password; sudo lets root become
|
||||
# anyone freely. On macOS the script deliberately never escalates (Homebrew refuses to run
|
||||
# as root), so the process already IS "$USERNAME", and re-invoking sudo for the SAME
|
||||
# account asks for a real password — potentially many times in one run, dev.sh calls this
|
||||
# shape roughly ten times — or fails outright on an account with no sudo rights at all.
|
||||
# `dev.sh`'s git/bun/deno/claude/opencode/editor helpers all route through this rather than
|
||||
# hand-rolling `sudo -H -u "$USERNAME" ...` each time, so macOS behaviour lives in one place.
|
||||
run_as_owner() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
"$@"
|
||||
else
|
||||
sudo -H -u "$USERNAME" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run a block as the created user (login shell, inherits HOME)
|
||||
as_user() {
|
||||
sudo -u "$USERNAME" -i bash -c "$1"
|
||||
|
||||
@@ -47,8 +47,8 @@ MACHINE_SETUP_DEV_LOADED=1
|
||||
# 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"); }
|
||||
git_get() { (cd / && run_as_owner git config --global --get "$1" 2>/dev/null) || true; }
|
||||
git_set() { (cd / && run_as_owner git config --global "$1" "$2"); }
|
||||
|
||||
# Is there anything configured at all?
|
||||
git_has_identity() { [[ -n "$(git_get user.name)" || -n "$(git_get user.email)" ]]; }
|
||||
@@ -102,7 +102,7 @@ install_oh_my_zsh() {
|
||||
# failed died here, silently, because the output is redirected: the run just
|
||||
# stopped after apt finished installing zsh, with nothing said. Observed on a
|
||||
# fresh Hetzner VPS, 2026-08-14.
|
||||
sudo -H -u "$USERNAME" sh -c \
|
||||
run_as_owner sh -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended >/dev/null 2>&1 ||
|
||||
true
|
||||
# Belt and braces: `|| true` above already makes the last command succeed, and
|
||||
@@ -155,7 +155,19 @@ nvim_latest_version() {
|
||||
#
|
||||
# The old install is removed only after the download has succeeded, so a failed
|
||||
# fetch leaves the working copy alone.
|
||||
#
|
||||
# This had NO macOS branch at all until it was caught here: unconditionally fetched
|
||||
# nvim-linux-<arch>.tar.gz regardless of $OS, which 404s on a Mac. Not a crash — the
|
||||
# caller's `if nvim_install; then … else warn …` catches the failure and reports it as
|
||||
# ERRORS+=("Neovim: install failed") rather than aborting the run — but Neovim never
|
||||
# actually landed. brew has a real, current formula, so that's the macOS path instead of
|
||||
# reaching for a macOS release asset the way lazygit's fix did.
|
||||
nvim_install() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install neovim
|
||||
return
|
||||
fi
|
||||
|
||||
local asset tarball dest
|
||||
asset="$(nvim_asset)"
|
||||
tarball="/tmp/nvim-linux-${asset}.tar.gz"
|
||||
@@ -187,11 +199,11 @@ 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
|
||||
(cd / && run_as_owner 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"
|
||||
[[ "$repo" == *LazyVim/starter* ]] && run_as_owner rm -rf "${dest}/.git"
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -230,6 +242,18 @@ node_installed_major() { node -v 2>/dev/null | sed 's/^v//; s/\..*//'; }
|
||||
|
||||
install_node() {
|
||||
local major="$1"
|
||||
|
||||
# NodeSource's setup script adds an apt repository — it is Debian/Ubuntu-only by name
|
||||
# and by mechanism (checks for dpkg internally), so there is nothing to point it at on
|
||||
# macOS. Homebrew's `node`/`nodejs` formula (both names resolve, confirmed) tracks only
|
||||
# the current release rather than an arbitrary major the way NodeSource's per-major
|
||||
# scripts do — same tradeoff this codebase already accepts for Docker and Xcode CLT on
|
||||
# macOS: brew's own version, not a pinned one.
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
pkg_install_now nodejs
|
||||
return
|
||||
fi
|
||||
|
||||
# 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" || {
|
||||
@@ -358,7 +382,7 @@ ensure_bun_symlink() {
|
||||
install_bun() {
|
||||
local tag="${1:-}"
|
||||
[[ -n "$tag" ]] || return 1
|
||||
(cd / && sudo -H -u "$USERNAME" bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
|
||||
(cd / && run_as_owner bash -c "curl -fsSL https://bun.sh/install | bash -s '${tag}'") >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.bun/bin/bun" ]]
|
||||
}
|
||||
|
||||
@@ -367,7 +391,7 @@ 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
|
||||
(cd / && run_as_owner bash -c 'curl -fsSL https://deno.land/install.sh | sh') >/dev/null 2>&1
|
||||
[[ -x "${USER_HOME}/.deno/bin/deno" ]]
|
||||
}
|
||||
|
||||
@@ -434,16 +458,16 @@ agent_is_npm_install() { [[ "$(readlink -f "$(agent_path "$1")" 2>/dev/null)" ==
|
||||
agent_version() {
|
||||
local bin
|
||||
bin="$(agent_path "$1")"
|
||||
[[ -n "$bin" ]] && (cd / && sudo -H -u "$USERNAME" "$bin" --version 2>/dev/null | head -1)
|
||||
[[ -n "$bin" ]] && (cd / && run_as_owner "$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
|
||||
(cd / && run_as_owner 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
|
||||
(cd / && run_as_owner bash -c "set -e; curl -fsSL ${OPENCODE_INSTALL_URL} | bash") >/dev/null 2>&1
|
||||
[[ -x "$(agent_bin opencode)" ]]
|
||||
}
|
||||
|
||||
@@ -471,7 +495,7 @@ editor_candidates() {
|
||||
|
||||
# `|| 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; }
|
||||
current_editor() { (cd / && run_as_owner bash -lc 'echo "${EDITOR:-}"' 2>/dev/null) || true; }
|
||||
|
||||
set_system_editor() {
|
||||
local editor="$1" path
|
||||
|
||||
@@ -108,24 +108,25 @@ pkgs_core() {
|
||||
# broken rather than a package being absent.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils radicale fail2ban unattended-upgrades
|
||||
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
inotify-tools poppler radicale fail2ban
|
||||
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
|
||||
inotify-tools poppler fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils radicale fail2ban
|
||||
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza poppler radicale
|
||||
#
|
||||
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -253,13 +254,20 @@ pkg_install() {
|
||||
# passed by value cannot be told apart from an empty one once it has been through
|
||||
# word splitting.
|
||||
#
|
||||
# Copies by name via eval rather than `local -n` — namerefs are bash 4.3+, and macOS's
|
||||
# stock bash is 3.2. This is the idiom namerefs replaced: build the local array by
|
||||
# expanding the CALLER's array name inside a double-quoted eval, which bash 3.2 has always
|
||||
# supported. `$2`/`$3` are trusted call-site literals (`present`, `missing`), never
|
||||
# user input, so the eval is not taking anything untrusted.
|
||||
#
|
||||
# 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"
|
||||
local -a _present _missing
|
||||
eval "_present=(\"\${$2[@]}\")"
|
||||
eval "_missing=(\"\${$3[@]}\")"
|
||||
|
||||
echo ""
|
||||
info "${label} — installs what is missing, keeps what you already have"
|
||||
|
||||
@@ -59,12 +59,35 @@ authorized_key_count() {
|
||||
|
||||
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.
|
||||
# Everything that has to be true for sshd to use ~/.ssh at all. sshd's StrictModes
|
||||
# (on by default, and nothing here turns it off) checks the HOME DIRECTORY too, not
|
||||
# just .ssh — this used to check only .ssh, which is the shape of bug that is hardest
|
||||
# to notice: an account whose home came from somewhere other than THIS script's own
|
||||
# adduser call (cloud-init, a prior useradd, LDAP, or simply an account that already
|
||||
# existed) can have a group- or world-writable home directory, and sshd refuses
|
||||
# authorized_keys under it silently. The .ssh directory and the key both look
|
||||
# completely correct — because they are — and nothing here used to say why the
|
||||
# login still failed.
|
||||
fix_ssh_permissions() {
|
||||
local dir
|
||||
local dir home mode
|
||||
dir="$(user_ssh_dir)"
|
||||
home="$USER_HOME"
|
||||
|
||||
if [[ -d "$home" ]]; then
|
||||
# GNU stat's -c, tried first since this step only ever runs on Linux (MACOS_SKIP)
|
||||
# — with a BSD fallback anyway rather than a call that silently returns nothing on
|
||||
# whatever this is next tested against, the same trap officer-setup's layout.sh
|
||||
# file_owner() hit for the same reason.
|
||||
mode="$(stat -c %a "$home" 2>/dev/null || stat -f %Lp "$home" 2>/dev/null)"
|
||||
# 0022 is the group-write and other-write bits together. Cleared rather than
|
||||
# forced to a fixed mode — 0755 vs 0750 is a real choice some setups make on
|
||||
# purpose, and this only removes the bits sshd actually refuses to see.
|
||||
if [[ -n "$mode" ]] && ((8#$mode & 0022)); then
|
||||
chmod go-w "$home"
|
||||
warn "${home} was group/world-writable (${mode}) — sshd's StrictModes refuses authorized_keys under a writable home regardless of .ssh's own permissions; cleared"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -d "$dir" ]] || install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$dir"
|
||||
chmod 700 "$dir"
|
||||
[[ -f "$dir/authorized_keys" ]] && chmod 600 "$dir/authorized_keys"
|
||||
|
||||
@@ -245,7 +245,18 @@ tailscale_needs_logout() {
|
||||
# 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.
|
||||
# Both this and install_exit_node_tuning below are Linux-only — sysctl.d, systemd's
|
||||
# networkd-dispatcher, ethtool NIC offload flags, none of which macOS has an equivalent
|
||||
# concept for. Tailscale itself DOES support acting as an exit node from macOS (that's the
|
||||
# `--advertise-exit-node` flag on `tailscale up`, untouched by this guard) — what's
|
||||
# unavailable is specifically this forwarding-throughput tuning, which is also a much
|
||||
# narrower loss on a Mac: the file's own MACOS_SKIP already treats a laptop advertising as
|
||||
# an exit node as an edge case, not the common path a homelab box is.
|
||||
enable_ip_forwarding() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
warn "IP-forwarding tuning is Linux-only (sysctl.d) — skipping; the exit node still works, just untuned"
|
||||
return 0
|
||||
fi
|
||||
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.
|
||||
@@ -265,6 +276,10 @@ EOF
|
||||
# once, because the settings are per-interface and do not survive the link going
|
||||
# down and back up.
|
||||
install_exit_node_tuning() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
warn "NIC offload tuning is Linux-only (networkd-dispatcher, ethtool) — skipping"
|
||||
return 0
|
||||
fi
|
||||
pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher
|
||||
|
||||
mkdir -p "$(dirname "$TS_DISPATCHER")"
|
||||
|
||||
@@ -48,7 +48,15 @@ tool_is_installed() { command -v "$(tool_command "$1")" &>/dev/null; }
|
||||
# The installers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# The installer script this used is literally named install_update_linux.sh — there is no
|
||||
# upstream equivalent for macOS to swap in, so this is brew there instead of a raw fetch.
|
||||
# All three tools are real Homebrew formulas (confirmed against the live catalogue), which
|
||||
# also means macOS gets `brew upgrade` maintenance the raw-fetch path never had.
|
||||
tool_install_lazydocker() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install lazydocker
|
||||
return
|
||||
fi
|
||||
curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh |
|
||||
DIR=/usr/local/bin bash
|
||||
}
|
||||
@@ -57,7 +65,16 @@ tool_install_lazydocker() {
|
||||
# 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.
|
||||
#
|
||||
# The URL was also hardcoded to `_Linux_` — brew handles macOS instead of this needing a
|
||||
# second literal-string branch, see tool_install_lazydocker above for why brew and not a
|
||||
# Darwin asset name here.
|
||||
tool_install_lazygit() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install lazygit
|
||||
return
|
||||
fi
|
||||
|
||||
local version asset url
|
||||
case "$ARCH" in
|
||||
amd64) asset="x86_64" ;;
|
||||
@@ -82,7 +99,14 @@ tool_install_lazygit() {
|
||||
# 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.
|
||||
#
|
||||
# starship's own installer IS cross-platform (unlike the two above), but brew on macOS
|
||||
# keeps it consistent with the other two tools and gets it `brew upgrade` maintenance.
|
||||
tool_install_starship() {
|
||||
if [[ "${OS:-}" == "macos" ]]; then
|
||||
brew install starship
|
||||
return
|
||||
fi
|
||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin >/dev/null
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
|
||||
# all and errors immediately ("invalid option -- P"), confirmed live.
|
||||
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
@@ -114,15 +116,17 @@ info "Machine: ${OS_NAME} (${ARCH})"
|
||||
info "Packages: ${PM:-none detected}"
|
||||
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
|
||||
|
||||
# Everything below this line is written against apt and systemd. Detection above
|
||||
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
|
||||
# nothing implements them yet — and running the apt path on Arch would half-build
|
||||
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
|
||||
# list one entry at a time as each package manager grows a real path.
|
||||
# Everything below this line is written against apt and systemd, with real macOS/brew
|
||||
# branches threaded through where a Mac needs different behaviour (MACOS_SKIP for whole
|
||||
# steps that are server-only, `$OS == macos` for the rest). Detection above also recognises
|
||||
# pacman and dnf so THEIR branches have somewhere to hang, but nothing implements those yet
|
||||
# — running the apt path on Arch would half-build a machine and stop somewhere unhelpful.
|
||||
# Refuse clearly instead, and relax this list one entry at a time as each package manager
|
||||
# grows a real path.
|
||||
case "$PM" in
|
||||
apt) ;;
|
||||
apt | brew) ;;
|
||||
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
|
||||
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
|
||||
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt and brew only, so far." ;;
|
||||
esac
|
||||
|
||||
ask_machine_role
|
||||
@@ -404,7 +408,9 @@ fi
|
||||
|
||||
step "System update"
|
||||
if ! skip; then
|
||||
mapfile -t UPGRADABLE < <(pkg_upgradable)
|
||||
# `mapfile` is bash 4+; macOS's stock bash is 3.2. This is the portable substitute.
|
||||
UPGRADABLE=()
|
||||
while IFS= read -r _line; do UPGRADABLE+=("$_line"); done < <(pkg_upgradable)
|
||||
|
||||
echo ""
|
||||
info "System update — upgrades installed packages to their latest versions"
|
||||
@@ -2479,7 +2485,9 @@ EOF
|
||||
echo " which is why git's own core.editor is deliberately not set: git"
|
||||
echo " falls back to \$EDITOR, so setting it here covers everything."
|
||||
echo ""
|
||||
mapfile -t EDITORS < <(editor_candidates)
|
||||
# mapfile is bash 4+; macOS's stock bash is 3.2.
|
||||
EDITORS=()
|
||||
while IFS= read -r _line; do EDITORS+=("$_line"); done < <(editor_candidates)
|
||||
if ((${#EDITORS[@]} == 0)); then
|
||||
warn "no editor found to offer — skipping"
|
||||
else
|
||||
|
||||
@@ -50,7 +50,9 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-l | --list)
|
||||
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
||||
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
|
||||
# all and errors immediately ("invalid option -- P"), confirmed live.
|
||||
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
|
||||
exit 0
|
||||
;;
|
||||
-h | --help)
|
||||
@@ -143,6 +145,13 @@ else
|
||||
echo " would have answered are asked below instead."
|
||||
fi
|
||||
|
||||
# proxy.sh set PROXY_NET from $SETUP_DOCKER_NETWORK when it was SOURCED, before the answers
|
||||
# file above could have populated that variable — so it needs re-deriving now the same way
|
||||
# ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL are recovered below. Without this a customised bridge
|
||||
# name from machine-setup silently would not carry over, and Proxy would default back to
|
||||
# "services" regardless of what Docker was actually told to use.
|
||||
PROXY_NET="${SETUP_DOCKER_NETWORK:-$PROXY_NET}"
|
||||
|
||||
# ── the account ──
|
||||
#
|
||||
# A remembered answer can go stale: the account it names may have been renamed or
|
||||
@@ -211,8 +220,11 @@ fi
|
||||
echo ""
|
||||
info "What Officer needs from this machine"
|
||||
|
||||
mapfile -t MISSING < <(missing_tools)
|
||||
mapfile -t MISSING_OPT < <(missing_optional_tools)
|
||||
# mapfile is bash 4+; macOS's stock bash is 3.2, so this reads line-by-line instead.
|
||||
MISSING=()
|
||||
while IFS= read -r _line; do MISSING+=("$_line"); done < <(missing_tools)
|
||||
MISSING_OPT=()
|
||||
while IFS= read -r _line; do MISSING_OPT+=("$_line"); done < <(missing_optional_tools)
|
||||
|
||||
for t in "${REQUIRED_TOOLS[@]}"; do
|
||||
if command -v "$t" &>/dev/null; then
|
||||
@@ -274,7 +286,9 @@ if ! skip; then
|
||||
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)
|
||||
# mapfile is bash 4+; macOS's stock bash is 3.2.
|
||||
WRONG_OWNER=()
|
||||
while IFS= read -r _line; do WRONG_OWNER+=("$_line"); done < <(layout_wrong_owner)
|
||||
if ((${#WRONG_OWNER[@]} > 0)); then
|
||||
echo ""
|
||||
warn "these exist but do not belong to ${USERNAME}:"
|
||||
|
||||
@@ -47,10 +47,16 @@ fail() {
|
||||
|
||||
ONLY_STEP="${ONLY_STEP:-}"
|
||||
|
||||
# `${var,,}` (case-insensitive --only compare below) is bash 4+. macOS's stock /bin/bash is
|
||||
# 3.2 — confirmed live: "bad substitution" — and under this script's `set -e` that means
|
||||
# `--only <step>` killed the ENTIRE run on macOS, not just the comparison. This is the
|
||||
# portable pre-4 substitute.
|
||||
lower() { tr '[:upper:]' '[:lower:]' <<<"$1"; }
|
||||
|
||||
step() {
|
||||
CURRENT_STEP="$1"
|
||||
if [[ -n "$ONLY_STEP" ]]; then
|
||||
if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then
|
||||
if [[ "$(lower "$1")" == "$(lower "$ONLY_STEP")" ]]; then
|
||||
SKIP_STEP=false
|
||||
echo ""
|
||||
echo -e "${BOLD}── $1 ──${NC}"
|
||||
@@ -134,7 +140,39 @@ ask_required() {
|
||||
|
||||
user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; }
|
||||
|
||||
# The bound address(es) of whatever is LISTENing on a TCP port — one per line, formatted
|
||||
# like `127.0.0.1:5432` or `*:5432`. Used both to ask "is anything on this port at all" and
|
||||
# "is it bound wide enough for a container to reach" (Postgres and the Proxy section ask
|
||||
# opposite versions of that question).
|
||||
#
|
||||
# `ss` (iproute2) is Linux-only — no equivalent ships with it on macOS. `lsof` is the
|
||||
# portable answer: present on every macOS install by default, and common enough on Linux
|
||||
# (already a REQUIRED_TOOL nowhere here, so this still prefers `ss` there since that is the
|
||||
# surface this was written and tested against) that branching by OS rather than picking one
|
||||
# tool for both keeps each path exactly as capable as it always was.
|
||||
listening_addrs() {
|
||||
local port="$1"
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk -v p=":${port}\$" '$9 ~ p {print $9}'
|
||||
else
|
||||
ss -ltn 2>/dev/null | awk -v p=":${port}\$" '$4 ~ p {print $4}'
|
||||
fi
|
||||
}
|
||||
|
||||
# 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"); }
|
||||
#
|
||||
# On Linux this script runs as root (that's the whole point of sudo -H -u here: dropping
|
||||
# from root down to the owning account). On macOS it never escalates — officer-setup.sh
|
||||
# refuses to even start under sudo there, "the account running this IS the owner" — so the
|
||||
# process already IS $USERNAME, and re-invoking sudo for the SAME account asks for a real
|
||||
# password on every single call (Dependencies, Schema, Build, Secrets, Services, Repository
|
||||
# all go through this) or fails outright without sudo rights at all.
|
||||
as_owner() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
(cd "${2:-/}" && bash -c "$1")
|
||||
else
|
||||
(cd "${2:-/}" && sudo -H -u "$USERNAME" bash -c "$1")
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -24,15 +24,20 @@ OFFICER_SETUP_BUILD_LOADED=1
|
||||
# attached. drizzle-kit then fails instead of waiting, which is the outcome you
|
||||
# want at 3am.
|
||||
push_schema() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
|
||||
as_owner "cd '$(platform_dir)' && bun db:push </dev/null" 2>&1
|
||||
}
|
||||
|
||||
# What tables the schema will create, read from the aggregator rather than
|
||||
# guessed. This is what makes the section able to say what it is about to do.
|
||||
#
|
||||
# Portable ERE + sed rather than GNU grep's -P/lookbehind: BSD grep on macOS has no -P at
|
||||
# all (confirmed live — errors immediately), and its 2>/dev/null here would have hidden that
|
||||
# and reported "0 tables" on every macOS run instead, cosmetic but silently wrong.
|
||||
schema_table_count() {
|
||||
local dir
|
||||
dir="$(platform_dir)/src/databases/officer_db/src"
|
||||
grep -oP "^export \* from '\./\K[\w-]+(?=/schema')" "$dir/schema.ts" 2>/dev/null | while read -r f; do
|
||||
grep -E "^export \* from '\./" "$dir/schema.ts" 2>/dev/null |
|
||||
sed -E "s/^export \* from '\.\/([A-Za-z0-9_-]+)\/schema'.*/\1/" | while read -r f; do
|
||||
grep -c "pgTable(" "$dir/$f/schema.ts" 2>/dev/null || true
|
||||
done | awk '{s+=$1} END {print s+0}'
|
||||
}
|
||||
@@ -43,7 +48,7 @@ schema_table_count() {
|
||||
# Not optional and not cosmetic: without it the server has no page to serve. It
|
||||
# is gitignored, so a fresh clone never has one.
|
||||
gen_index() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
|
||||
as_owner "cd '$(platform_dir)' && bun gen:index '$ENV_PUBLIC_URL'" 2>&1
|
||||
}
|
||||
|
||||
gen_index_output() { echo "$(platform_dir)/src/apps/officer-web/index.gen.html"; }
|
||||
|
||||
@@ -31,14 +31,32 @@ 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.
|
||||
#
|
||||
# Checks the OFFICERDEV_-prefixed key first, matching the priority `OFFICERDEV_X ?? X`
|
||||
# gives it at runtime — write_env ALWAYS writes prefixed keys (see below), so plain `env_get
|
||||
# PORT` used to compare against a key that could never appear in a file this script wrote,
|
||||
# and always came back empty. That silently defeated the resume block below it was written
|
||||
# to fix, and the Environment step's own re-run showed the wrong default. Verified against a
|
||||
# real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000], before this.
|
||||
# Falls back to the bare key so an old, hand-written unprefixed file still works, same as the
|
||||
# runtime does.
|
||||
env_get() {
|
||||
[[ -r "$(env_file)" ]] || return 0
|
||||
awk -F= -v k="$1" '
|
||||
$1 == k {
|
||||
awk -F= -v k="$1" -v pk="OFFICERDEV_$1" '
|
||||
$1 == pk {
|
||||
v = substr($0, index($0, "=") + 1)
|
||||
gsub(/^"|"$/, "", v)
|
||||
print v
|
||||
exit
|
||||
}
|
||||
$1 == k && !found {
|
||||
v = substr($0, index($0, "=") + 1)
|
||||
gsub(/^"|"$/, "", v)
|
||||
bare = v
|
||||
found = 1
|
||||
}
|
||||
END {
|
||||
if (bare != "") print bare
|
||||
}' "$(env_file)"
|
||||
}
|
||||
|
||||
@@ -111,7 +129,12 @@ ENVF
|
||||
# is any better a guess.
|
||||
tailnet_hostname() {
|
||||
local dns ip
|
||||
dns="$(tailscale status --json 2>/dev/null | grep -oP '"DNSName":\s*"\K[^"]+' | head -1)"
|
||||
# jq over grep -oP: this output is real JSON, and GNU grep's -P/\K doesn't exist on BSD
|
||||
# grep at all (confirmed live — errors visibly), which used to leak straight to the
|
||||
# terminal since only the `tailscale` call's stderr was suppressed, not the grep's. jq is
|
||||
# already an unconditional dependency elsewhere in this script (proxy.sh), so this is
|
||||
# strictly a correctness improvement even setting the portability problem aside.
|
||||
dns="$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // empty')"
|
||||
dns="${dns%.}" # MagicDNS reports it fully qualified, with a trailing dot
|
||||
dns="${dns%%.*}" # and we want the short name
|
||||
if [[ -n "$dns" ]]; then
|
||||
|
||||
@@ -39,6 +39,13 @@ layout_dirs() {
|
||||
echo "$(layout_items_dir)"
|
||||
}
|
||||
|
||||
# A file's owning username, portably: GNU stat (Linux) takes `-c %U`, BSD stat (macOS) has
|
||||
# no `-c` at all and wants `-f %Su` instead — confirmed live, GNU form exits
|
||||
# "illegal option -- c" under macOS's stat. Try GNU first since that is the common case,
|
||||
# fall back to BSD rather than branching on an OS variable this file does not otherwise
|
||||
# track.
|
||||
file_owner() { stat -c %U "$1" 2>/dev/null || stat -f %Su "$1"; }
|
||||
|
||||
# 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.
|
||||
@@ -58,7 +65,7 @@ layout_wrong_owner() {
|
||||
local dir
|
||||
while read -r dir; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
[[ "$(stat -c %U "$dir")" == "$USERNAME" ]] || echo "$dir"
|
||||
[[ "$(file_owner "$dir")" == "$USERNAME" ]] || echo "$dir"
|
||||
done < <(layout_dirs)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ pg_client_installed() {
|
||||
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||
# just a more annoying one to operate.
|
||||
install_pg_client() {
|
||||
local major codename
|
||||
local major
|
||||
major="$(pg_client_major)"
|
||||
[[ -n "$major" ]] || {
|
||||
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||
@@ -74,6 +74,49 @@ install_pg_client() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
install_pg_client_macos "$major"
|
||||
return $?
|
||||
fi
|
||||
|
||||
install_pg_client_linux "$major"
|
||||
}
|
||||
|
||||
# Homebrew's `postgresql@<major>` matches the apt path's own logic: the version has to agree
|
||||
# with the server's, not just be "a psql". It is keg-only — Homebrew never links more than one
|
||||
# Postgres onto PATH by default, because a dev machine plausibly has several majors installed
|
||||
# for different projects — so `brew install` alone does not make `psql` resolve. Symlinking
|
||||
# only the four client binaries this platform actually uses, rather than `brew link --force`ing
|
||||
# the whole keg, is the same caveat Homebrew's own postgresql formula prints; force-linking
|
||||
# would silently steal `psql` from whatever other major was linked before this ran.
|
||||
install_pg_client_macos() {
|
||||
local major="$1" prefix
|
||||
command -v brew &>/dev/null || {
|
||||
warn "Homebrew is not installed — cannot install postgresql@${major}. Run machine-setup.sh first."
|
||||
return 1
|
||||
}
|
||||
prefix="$(brew --prefix)"
|
||||
|
||||
brew install "postgresql@${major}" &>/dev/null || {
|
||||
warn "brew install postgresql@${major} failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
local bin
|
||||
for bin in psql pg_dump pg_dumpall pg_restore; do
|
||||
[[ -x "${prefix}/opt/postgresql@${major}/bin/${bin}" ]] || continue
|
||||
ln -sf "${prefix}/opt/postgresql@${major}/bin/${bin}" "${prefix}/bin/${bin}"
|
||||
done
|
||||
|
||||
pg_client_installed || {
|
||||
warn "psql is not version ${major} after installing — check: brew list postgresql@${major}"
|
||||
return 1
|
||||
}
|
||||
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed via Homebrew"
|
||||
}
|
||||
|
||||
install_pg_client_linux() {
|
||||
local major="$1" codename
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||
@@ -121,7 +164,12 @@ pg_container_running() { docker ps --filter "name=^${PG_CONTAINER}$" --format '{
|
||||
|
||||
# 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"; }
|
||||
#
|
||||
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) is the portable version, branching to
|
||||
# `lsof` on macOS. Confirmed empirically to fail silently the old way: `ss` doesn't exist on
|
||||
# macOS, its "command not found" was swallowed by `2>/dev/null`, and an empty grep result
|
||||
# reported the port as free even when something WAS listening.
|
||||
pg_port_in_use() { [[ -n "$(listening_addrs "$PG_PORT")" ]]; }
|
||||
|
||||
# Bound to loopback, deliberately, and the reason is worth keeping next to the
|
||||
# line it explains.
|
||||
@@ -189,9 +237,19 @@ ENVF
|
||||
return 0
|
||||
}
|
||||
|
||||
# Failure has to mean failure. The bare awk one-liner this replaced printed nothing and
|
||||
# still exited 0 when the file existed but had no POSTGRES_PASSWORD= line — so the caller's
|
||||
# `pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"` treated that as
|
||||
# success with an EMPTY password, and built POSTGRES_URL with a blank credential instead of
|
||||
# falling through to provision fresh. Only reachable via a hand-edited/corrupted .env, since
|
||||
# write_pg_compose always writes the line — but "reuse a blank password" is the wrong
|
||||
# direction to fail in even for an edge case that rare.
|
||||
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)"
|
||||
local pw
|
||||
pw="$(awk -F= '/^POSTGRES_PASSWORD=/ { print substr($0, index($0, "=") + 1); exit }' "$(pg_env_file)")"
|
||||
[[ -n "$pw" ]] || return 1
|
||||
printf '%s' "$pw"
|
||||
}
|
||||
|
||||
pg_compose_up() { as_owner "docker compose --project-directory '$(pg_service_dir)' up -d" /; }
|
||||
|
||||
@@ -32,6 +32,13 @@ load_machine_answers() {
|
||||
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 ;;
|
||||
# The bridge machine-setup told Docker to use — proxy.sh's PROXY_NET otherwise
|
||||
# defaults to "services" independently and can only agree with this by coincidence.
|
||||
# PROXY_NET is a top-level assignment evaluated when proxy.sh is SOURCED, which
|
||||
# happens before this function is ever called — officer-setup.sh re-derives it from
|
||||
# SETUP_DOCKER_NETWORK right after calling this, same pattern as ENV_PORT/
|
||||
# ENV_PUBLIC_URL/POSTGRES_URL below it.
|
||||
SETUP_DOCKER_NETWORK) if [[ -z "${SETUP_DOCKER_NETWORK:-}" ]]; then SETUP_DOCKER_NETWORK="$value"; fi ;;
|
||||
esac
|
||||
done <"$MACHINE_ANSWERS"
|
||||
return 0
|
||||
@@ -69,7 +76,19 @@ tool_why() {
|
||||
# The account has to exist before anything is written to its home.
|
||||
owner_exists() { id "$USERNAME" &>/dev/null; }
|
||||
|
||||
# `getent` is Linux/glibc — not present on stock macOS at all (confirmed: "command not
|
||||
# found" on a real machine; it only appeared to work in earlier testing because this dev
|
||||
# session's shell profile shims it, which a fresh install does not have). Reproduced live:
|
||||
# this returned the WRONG, hardcoded `/home/<user>` fallback on a real macOS run just now,
|
||||
# and `install -d` then failed on it outright ("Operation not supported") since /home isn't
|
||||
# a real, writable path on macOS.
|
||||
#
|
||||
# `eval echo ~"$USERNAME"` is the portable replacement — tilde expansion resolves through
|
||||
# whatever the OS's own user database is (NSS on Linux, Directory Services on macOS)
|
||||
# without naming either. A nonexistent user leaves the tilde word unexpanded (`~name`
|
||||
# rather than a path), which is how the fallback below still triggers for that case.
|
||||
resolve_user_home() {
|
||||
USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)"
|
||||
USER_HOME="$(eval echo ~"$USERNAME" 2>/dev/null)"
|
||||
[[ "$USER_HOME" == "~"* ]] && USER_HOME=""
|
||||
[[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ proxy_ask() {
|
||||
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
|
||||
proxy_require_listening() {
|
||||
local port="$1" listen
|
||||
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
|
||||
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) branches to `lsof` on macOS. Same
|
||||
# silent-empty-result failure mode as postgres.sh's pg_port_in_use if this were left as-is.
|
||||
listen="$(listening_addrs "$port" | head -1)"
|
||||
[[ -n "$listen" ]] || {
|
||||
warn "nothing is listening on port ${port} — start Officer first"
|
||||
return 1
|
||||
@@ -170,7 +172,15 @@ proxy_wait_for_dns() {
|
||||
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
|
||||
|
||||
while ((elapsed < timeout)); do
|
||||
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
|
||||
# `getent` is glibc/Linux-only — absent on macOS (confirmed: "command not found" on a
|
||||
# real machine), silently swallowed by 2>/dev/null, which would have spun this through
|
||||
# its full 30-minute timeout regardless of whether the record actually resolved.
|
||||
# `dscacheutil` is macOS's own equivalent, consulting the same system resolver.
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
got="$(dscacheutil -q host -a name "$domain" 2>/dev/null | awk '/^ip_address:/ {print $2; exit}')"
|
||||
else
|
||||
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
|
||||
fi
|
||||
if [[ "$got" == "$want" ]]; then
|
||||
ok "${domain} resolves to ${want}"
|
||||
return 0
|
||||
@@ -194,8 +204,18 @@ proxy_ensure_network() {
|
||||
|
||||
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
|
||||
# address does not exist yet and the WHOLE container fails to start, not just that port.
|
||||
#
|
||||
# Docker Desktop on macOS has no systemd unit to order — it is a GUI app the OS launches, not
|
||||
# a service tailscaled could be declared a dependency of — and this account does not run as
|
||||
# root there either, so `mkdir -p /etc/systemd/...` would fail on permissions rather than do
|
||||
# something merely unnecessary. Same shape as machine-setup's MACOS_SKIP: skip with a reason
|
||||
# rather than let a Linux-only action fail confusingly.
|
||||
proxy_order_docker_after_tailscaled() {
|
||||
[[ "$CHALLENGE" == "dns" ]] || return 0
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
ok "Docker Desktop has no systemd unit to order — skipping"
|
||||
return 0
|
||||
fi
|
||||
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
|
||||
[[ -f "$f" ]] && return 0
|
||||
mkdir -p "$(dirname "$f")"
|
||||
@@ -378,8 +398,20 @@ proxy_get_token() {
|
||||
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
|
||||
# upstream would need none of this, which is why container upstreams are preferable when
|
||||
# there is a choice.
|
||||
# `ufw` is Debian/Ubuntu — no macOS equivalent through this interface (pf/pfctl is a
|
||||
# different rule syntax entirely, and the Application Firewall machine-setup's own
|
||||
# MACOS_SKIP already documents for "Firewall" is per-APP, not per-subnet/per-port). Skipped
|
||||
# rather than guessed at: Docker Desktop for Mac also runs containers inside its own VM, so
|
||||
# bridge-to-host traffic does not cross the real host firewall the same way it does when
|
||||
# Docker runs directly on a Linux kernel — an unverified pf rule here could be solving a
|
||||
# problem macOS's Docker networking model does not actually have.
|
||||
proxy_allow_bridge_to_host() {
|
||||
local port="$1" subnet
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
ok "no ufw on macOS — Docker Desktop's own networking model handles bridge-to-host, skipping"
|
||||
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
|
||||
return 0
|
||||
fi
|
||||
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
|
||||
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
|
||||
ok "ufw already allows the bridge to reach port ${port}"
|
||||
|
||||
@@ -35,7 +35,7 @@ bootstrap_secret_store() {
|
||||
|
||||
# From the repo, because the module derives the install root as the parent of
|
||||
# the working directory — the same rule as src/servers/data-path.ts.
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
|
||||
as_owner "cd '$(platform_dir)' && bun --eval \"
|
||||
const { getKey } = await import('officerdb/secret-store');
|
||||
getKey('jwt');
|
||||
\"" >/dev/null 2>&1 || return 1
|
||||
|
||||
@@ -44,13 +44,6 @@ CORE_PROCESSES=(
|
||||
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
|
||||
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
|
||||
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
|
||||
# Calendar and contacts, CORE since 2026-08-19 rather than an app-store entry.
|
||||
#
|
||||
# It was a Docker service because the app store made everything one, not because Radicale needs a
|
||||
# container — it is a ~2MB Python package, and this sidecar spawns it directly on loopback. A personal
|
||||
# server having a calendar is not an integration with something you already run; it is the baseline.
|
||||
# Radicale needs `python3` and the `radicale` package, both installed by machine-setup.
|
||||
"officer-caldav|bun|run src/servers/sidecar/caldav/index.ts"
|
||||
# Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with
|
||||
# memos and photos when it was slated to become a plugin — the only one of the three never extracted,
|
||||
# so it sat mounted nowhere with its schema commented out.
|
||||
@@ -62,6 +55,11 @@ CORE_PROCESSES=(
|
||||
"officer-notify|bun|run src/servers/sidecar/notify/index.ts"
|
||||
)
|
||||
|
||||
# Calendar and contacts are NOT here. They were core from 2026-08-19 until 2026-08-27, when caldav became
|
||||
# a plugin: it declares its own sidecar, its own `radicale` dependency and its own two screens, and the
|
||||
# generated ecosystem file gains an `officer-caldav` entry when it is installed. A fresh machine runs five
|
||||
# processes instead of seven, and gets a calendar by installing one plugin.
|
||||
|
||||
write_ecosystem() {
|
||||
local dest entry name script args
|
||||
dest="$(ecosystem_file)"
|
||||
@@ -101,24 +99,36 @@ FOOTER
|
||||
}
|
||||
|
||||
pm2_start() {
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
|
||||
as_owner "cd '$(platform_dir)' && pm2 startOrRestart '$(ecosystem_file)' --update-env" 2>&1
|
||||
}
|
||||
|
||||
pm2_save() { sudo -u "$USERNAME" pm2 save 2>&1; }
|
||||
pm2_save() { as_owner "pm2 save" 2>&1; }
|
||||
|
||||
# Survive a reboot. `pm2 startup` PRINTS a command for root to run rather than
|
||||
# doing it — so this runs what it prints, which is the whole point of already
|
||||
# being root here.
|
||||
#
|
||||
# `systemd` is hardcoded because that IS the only path today — pm2's own "platform" argument
|
||||
# has to be `launchd` on macOS instead, generating a LaunchAgent plist rather than a systemd
|
||||
# unit, and running it is a separate mechanism (`launchctl load`, not "eval a printed sudo
|
||||
# command"). That is real, separate work rather than a one-line swap, so this skips with a
|
||||
# stated reason on macOS instead of pretending the systemd path could ever produce anything
|
||||
# usable there — matches the same call this codebase already made for the Tailscale exit-node
|
||||
# NIC tuning and the proxy's systemd docker-ordering fix.
|
||||
pm2_enable_startup() {
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
warn "pm2 boot-persistence needs a launchd plist on macOS, not a systemd unit — not implemented yet"
|
||||
return 1
|
||||
fi
|
||||
local cmd
|
||||
cmd="$(sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
|
||||
cmd="$(as_owner "cd '$(platform_dir)' && pm2 startup systemd -u '$USERNAME' --hp '$USER_HOME'" 2>/dev/null | grep -E '^sudo ' | tail -1)"
|
||||
[[ -z "$cmd" ]] && return 1
|
||||
eval "${cmd#sudo }"
|
||||
}
|
||||
|
||||
# One line per process: name, status, restarts.
|
||||
pm2_status_lines() {
|
||||
sudo -u "$USERNAME" pm2 jlist 2>/dev/null |
|
||||
as_owner "pm2 jlist" 2>/dev/null |
|
||||
node -e '
|
||||
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
||||
let apps = []; try { apps = JSON.parse(s); } catch { }
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# Frontend Apps
|
||||
|
||||
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
|
||||
|
||||
## UI Components
|
||||
|
||||
### shadcn/ui (Base)
|
||||
Location: `src/workspaces/components/ui/`
|
||||
|
||||
Standard shadcn/ui components with Tailwind CSS. Import via:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
Location: `src/workspaces/components/`
|
||||
|
||||
Built on top of shadcn primitives:
|
||||
- `Avatar.tsx` - User avatars
|
||||
- `Card.tsx` - Custom card wrapper
|
||||
- `ColorPicker.tsx` - Color selection
|
||||
- `DataTable/` - Table with sorting, filtering, pagination (see below)
|
||||
- `Dialogs/` - Common dialog patterns
|
||||
- `ErrorDialogs/` - Error display dialogs
|
||||
- `MetricCard.tsx` - Stats display card
|
||||
- `SearchInput.tsx` - Search with debounce
|
||||
- `Select.tsx` - Enhanced select
|
||||
- `Slider/` - Custom slider
|
||||
- `Tabs/` - Enhanced tabs
|
||||
|
||||
### DataTable + useDataControl
|
||||
|
||||
A complete data table solution with sorting, filtering, and pagination.
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
import { DataTable, useDataControl } from '@/components/DataTable';
|
||||
|
||||
function JobsLibrary() {
|
||||
const { jobs } = useJobs();
|
||||
const dataController = useDataControl<Job>(jobs || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Wire search input to controller */}
|
||||
<SearchInput
|
||||
value={dataController.searchQuery}
|
||||
handleSearch={dataController.setSearchQuery}
|
||||
/>
|
||||
|
||||
<DataTable<Job>
|
||||
dataController={dataController}
|
||||
pageSize={10}
|
||||
columns={[
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'type', label: 'Type', sortKey: 'type' },
|
||||
{
|
||||
field: 'status',
|
||||
label: 'Status',
|
||||
condition: view === 'All', // conditional column
|
||||
format: ({ value, item }) => <Badge>{value}</Badge>
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <ActionsCell job={item} />
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useDataControl returns:**
|
||||
- `data` - Current page of filtered/sorted data
|
||||
- `rawData` - Original unfiltered data
|
||||
- `searchQuery`, `setSearchQuery` - Search state
|
||||
- `searchKeys`, `setSearchKeys` - Which fields to search
|
||||
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
|
||||
- `sortKey`, `sortDirection` - Parsed sort info
|
||||
- `currentPage`, `changePage`, `pageCount` - Pagination
|
||||
- `pageSize`, `setPageSize` - Items per page
|
||||
- `setCustomSort` - Custom sort function
|
||||
|
||||
**Column options:**
|
||||
- `field` - Key in data object
|
||||
- `label` / `labelMobile` - Header text
|
||||
- `sortKey` - Enable sorting on this column
|
||||
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
|
||||
- `condition` - Show/hide column: `boolean | () => boolean`
|
||||
- `tooltip` - Header tooltip text
|
||||
- `headerClassName` / `cellClassName` - Styling
|
||||
|
||||
**Features:**
|
||||
- Auto-generates columns from data if not specified
|
||||
- Resets to page 1 on search/sort change
|
||||
- Search strips diacritics for accent-insensitive matching
|
||||
- Built-in pagination bar (shows when data exceeds pageSize)
|
||||
|
||||
## Hooks
|
||||
|
||||
Location: `src/workspaces/hooks/src/`
|
||||
|
||||
### Data Fetching - `useClient`
|
||||
HTTP client with auth token handling:
|
||||
```tsx
|
||||
const client = useClient();
|
||||
const data = await client.get<User[]>("/users");
|
||||
await client.post("/experiments", payload);
|
||||
```
|
||||
|
||||
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
Auto-attaches Bearer token from localStorage/sessionStorage.
|
||||
|
||||
### Forms - `useForm`
|
||||
Custom form hook (not react-hook-form):
|
||||
```tsx
|
||||
const { state, formRef, update, reset, isValid } = useForm<FormState>(
|
||||
initialState,
|
||||
validateFn
|
||||
);
|
||||
|
||||
return <form ref={formRef}>...</form>;
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-syncs form inputs with state via `name` attribute
|
||||
- Handles checkboxes, radios, selects, number inputs
|
||||
- Nested object support via data attributes
|
||||
- Validation function support
|
||||
|
||||
### Other Hooks
|
||||
- `useAuth/` - Authentication state and methods
|
||||
- `useDebounce` - Debounced values
|
||||
- `useDragAndDrop` - Drag and drop functionality
|
||||
- `useFullscreen/` - Fullscreen API wrapper
|
||||
- `useIsMobile` - Responsive breakpoint detection
|
||||
- `useLocalStorageState` - Persisted state
|
||||
- `useMounted` - Component mount status
|
||||
- `usePopover` - Popover state management
|
||||
- `useTimeout` - Timeout management
|
||||
- `useTimer` - Interval-based timer
|
||||
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
|
||||
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
|
||||
- `useJobs` - background jobs
|
||||
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
|
||||
|
||||
## State Management
|
||||
|
||||
Uses React Query cache as both server and client state manager.
|
||||
|
||||
### Global State - `useGlobal`
|
||||
|
||||
Uses React Query cache as a global state store (no Context providers needed):
|
||||
|
||||
```tsx
|
||||
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
|
||||
|
||||
// Any component using the same key shares state and reacts to changes
|
||||
setValue("collapsed");
|
||||
```
|
||||
|
||||
How it works:
|
||||
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
|
||||
- Automatic re-renders when state changes
|
||||
- Visible in React Query DevTools
|
||||
- Supports functional updates: `setValue(prev => ...)`
|
||||
|
||||
### URL Query State - `useQueryState`
|
||||
|
||||
Syncs state with URL query parameters:
|
||||
|
||||
```tsx
|
||||
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
|
||||
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
|
||||
```
|
||||
|
||||
Features:
|
||||
- Updates URL via `history.replaceState` (no page reload)
|
||||
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
|
||||
- `isGlobal = false`: local to component, uses `useState`
|
||||
- Auto-rebuilds URL when pathname changes (preserves global query state)
|
||||
- Type coercion based on `defaultValue` type
|
||||
|
||||
### Server State
|
||||
|
||||
Domain-specific hooks in `state/` directories wrap React Query:
|
||||
|
||||
```tsx
|
||||
// src/workspaces/hooks/src/useJobs.ts
|
||||
const { jobs, isLoading } = useJobs();
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Hook |
|
||||
|----------|------|
|
||||
| API data | Domain hooks (`useJobs`, `useModels`, …) |
|
||||
| Shared UI state | `useGlobal` |
|
||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||
| Component-only state | `useState` |
|
||||
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
|
||||
| Persisted to localStorage | `useLocalStorageState` |
|
||||
|
||||
## Event Handlers
|
||||
|
||||
**Always use `ev` for event parameters**, not `e`:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
```
|
||||
|
||||
## React 19 Patterns
|
||||
|
||||
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
|
||||
```tsx
|
||||
// ✅ Good - allow null in ref type
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
// ❌ Bad - will error when passing useRef result
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
|
||||
```tsx
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type TopHeaderProps = {
|
||||
manager: ReturnType<typeof useJobs>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Toast notifications** via Sonner:
|
||||
```tsx
|
||||
import { toast } from "sonner";
|
||||
|
||||
toast.error("Something went wrong");
|
||||
toast.success("Saved successfully");
|
||||
```
|
||||
|
||||
API errors automatically trigger via `useClient.config.onError`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `../../CLAUDE.md` — architecture and the frontend route conventions
|
||||
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
|
||||
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
|
||||
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
|
||||
- `../../CONVENTIONS.md` — component organisation and React patterns
|
||||
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
|
||||
+3
-268
@@ -1,271 +1,6 @@
|
||||
# Frontend Apps
|
||||
|
||||
Shared patterns and conventions for all frontend applications (dashboard, editor, runtime).
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
## UI Components
|
||||
|
||||
### shadcn/ui (Base)
|
||||
Location: `src/workspaces/components/ui/`
|
||||
|
||||
Standard shadcn/ui components with Tailwind CSS. Import via:
|
||||
```tsx
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
Location: `src/workspaces/components/`
|
||||
|
||||
Built on top of shadcn primitives:
|
||||
- `Avatar.tsx` - User avatars
|
||||
- `Card.tsx` - Custom card wrapper
|
||||
- `ColorPicker.tsx` - Color selection
|
||||
- `DataTable/` - Table with sorting, filtering, pagination (see below)
|
||||
- `Dialogs/` - Common dialog patterns
|
||||
- `ErrorDialogs/` - Error display dialogs
|
||||
- `MetricCard.tsx` - Stats display card
|
||||
- `SearchInput.tsx` - Search with debounce
|
||||
- `Select.tsx` - Enhanced select
|
||||
- `Slider/` - Custom slider
|
||||
- `Tabs/` - Enhanced tabs
|
||||
|
||||
### DataTable + useDataControl
|
||||
|
||||
A complete data table solution with sorting, filtering, and pagination.
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
import { DataTable, useDataControl } from '@/components/DataTable';
|
||||
|
||||
function JobsLibrary() {
|
||||
const { jobs } = useJobs();
|
||||
const dataController = useDataControl<Job>(jobs || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Wire search input to controller */}
|
||||
<SearchInput
|
||||
value={dataController.searchQuery}
|
||||
handleSearch={dataController.setSearchQuery}
|
||||
/>
|
||||
|
||||
<DataTable<Job>
|
||||
dataController={dataController}
|
||||
pageSize={10}
|
||||
columns={[
|
||||
{ field: 'id', label: 'ID', sortKey: 'id' },
|
||||
{ field: 'type', label: 'Type', sortKey: 'type' },
|
||||
{
|
||||
field: 'status',
|
||||
label: 'Status',
|
||||
condition: view === 'All', // conditional column
|
||||
format: ({ value, item }) => <Badge>{value}</Badge>
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
format: ({ item }) => <ActionsCell job={item} />
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**useDataControl returns:**
|
||||
- `data` - Current page of filtered/sorted data
|
||||
- `rawData` - Original unfiltered data
|
||||
- `searchQuery`, `setSearchQuery` - Search state
|
||||
- `searchKeys`, `setSearchKeys` - Which fields to search
|
||||
- `sortedBy`, `setSortedBy`, `sortBy` - Sort state
|
||||
- `sortKey`, `sortDirection` - Parsed sort info
|
||||
- `currentPage`, `changePage`, `pageCount` - Pagination
|
||||
- `pageSize`, `setPageSize` - Items per page
|
||||
- `setCustomSort` - Custom sort function
|
||||
|
||||
**Column options:**
|
||||
- `field` - Key in data object
|
||||
- `label` / `labelMobile` - Header text
|
||||
- `sortKey` - Enable sorting on this column
|
||||
- `format` - Custom render: `({ value, item, data, idx }) => ReactNode`
|
||||
- `condition` - Show/hide column: `boolean | () => boolean`
|
||||
- `tooltip` - Header tooltip text
|
||||
- `headerClassName` / `cellClassName` - Styling
|
||||
|
||||
**Features:**
|
||||
- Auto-generates columns from data if not specified
|
||||
- Resets to page 1 on search/sort change
|
||||
- Search strips diacritics for accent-insensitive matching
|
||||
- Built-in pagination bar (shows when data exceeds pageSize)
|
||||
|
||||
## Hooks
|
||||
|
||||
Location: `src/workspaces/hooks/src/`
|
||||
|
||||
### Data Fetching - `useClient`
|
||||
HTTP client with auth token handling:
|
||||
```tsx
|
||||
const client = useClient();
|
||||
const data = await client.get<User[]>("/users");
|
||||
await client.post("/experiments", payload);
|
||||
```
|
||||
|
||||
Methods: `get`, `getText`, `getBlob`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
Auto-attaches Bearer token from localStorage/sessionStorage.
|
||||
|
||||
### Forms - `useForm`
|
||||
Custom form hook (not react-hook-form):
|
||||
```tsx
|
||||
const { state, formRef, update, reset, isValid } = useForm<FormState>(
|
||||
initialState,
|
||||
validateFn
|
||||
);
|
||||
|
||||
return <form ref={formRef}>...</form>;
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-syncs form inputs with state via `name` attribute
|
||||
- Handles checkboxes, radios, selects, number inputs
|
||||
- Nested object support via data attributes
|
||||
- Validation function support
|
||||
|
||||
### Other Hooks
|
||||
- `useAuth/` - Authentication state and methods
|
||||
- `useDebounce` - Debounced values
|
||||
- `useDragAndDrop` - Drag and drop functionality
|
||||
- `useFullscreen/` - Fullscreen API wrapper
|
||||
- `useIsMobile` - Responsive breakpoint detection
|
||||
- `useLocalStorageState` - Persisted state
|
||||
- `useMounted` - Component mount status
|
||||
- `usePopover` - Popover state management
|
||||
- `useTimeout` - Timeout management
|
||||
- `useTimer` - Interval-based timer
|
||||
- `useChatWebSocket` - the chat socket, with reconnect + replay cursor
|
||||
- `usePanelChannel` - panel-to-panel signals (refresh buses, NOT selection — selection is the URL)
|
||||
- `useJobs` - background jobs
|
||||
- `useCustomSorter`, `useImageLoader`, `useIsProduction`, `usePhotoEditor`
|
||||
|
||||
## State Management
|
||||
|
||||
Uses React Query cache as both server and client state manager.
|
||||
|
||||
### Global State - `useGlobal`
|
||||
|
||||
Uses React Query cache as a global state store (no Context providers needed):
|
||||
|
||||
```tsx
|
||||
const [value, setValue, refresh, reset] = useGlobal<string>("SIDEBAR_STATE", "expanded");
|
||||
|
||||
// Any component using the same key shares state and reacts to changes
|
||||
setValue("collapsed");
|
||||
```
|
||||
|
||||
How it works:
|
||||
- `enabled: false` + `staleTime: Infinity` = never fetches, just stores
|
||||
- Automatic re-renders when state changes
|
||||
- Visible in React Query DevTools
|
||||
- Supports functional updates: `setValue(prev => ...)`
|
||||
|
||||
### URL Query State - `useQueryState`
|
||||
|
||||
Syncs state with URL query parameters:
|
||||
|
||||
```tsx
|
||||
const [page, setPage, reset, clear] = useQueryState<number>("page", 1);
|
||||
const [filter, setFilter] = useQueryState<string>("filter", null, true); // isGlobal = true
|
||||
```
|
||||
|
||||
Features:
|
||||
- Updates URL via `history.replaceState` (no page reload)
|
||||
- `isGlobal = true`: persists across navigation, uses `useGlobal` internally
|
||||
- `isGlobal = false`: local to component, uses `useState`
|
||||
- Auto-rebuilds URL when pathname changes (preserves global query state)
|
||||
- Type coercion based on `defaultValue` type
|
||||
|
||||
### Server State
|
||||
|
||||
Domain-specific hooks in `state/` directories wrap React Query:
|
||||
|
||||
```tsx
|
||||
// src/workspaces/hooks/src/useJobs.ts
|
||||
const { jobs, isLoading } = useJobs();
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
| Scenario | Hook |
|
||||
|----------|------|
|
||||
| API data | Domain hooks (`useJobs`, `useModels`, …) |
|
||||
| Shared UI state | `useGlobal` |
|
||||
| URL-driven state (filters, pagination) | `useQueryState` |
|
||||
| Component-only state | `useState` |
|
||||
| Shared UI state that must survive a refresh, per tab | `useSessionState` |
|
||||
| Persisted to localStorage | `useLocalStorageState` |
|
||||
|
||||
## Event Handlers
|
||||
|
||||
**Always use `ev` for event parameters**, not `e`:
|
||||
```tsx
|
||||
// ✅ Good
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
const handleSubmit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Bad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
```
|
||||
|
||||
## React 19 Patterns
|
||||
|
||||
**RefObject includes null**: In React 19, `useRef` returns `RefObject<T | null>`. Update prop types accordingly:
|
||||
```tsx
|
||||
// ✅ Good - allow null in ref type
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
};
|
||||
|
||||
// ❌ Bad - will error when passing useRef result
|
||||
type PanelProps = {
|
||||
triggerRef: React.RefObject<HTMLButtonElement>;
|
||||
};
|
||||
```
|
||||
|
||||
**Hook return types**: Use `ReturnType<typeof hookName>` for typing hook returns in props:
|
||||
```tsx
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type TopHeaderProps = {
|
||||
manager: ReturnType<typeof useJobs>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Toast notifications** via Sonner:
|
||||
```tsx
|
||||
import { toast } from "sonner";
|
||||
|
||||
toast.error("Something went wrong");
|
||||
toast.success("Saved successfully");
|
||||
```
|
||||
|
||||
API errors automatically trigger via `useClient.config.onError`.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- `../../CLAUDE.md` — architecture and the frontend route conventions
|
||||
- `../../docs/navigation-audit.md` — **authoritative** on routing and selection. The short version:
|
||||
addressable state lives in the URL (`useParams` / `?selected=`), never in a channel or a global; rows
|
||||
and nav items are real `<Link>`/`<NavLink>`s. Read it before building a screen that selects things.
|
||||
- `../../CONVENTIONS.md` — component organisation and React patterns
|
||||
- `../workspaces/officerdev/APP_CONVENTIONS.md` — panel apps and the AppRegistry
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
@@ -78,8 +78,6 @@ export function App() {
|
||||
entry keep working. Only DIRECTORIES live here — an open file stays `?view=`, since it is
|
||||
what is on top of you rather than where you are. */}
|
||||
<Route path="/files/*" element={<Dashboard.FilesScreen />} />
|
||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
<Route path="/command-hq" element={<Dashboard.CommandHqScreen />} />
|
||||
<Route path="/command-hq/:sessionId" element={<Dashboard.CommandHqScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
@@ -91,13 +89,15 @@ export function App() {
|
||||
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
||||
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
|
||||
{installedPlugins.flatMap((plugin) => [
|
||||
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
|
||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||
// passed between them.
|
||||
<Route
|
||||
key={`${plugin.appName}-section`}
|
||||
key={`${plugin.screenId}-section`}
|
||||
path={`${plugin.route}/:section`}
|
||||
element={<Dashboard.PluginScreen {...plugin} />}
|
||||
/>,
|
||||
@@ -108,6 +108,9 @@ export function App() {
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
||||
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
||||
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
||||
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { Bot, Check, Pencil, Plus, Trash2, X } from 'lucide-react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { useClaudeSessions, type ClaudeSessionSummary } from 'state/useClaudeSessions';
|
||||
|
||||
/**
|
||||
* The bots, as cards — the same object Command HQ gives a commander.
|
||||
*
|
||||
* ── Why not the chat session list ──
|
||||
*
|
||||
* A bot IS a chat underneath, and that is an implementation detail nobody using this should have to hold.
|
||||
* The generic session list says "conversation, last touched at" because that is what a conversation is. A
|
||||
* bot is a thing you name, check on, and eventually delete — so it gets a card with its name, its id, and
|
||||
* whether it is watching, exactly as a commander does.
|
||||
*
|
||||
* ── The id pill ──
|
||||
*
|
||||
* A commander shows `#54`, its directory. A bot has no directory of its own — they share one — so its id
|
||||
* is the first eight characters of its session, which is also what an `@mention` carries. Seeing the same
|
||||
* eight characters in both places is the point: the pill is what you type to reference it.
|
||||
*/
|
||||
|
||||
const shortId = (id: string) => id.slice(0, 8);
|
||||
|
||||
type CardProps = {
|
||||
bot: ClaudeSessionSummary;
|
||||
selected: boolean;
|
||||
onRename: (id: string, title: string) => Promise<unknown>;
|
||||
onDelete: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const BotCard = ({ bot, selected, onRename, onDelete }: CardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [draft, setDraft] = useState(bot.title);
|
||||
|
||||
const commit = async () => {
|
||||
const next = draft.trim();
|
||||
setRenaming(false);
|
||||
if (next && next !== bot.title) await onRename(bot.id, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex flex-col gap-1.5 rounded-xl border bg-background p-3 transition-colors hover:border-primary/40 hover:bg-muted/40 ${
|
||||
selected ? 'border-primary/60 ring-1 ring-primary/30' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
{/* The whole card is the link, with the controls layered above it — same arrangement as a commander
|
||||
card, so a click anywhere opens the bot and the buttons still work. */}
|
||||
{!renaming && <Link to={`/bots/${bot.id}`} className="absolute inset-0" aria-label={bot.title} />}
|
||||
|
||||
<div className="flex items-center gap-2 pr-12">
|
||||
<Bot className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
{renaming ? (
|
||||
<form
|
||||
className="z-10 flex min-w-0 flex-1 items-center gap-1"
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void commit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
onKeyDown={(ev) => ev.key === 'Escape' && setRenaming(false)}
|
||||
className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0.5 text-sm"
|
||||
/>
|
||||
<button type="submit" className="rounded p-0.5 text-primary" aria-label="Save name">
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate text-sm font-medium">{bot.title || '(unnamed)'}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground" title={bot.id}>
|
||||
#{shortId(bot.id)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{bot.messageCount} message{bot.messageCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span title={bot.updatedAt}>{new Date(bot.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
{confirming ? (
|
||||
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-1 rounded-lg bg-background/95 p-0.5 shadow">
|
||||
<button
|
||||
onClick={() => {
|
||||
void onDelete(bot.id);
|
||||
// Leaving the route pointed at something that no longer exists would render an empty chat.
|
||||
if (selected) void navigate('/bots');
|
||||
}}
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
title="Delete this bot"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirming(false)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
!renaming && (
|
||||
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(bot.title);
|
||||
setRenaming(true);
|
||||
}}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BotsList = () => {
|
||||
const { sessionId } = useParams();
|
||||
const { sessions, isLoading, renameSession, deleteSession } = useClaudeSessions(null, {
|
||||
chatContext: { context: 'bots' },
|
||||
});
|
||||
|
||||
return (
|
||||
<nav className="flex size-full flex-col">
|
||||
<header className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
{sessions.length} {sessions.length === 1 ? 'bot' : 'bots'}
|
||||
</span>
|
||||
{/* `/bots` with no id IS the new-bot state: an empty composer, and the first sentence arms it. */}
|
||||
<Link to="/bots" title="New bot" className="text-muted-foreground hover:text-foreground">
|
||||
<Plus className="size-4" />
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="p-3 text-xs text-muted-foreground">Loading…</p>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<Bot className="size-7 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No bots yet.</p>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
Say what you want watched — “notify me when x.com is down” — and it arms itself.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-1 flex-col gap-2 overflow-auto p-2">
|
||||
{sessions.map((bot) => (
|
||||
<li key={bot.id}>
|
||||
<BotCard bot={bot} selected={bot.id === sessionId} onRename={renameSession} onDelete={deleteSession} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
|
||||
import { useParams } from 'react-router';
|
||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { BotsList } from './BotsList';
|
||||
|
||||
/**
|
||||
* `/bots` — one sentence arms a watch, and the conversation stays open to widen it.
|
||||
*
|
||||
* ── The same shape as Command HQ ──
|
||||
*
|
||||
* A narrow list on the left, the conversation on the right, and the selection in the URL as a route pair:
|
||||
* `/bots` is the new-bot state, `/bots/:sessionId` is one of them. That is the convention
|
||||
* `docs/navigation-audit.md` sets and the one Command HQ already follows, so a bot is linkable and a
|
||||
* reload keeps the one you were reading.
|
||||
*
|
||||
* ── Why the panels are supplied here rather than registered ──
|
||||
*
|
||||
* Every bot runs in one shared directory, which the server resolves from `context: 'bots'`. The registry's
|
||||
* `officerdev/chat` derives its context from the workspace and would land on none of it — it would list
|
||||
* and resume the caller's HOME, which is exactly what this screen did on its first outing. `components` is
|
||||
* the prop for a screen that supplies its own chat panel, and it carries the header, so the picker lists
|
||||
* bots rather than every chat on the machine.
|
||||
*
|
||||
* The instructions a bot runs under are `$DATA_PATH/<email>/bots/CLAUDE.md`, written by the server on
|
||||
* every turn — `api/bots/brief.ts`.
|
||||
*/
|
||||
|
||||
const CHAT_CONTEXT = { context: 'bots' } as const;
|
||||
|
||||
const BotsChat = ({ panelId }: { panelId: string }) => {
|
||||
const { sessionId } = useParams();
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* `sessionId ?? null` — null is "the screen chose a new chat", which is not the same as having no
|
||||
opinion. On `/bots` with no id that is exactly right: an empty composer waiting for the sentence. */}
|
||||
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} sessionId={sessionId ?? null} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BotsScreen = () => {
|
||||
const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
|
||||
|
||||
return (
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
|
||||
components={{
|
||||
'bots-list': BotsList,
|
||||
// No header picker: the cards on the left are the list, and a second one in the panel bar would be
|
||||
// the same choice offered twice. Command HQ does not have one either, for the same reason.
|
||||
'bots-chat': BotsChat,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// The bots down the left, the conversation on the right — the same division Command HQ makes, and for the
|
||||
// same reason: the list is names and the right-hand side is the only thing anybody reads.
|
||||
//
|
||||
// No category pane. Commanders have one because a commander is a job somebody files; a bot is a sentence,
|
||||
// and a filter over a handful of one-line watches would be furniture.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'bots-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'bots-list', appType: 'bots-list' }, size: 24 },
|
||||
{ node: { type: 'panel', id: 'bots-chat', appType: 'bots-chat' }, size: 76 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BotsScreen } from './BotsScreen';
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /calendar uses the Workspace/Panel system (like /command-hq): the calendar list on the left, the
|
||||
// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the
|
||||
// /api/caldav auth proxy, which holds no DAV credentials of its own.
|
||||
//
|
||||
// There is no :param route pair here and so no <Navigate> guard: a collection id is a DAV path with
|
||||
// slashes in it, so the selection lives in ?collection= on this same route. An absent or unknown value
|
||||
// resolves to the first collection inside the panels rather than by redirecting, because until the
|
||||
// collection list has loaded there is no canonical URL to redirect to.
|
||||
|
||||
export const CalendarScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/calendar', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['calendar-nav', 'calendar-view'], fallback: 'calendar-view' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'calendar-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'calendar-nav', appType: 'calendar-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'calendar-view', appType: 'calendar-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './CalendarScreen';
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected
|
||||
// collection is a query param rather than a route segment.
|
||||
|
||||
export const ContactsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/contacts', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['contacts-nav', 'contacts-view'], fallback: 'contacts-view' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'contacts-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'contacts-nav', appType: 'contacts-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'contacts-view', appType: 'contacts-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ContactsScreen';
|
||||
@@ -142,22 +142,23 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
|
||||
};
|
||||
|
||||
import {
|
||||
Home,
|
||||
MessageCircle,
|
||||
FolderOpen,
|
||||
Code,
|
||||
LayoutGrid,
|
||||
Monitor,
|
||||
Globe,
|
||||
Workflow,
|
||||
Music,
|
||||
Activity,
|
||||
Radio,
|
||||
Bot,
|
||||
Code,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Store,
|
||||
Globe,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
MessageCircle,
|
||||
Monitor,
|
||||
Music,
|
||||
Puzzle,
|
||||
Radar,
|
||||
Radio,
|
||||
Send,
|
||||
Store,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -176,6 +177,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
||||
{ labelKey: 'nav.files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ labelKey: 'nav.chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ labelKey: 'nav.commandHq', to: '/command-hq', icon: Radar, color: '#f43f5e' },
|
||||
// Beside Command HQ, which is the neighbouring idea: a commander runs a job, a bot watches one thing.
|
||||
// The route and the screen have existed since bots did; there was simply never a tile, so the only way
|
||||
// to reach /bots was to type it.
|
||||
{ labelKey: 'nav.bots', to: '/bots', icon: Bot, color: '#34d399' },
|
||||
{ labelKey: 'nav.editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ labelKey: 'nav.jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ labelKey: 'nav.terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
@@ -187,17 +192,10 @@ export const CORE_DOCK_ITEMS: CoreDockItem[] = [
|
||||
// `kind: 'app'` — so `canVisit()` hides the tile from anyone not granted it, which is the same treatment
|
||||
// every other grantable tile here gets.
|
||||
{ labelKey: 'nav.sendOff', to: '/sendoff', icon: Send, color: '#22d3ee' },
|
||||
// Core since 2026-08-19. These were contributed by the app-store `caldav` entry until calendar and
|
||||
// contacts became core, and deleting that entry took their tiles with it — the routes kept working
|
||||
// while the dock and the settings list went silent about them, which is exactly the shape of failure
|
||||
// this file's comment above warns about, arrived at from the other direction.
|
||||
//
|
||||
// Artwork rather than glyphs since 2026-08-19: both were redrawn to the icon brief, so they carry
|
||||
// the same voxel mark the phone apps show. `tile: 'bare'` for the same reason a plugin's artwork
|
||||
// does — the art brings its own frame, and a coloured square behind it reads as a mistake. `color`
|
||||
// still earns its place: the active glow, the indicator dot and the settings tint all use it.
|
||||
{ labelKey: 'nav.calendar', to: '/calendar', image: '/icons/calendar.png', tile: 'bare', color: '#ef4444' },
|
||||
{ labelKey: 'nav.contacts', to: '/contacts', image: '/icons/contacts.png', tile: 'bare', color: '#8b5cf6' },
|
||||
// Calendar and Contacts are NOT here. They were core tiles from 2026-08-19 until caldav became a
|
||||
// plugin on 2026-08-27, and they come back through `dockItemsFromPlugins` — one tile per screen, each
|
||||
// with its own artwork, filtered by the same `calendar` permission. A plugin contributing two tiles is
|
||||
// what `screens` in a manifest is for.
|
||||
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
|
||||
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
|
||||
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
|
||||
@@ -282,6 +280,8 @@ export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
||||
// this list is what every install has and most people want visible; a calendar is now in that category.
|
||||
// Contacts is deliberately NOT here — it is core and available in dock settings, but a second tile for
|
||||
// the same sidecar in everyone's default dock is a crowded default rather than a useful one.
|
||||
// `/calendar` stays in the default arrangement even though it is a plugin route now: it is installed by
|
||||
// default and a fresh dock that silently dropped it would look like a downgrade.
|
||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat', '/calendar'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -178,13 +178,9 @@ const SETTINGS_ITEMS: SettingsItem[] = [
|
||||
icon: KeyRound,
|
||||
},
|
||||
{ id: 'set:apify', labelKey: 'settings.sections.apify.label', to: '/settings/integrations/apify', icon: Wrench },
|
||||
{
|
||||
id: 'set:dav',
|
||||
labelKey: 'settings.sections.davSync.label',
|
||||
to: '/settings/integrations/dav-app-passwords',
|
||||
hintKey: 'settings.sections.davSync.hint',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
// DAV sync setup left with the caldav plugin on 2026-08-27 — it is a modal on the Calendar screen now,
|
||||
// and Spotlight has no way to open a plugin's modal. `bun run docs:check` caught this one: it compares
|
||||
// every settings route Spotlight offers against the sections that actually exist.
|
||||
// Users — UserManagement
|
||||
{
|
||||
id: 'set:users',
|
||||
|
||||
@@ -18,11 +18,18 @@ import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
// rearranges — and `appTypes.allowed` pins it to that plugin's own panels, so a persisted layout naming
|
||||
// something else falls back rather than rendering another plugin's panel inside this one.
|
||||
export function PluginScreen({
|
||||
appName,
|
||||
screenId,
|
||||
panels,
|
||||
layout,
|
||||
}: {
|
||||
appName: string;
|
||||
/**
|
||||
* This SCREEN's id, not the plugin's — `caldav/calendar`, not `caldav`.
|
||||
*
|
||||
* A plugin with two screens has two of these, and they must differ: the id is both the layout
|
||||
* persistence key and the owner of the panel allow-list below, so sharing one would have Calendar and
|
||||
* Contacts persisting over each other's sizes and each accepting the other's panels.
|
||||
*/
|
||||
screenId: string;
|
||||
panels: AppRegistryMeta[];
|
||||
layout: LayoutNode;
|
||||
}) {
|
||||
@@ -32,7 +39,7 @@ export function PluginScreen({
|
||||
// `layout` on every render, while only the sizes are the user's and persisted. That is what lets a plugin
|
||||
// ship a new layout and have it reach people who have already opened the screen — and it is what stops
|
||||
// the `appTypes` repair below from being written back as though the user had chosen it. See the hook.
|
||||
const workspace = useAuthoredLayout(`screens/plugin/${appName}`, layout);
|
||||
const workspace = useAuthoredLayout(`screens/plugin/${screenId}`, layout);
|
||||
const allowed = panels.map((panel) => panel.key);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
|
||||
@@ -11,7 +11,11 @@ import { defaultLayout } from './defaultLayout';
|
||||
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
|
||||
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
|
||||
export const PluginsScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
|
||||
// `useAuthoredLayout`, not `useDashboardState`: the STRUCTURE here is ours and comes from source on
|
||||
// every render, while only the sizes are the user's and persisted. Under the old hook the whole tree
|
||||
// was stored, so the panels this screen ships today would never have reached anyone who had opened it
|
||||
// before — they would still be looking at the list-and-preview this replaced.
|
||||
const workspace = useAuthoredLayout('screens/plugins', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
|
||||
@@ -8,7 +8,9 @@ export const defaultLayout: LayoutNode = {
|
||||
id: 'plugins-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
|
||||
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
|
||||
// 22/78, not 32/68. The left panel was a list of every plugin and needed the room; it is a list of
|
||||
// seven tags now, and the grid on the right is what benefits from the width — one more card per row.
|
||||
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 78 },
|
||||
],
|
||||
};
|
||||
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useFormat } from 'i18n';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
||||
//
|
||||
// The generated password is returned by POST and never again — it is stored as an argon2 hash, so there
|
||||
// is nothing to read back. That single fact drives the whole design of this screen: the new credential
|
||||
// is shown in a panel that stays put until dismissed, with the server URL and username beside it,
|
||||
// because the moment it disappears the only remedy is to revoke and mint another.
|
||||
|
||||
type DavPassword = {
|
||||
id: number;
|
||||
label: string;
|
||||
hint: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Minted = { password: string; username: string; label: string };
|
||||
|
||||
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
|
||||
|
||||
export const DavAppPasswords = () => {
|
||||
const { t } = useTranslation();
|
||||
const { date: formatDate } = useFormat();
|
||||
|
||||
const copy = async (text: string, what: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast.success(t('settings.dav.copied', { what }));
|
||||
} catch {
|
||||
toast.error(t('settings.dav.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const [passwords, setPasswords] = useState<DavPassword[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [label, setLabel] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [minted, setMinted] = useState<Minted | null>(null);
|
||||
|
||||
// The base the phone should be pointed at. Taken from the browser rather than configured: it is by
|
||||
// definition the address that reached this page, which is the one that will work.
|
||||
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
|
||||
const load = useCallback(() => {
|
||||
client
|
||||
.get<{ passwords: DavPassword[] }>('/dav/passwords')
|
||||
.then((data) => setPasswords(data?.passwords ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [client]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const create = async (ev?: FormEvent) => {
|
||||
ev?.preventDefault();
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const res = await client.post<{ password: string; username: string }>('/dav/passwords', { label: trimmed });
|
||||
setMinted({ password: res.password, username: res.username, label: trimmed });
|
||||
setLabel('');
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.createFailed'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: number) => {
|
||||
try {
|
||||
await client.post(`/dav/passwords/${id}/revoke`, {});
|
||||
toast.success(t('settings.dav.revoked'));
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.revokeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await client.delete(`/dav/passwords/${id}`);
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
Calendar and contacts sync uses one password per device. A phone can't hold an Officer session, so it gets its
|
||||
own credential — revocable on its own, without touching anything else.
|
||||
</p>
|
||||
|
||||
{minted && (
|
||||
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
|
||||
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
Password for “{minted.label}” — shown once
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
|
||||
revoke this entry and make another.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{[
|
||||
{ field: 'Server', value: serverUrl },
|
||||
{ field: 'Username', value: minted.username },
|
||||
{ field: 'Password', value: minted.password },
|
||||
].map(({ field, value }) => (
|
||||
<div key={field} className="flex items-center gap-2">
|
||||
<span className="w-20 shrink-0 text-xs text-duck-dark/50 dark:text-foreground/50">{field}</span>
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copy(value, field)}
|
||||
title={t('settings.dav.copyField', { field })}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||
In DAVx5 or iOS, choose “login with URL and username” and give it the server address above — not a full
|
||||
calendar path. Discovery does the rest.
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
|
||||
I've saved it
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={create} className="flex items-end gap-2">
|
||||
<Label className="grid flex-1 gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20"
|
||||
value={label}
|
||||
onChange={(ev) => setLabel(ev.target.value)}
|
||||
placeholder={t('settings.dav.placeholder')}
|
||||
/>
|
||||
</Label>
|
||||
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
{t('settings.dav.create')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passwords.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
{t('settings.dav.none')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{passwords.map((entry) => {
|
||||
const revoked = !!entry.revokedAt;
|
||||
const lastUsed = entry.lastUsedAt
|
||||
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-center gap-3 rounded-lg border p-3 ${
|
||||
revoked
|
||||
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
|
||||
: 'border-duck-dark/15 dark:border-foreground/15'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="h-4 w-4 shrink-0 text-duck-teal/70" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-duck-dark dark:text-foreground">
|
||||
{entry.label}
|
||||
{revoked && <span className="ml-2 text-xs text-red-500">revoked</span>}
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
<code className="font-mono">{entry.hint}…</code>
|
||||
{' · '}
|
||||
{/* "never used" is the tell that a device was set up wrong, so it earns its own wording. */}
|
||||
{lastUsed ? `last used ${lastUsed}` : 'never used'}
|
||||
</div>
|
||||
</div>
|
||||
{!revoked && (
|
||||
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(entry.id)}
|
||||
title={t('settings.dav.delete')}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection }
|
||||
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
|
||||
import { ApifyConfig } from './ApifyConfig';
|
||||
import { DavAppPasswords } from './DavAppPasswords';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const BASE_PATH = '/settings/integrations';
|
||||
@@ -43,13 +42,9 @@ const personalSections: SettingsSection[] = [
|
||||
// descriptionKey: 'settings.sections.apify.description',
|
||||
// content: <GoogleAccount />,
|
||||
// },
|
||||
{
|
||||
key: 'dav-app-passwords',
|
||||
icon: CalendarDays,
|
||||
titleKey: 'settings.sections.davSync.label',
|
||||
descriptionKey: 'settings.sections.davSync.description',
|
||||
content: <DavAppPasswords />,
|
||||
},
|
||||
// DAV app passwords left with the caldav plugin on 2026-08-27. A plugin cannot contribute a settings
|
||||
// section — it ships panels — so the form is a modal on the Calendar screen, the same answer the email
|
||||
// plugin reached for account management when it was extracted.
|
||||
];
|
||||
|
||||
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
|
||||
|
||||
@@ -11,8 +11,6 @@ export * from './Skills';
|
||||
export * from './Tasks';
|
||||
|
||||
export * from './Files';
|
||||
export * from './Calendar';
|
||||
export * from './Contacts';
|
||||
export * from './CommandHQ';
|
||||
export * from './SystemMonitor';
|
||||
export * from './Activity';
|
||||
@@ -24,3 +22,4 @@ export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './SendOff';
|
||||
export * from './Todos';
|
||||
export * from './Bots';
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Dateien",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Files",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Archivos",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Trabajos",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Fichiers",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Éditeur",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Ficheiros",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Trabalhos",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -463,6 +463,7 @@
|
||||
"files": "Filer",
|
||||
"chat": "Chatt",
|
||||
"commandHq": "Command HQ",
|
||||
"bots": "Bots",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobb",
|
||||
"terminal": "Terminal",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
|
||||
describing a different codebase.
|
||||
|
||||
```
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
|
||||
│ ├── schema.ts # what db:push creates — see below
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||
│ └── <feature>/
|
||||
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||
│ ├── schema.ts # its tables
|
||||
│ └── queries.ts # everything that reads or writes them
|
||||
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||
```
|
||||
|
||||
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||
query function is one file in one directory, rather than that file plus a hand-written list of every
|
||||
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
|
||||
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
|
||||
|
||||
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
|
||||
`queries/` trees, where the two sides had drifted: four features were named differently on each side
|
||||
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
|
||||
query file at all, and `integrations` had no schema file.
|
||||
|
||||
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
|
||||
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
|
||||
Task Logs feature — see below.)
|
||||
|
||||
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
|
||||
commented line there removes a table from the DATABASE without removing a line of code — every query
|
||||
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
|
||||
create only the core tables, with the plugin ones commented out until their plugin is installed.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
|
||||
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
|
||||
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
|
||||
as the source of truth, never those files.
|
||||
|
||||
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
|
||||
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
|
||||
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
|
||||
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
|
||||
them was gone, because nothing structural said they had become illegal.
|
||||
|
||||
```ts
|
||||
const SERVER_PROVIDERS = ['google', 'apify'] as const;
|
||||
|
||||
check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(
|
||||
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
database has drifted, instead of quietly accepting it. Clean the rows, then push.
|
||||
|
||||
### Composite keys: use `uniqueIndex`, and name any long foreign key
|
||||
|
||||
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
|
||||
|
||||
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
|
||||
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
|
||||
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
|
||||
longer version and re-creates the constraint on every push.
|
||||
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
|
||||
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
|
||||
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
The six, and where each lives:
|
||||
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
|
||||
nothing — whatever is being re-created gets re-created next push regardless.
|
||||
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
|
||||
uniqueness rule would break the upserts that depend on it.
|
||||
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
|
||||
branch it takes at a truncate prompt.
|
||||
4. **Read the plan before applying it:**
|
||||
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
|
||||
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
|
||||
all. Anything else is your change.
|
||||
|
||||
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
|
||||
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
|
||||
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
|
||||
|
||||
## Type naming
|
||||
|
||||
```ts
|
||||
// types.ts — inferred from the schema, never hand-written
|
||||
export type UserSelect = typeof Schema.users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.users.$inferInsert;
|
||||
|
||||
// The bare name is the hydrated shape, when a table has relations worth carrying
|
||||
export type User = UserSelect & {
|
||||
passkeys: PasskeySelect[];
|
||||
};
|
||||
```
|
||||
|
||||
Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
## Queries
|
||||
|
||||
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||
types from `../types`:
|
||||
|
||||
```ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys } from '../schema';
|
||||
import type { UserSelect } from '../types';
|
||||
|
||||
export async function getUsers(): Promise<UserSelect[]> { … }
|
||||
```
|
||||
|
||||
Every query is exported from `src/index.ts`, which is the only surface callers use.
|
||||
|
||||
## Importing
|
||||
|
||||
```ts
|
||||
// ✅ types from the types subpath
|
||||
import type { User } from 'officerdb/types';
|
||||
// ✅ queries, schema and drizzle helpers from the package root
|
||||
import { getUserById, eq } from 'officerdb';
|
||||
|
||||
// ❌ never reach into src/schema or src/queries directly
|
||||
// ❌ never hand-write a type a table can infer
|
||||
```
|
||||
|
||||
In app code import from `'types'`, which re-exports the database types. Only server and database code
|
||||
imports `officerdb/types` directly.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
|
||||
(`chat_session_events` is the example — its id is also the replay cursor).
|
||||
- `timestamp('…', { withTimezone: true })`, always.
|
||||
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
|
||||
than cast.
|
||||
+3
-228
@@ -1,231 +1,6 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
Drizzle ORM. There is no second database and no cache database — anything that used to say otherwise was
|
||||
describing a different codebase.
|
||||
|
||||
```
|
||||
src/databases/officer_db/
|
||||
├── src/
|
||||
│ ├── db.ts # the connection
|
||||
│ ├── index.ts # public surface: one `export * from './<feature>'` per line
|
||||
│ ├── schema.ts # what db:push creates — see below
|
||||
│ ├── types.ts # every type export (Select / Insert / extended)
|
||||
│ ├── crypto.ts # at-rest encryption, one key per purpose
|
||||
│ ├── secret-store.ts # the key store itself (SQLite, outside Postgres)
|
||||
│ └── <feature>/
|
||||
│ ├── index.ts # what this feature exports — declared here, not in a list three levels up
|
||||
│ ├── schema.ts # its tables
|
||||
│ └── queries.ts # everything that reads or writes them
|
||||
└── package.json # exports ".", "./types", "./db", "./schema", "./secret-store", "./*"
|
||||
```
|
||||
|
||||
**A feature owns its own public surface.** `src/index.ts` is one `export *` per feature and nothing
|
||||
else; what a feature exports is declared in its own `index.ts`, beside the code it describes. Adding a
|
||||
query function is one file in one directory, rather than that file plus a hand-written list of every
|
||||
symbol in the package. That list was 297 lines until 2026-08-13 and it had already drifted — twelve
|
||||
features listed twice, `db` and `schema` buried at line 270 with three feature blocks after them.
|
||||
|
||||
**One directory per feature, holding both halves.** Restructured 2026-08-13 from parallel `schema/` and
|
||||
`queries/` trees, where the two sides had drifted: four features were named differently on each side
|
||||
(`app-store`/`sidecar-installs`, `email`/`email-accounts`, `server`/`server-config`), `operations` had no
|
||||
query file at all, and `integrations` had no schema file.
|
||||
|
||||
One directory is still lopsided and says so by its contents: `integrations/` has only queries, because it
|
||||
spans `server` and `user-data`. (`operations/` was the other, and was deleted on 2026-08-13 along with the
|
||||
Task Logs feature — see below.)
|
||||
|
||||
**`src/schema.ts` is drizzle-kit's view, not the runtime's.** `drizzle.config.ts` points at it, so a
|
||||
commented line there removes a table from the DATABASE without removing a line of code — every query
|
||||
imports its tables from `./schema` inside its own feature directory. That is what lets a fresh install
|
||||
create only the core tables, with the plugin ones commented out until their plugin is installed.
|
||||
|
||||
## Schema changes use `push`, not migrations
|
||||
|
||||
`bun db:push` diffs the schema code against the live database and alters it directly. **`drizzle-kit
|
||||
migrate` has never been run here** — there is no `__drizzle_migrations` table. Change the schema, run
|
||||
push, done. `bun db:gen` writes files to `migrations/`, but nothing applies them; treat the schema code
|
||||
as the source of truth, never those files.
|
||||
|
||||
**The schema is the source of truth for what the database may contain**, not just for its shape. Where a
|
||||
column has a known set of legal values, say so with a `check()` rather than leaving it free `text` —
|
||||
otherwise a value the code stopped supporting sits there unnoticed. `serverIntegrations.provider` is the
|
||||
worked example: rows for deleted chat integrations kept their bot tokens long after the code that read
|
||||
them was gone, because nothing structural said they had become illegal.
|
||||
|
||||
```ts
|
||||
const SERVER_PROVIDERS = ['google', 'apify'] as const;
|
||||
|
||||
check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(
|
||||
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
database has drifted, instead of quietly accepting it. Clean the rows, then push.
|
||||
|
||||
### Composite keys: use `uniqueIndex`, and name any long foreign key
|
||||
|
||||
**Declare a multi-column uniqueness rule as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`.**
|
||||
|
||||
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back from the database, compares
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
something your change caused. Single-column `.unique()` is diffed correctly and is unaffected — only the
|
||||
table-level `unique('name').on(...)` form. Unique indexes are diffed on a different code path and are
|
||||
stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
truncates identifiers at 63 and stores the shortened form, so drizzle keeps comparing against its own
|
||||
longer version and re-creates the constraint on every push.
|
||||
`user_integrations_server_integration_id_server_integrations_id_fk` was 65, and is now declared with
|
||||
`foreignKey({ name: 'fk_user_integrations_server_integration', … })`.
|
||||
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
The six, and where each lives:
|
||||
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
1. **Never answer "Yes, truncate the table"** if a prompt ever does appear. It destroys rows and fixes
|
||||
nothing — whatever is being re-created gets re-created next push regardless.
|
||||
2. **Never delete a constraint from the schema to quiet a diff.** The schema is right. Removing a
|
||||
uniqueness rule would break the upserts that depend on it.
|
||||
3. **Do not use `--force`.** It auto-accepts data-loss statements and nobody has established which
|
||||
branch it takes at a truncate prompt.
|
||||
4. **Read the plan before applying it:**
|
||||
`bunx drizzle-kit push --config=drizzle.config.ts --verbose` prints every statement first. Expect the
|
||||
two `pk_music_now_playing` lines and the eight from the four over-long foreign keys above — ten in
|
||||
all. Anything else is your change.
|
||||
|
||||
To test a schema change without touching `officer_dev`: `createdb officer_scratch`, then
|
||||
`POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:5432/officer_scratch bunx drizzle-kit push
|
||||
--config=drizzle.config.ts`. Push twice — the second run tells you whether your declaration is stable.
|
||||
|
||||
## Type naming
|
||||
|
||||
```ts
|
||||
// types.ts — inferred from the schema, never hand-written
|
||||
export type UserSelect = typeof Schema.users.$inferSelect;
|
||||
export type UserInsert = typeof Schema.users.$inferInsert;
|
||||
|
||||
// The bare name is the hydrated shape, when a table has relations worth carrying
|
||||
export type User = UserSelect & {
|
||||
passkeys: PasskeySelect[];
|
||||
};
|
||||
```
|
||||
|
||||
Organise `types.ts` by domain with section comments, mirroring the schema files.
|
||||
|
||||
## Queries
|
||||
|
||||
Hand-written, one `queries.ts` per feature directory, importing tables from `./schema` beside it and
|
||||
types from `../types`:
|
||||
|
||||
```ts
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users, passkeys } from '../schema';
|
||||
import type { UserSelect } from '../types';
|
||||
|
||||
export async function getUsers(): Promise<UserSelect[]> { … }
|
||||
```
|
||||
|
||||
Every query is exported from `src/index.ts`, which is the only surface callers use.
|
||||
|
||||
## Importing
|
||||
|
||||
```ts
|
||||
// ✅ types from the types subpath
|
||||
import type { User } from 'officerdb/types';
|
||||
// ✅ queries, schema and drizzle helpers from the package root
|
||||
import { getUserById, eq } from 'officerdb';
|
||||
|
||||
// ❌ never reach into src/schema or src/queries directly
|
||||
// ❌ never hand-write a type a table can infer
|
||||
```
|
||||
|
||||
In app code import from `'types'`, which re-exports the database types. Only server and database code
|
||||
imports `officerdb/types` directly.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `serial` for ids on small tables; `bigserial` with `mode: 'number'` where the row count is unbounded
|
||||
(`chat_session_events` is the example — its id is also the replay cursor).
|
||||
- `timestamp('…', { withTimezone: true })`, always.
|
||||
- Index anything you filter on. Drizzle-inferred types reflect nullability correctly, so guard rather
|
||||
than cast.
|
||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
@@ -17,6 +17,8 @@ export type CatalogueEntry = {
|
||||
platform: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
/** What it is for. Always present, `[]` for a plugin that declares none, so callers need no guard. */
|
||||
tags: string[];
|
||||
color: string;
|
||||
tile?: 'badge' | 'bare';
|
||||
iconUrl?: string;
|
||||
@@ -53,6 +55,9 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
|
||||
platform: m.platform,
|
||||
label: m.label,
|
||||
summary: m.summary,
|
||||
// Normalised to an array here rather than at every call site: the column is nullable because it
|
||||
// arrived after the rows did, and null vs [] is a distinction no consumer of this wants to carry.
|
||||
tags: Array.isArray(m.tags) ? (m.tags as string[]) : [],
|
||||
color: m.color ?? '#94a3b8',
|
||||
...(m.tile ? { tile: m.tile as 'badge' | 'bare' } : {}),
|
||||
...(m.iconUrl ? { iconUrl: m.iconUrl } : {}),
|
||||
|
||||
@@ -85,6 +85,20 @@ export const pluginMetadata = pgTable(
|
||||
/** A lucide glyph NAME, for a plugin that ships no artwork. `iconUrl` wins where both are given. */
|
||||
icon: text('icon'),
|
||||
|
||||
/**
|
||||
* What the plugin is for — the store's filter, and the only field here that exists for BROWSING
|
||||
* rather than for installing.
|
||||
*
|
||||
* jsonb like the shapes below it, and for the same reason: it is read as a unit and rendered as a
|
||||
* unit. A join table would buy filtering in SQL, which matters at a scale a hand-curated catalogue of
|
||||
* twenty-one is nowhere near — and it would cost a migration every time the vocabulary changes, which
|
||||
* is exactly the thing expected to change.
|
||||
*
|
||||
* Nullable because it arrived after the rows did. Null and `[]` mean the same thing here: no filter
|
||||
* shows it.
|
||||
*/
|
||||
tags: jsonb('tags'),
|
||||
|
||||
permissions: jsonb('permissions').notNull(),
|
||||
osDependencies: jsonb('os_dependencies'),
|
||||
/** `{ api, schema, sidecar, web }` — what the plugin ships. */
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export {
|
||||
listDavAppPasswords,
|
||||
createDavAppPassword,
|
||||
revokeDavAppPassword,
|
||||
deleteDavAppPassword,
|
||||
verifyDavAppPassword,
|
||||
} from './queries';
|
||||
|
||||
export type { DavAppPassword, DavAppPasswordView } from './queries';
|
||||
@@ -1,113 +0,0 @@
|
||||
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||
import argon2 from 'argon2';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { db } from '../db';
|
||||
import { davAppPasswords } from './schema';
|
||||
|
||||
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
||||
|
||||
/** What the UI is allowed to see: everything except the hash. */
|
||||
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
|
||||
|
||||
const view = (row: DavAppPassword): DavAppPasswordView => {
|
||||
const { passwordHash: _hash, ...rest } = row;
|
||||
return rest;
|
||||
};
|
||||
|
||||
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
|
||||
// into a phone by hand. Grouped into blocks of four for the same reason.
|
||||
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
|
||||
function generateSecret(): string {
|
||||
const bytes = randomBytes(20);
|
||||
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
|
||||
// still fits in five readable blocks.
|
||||
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
|
||||
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
|
||||
}
|
||||
|
||||
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(eq(davAppPasswords.userId, userId))
|
||||
.orderBy(desc(davAppPasswords.createdAt));
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
|
||||
* value is the only chance the owner gets to see it.
|
||||
*/
|
||||
export async function createDavAppPassword(
|
||||
userId: number,
|
||||
label: string,
|
||||
): Promise<{ entry: DavAppPasswordView; password: string }> {
|
||||
const password = generateSecret();
|
||||
const [row] = await db
|
||||
.insert(davAppPasswords)
|
||||
.values({
|
||||
userId,
|
||||
label,
|
||||
passwordHash: await argon2.hash(password),
|
||||
hint: password.slice(0, 8),
|
||||
})
|
||||
.returning();
|
||||
if (!row) throw new Error('failed to create dav app password');
|
||||
return { entry: view(row), password };
|
||||
}
|
||||
|
||||
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.update(davAppPasswords)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.delete(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
|
||||
.returning();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Basic-auth credential, returning the owning user id.
|
||||
*
|
||||
* Every live credential is tried, because the username a DAV client sends is the account email, not a
|
||||
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
|
||||
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
|
||||
* UI should encourage deleting devices that are gone rather than accumulating them.
|
||||
*
|
||||
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
|
||||
* the column exists to answer "is this device still around", not to be an access log.
|
||||
*/
|
||||
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(davAppPasswords)
|
||||
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
|
||||
|
||||
for (const row of rows) {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await argon2.verify(row.passwordHash, password);
|
||||
} catch {
|
||||
ok = false; // a corrupt hash must not take the whole login path down
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
const now = Date.now();
|
||||
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
|
||||
await db
|
||||
.update(davAppPasswords)
|
||||
.set({ lastUsedAt: new Date(now) })
|
||||
.where(eq(davAppPasswords.id, row.id));
|
||||
}
|
||||
return row.userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
|
||||
//
|
||||
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
|
||||
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
|
||||
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
|
||||
// credential a phone could carry is the account password itself — which then lives in that phone's
|
||||
// account manager in recoverable form, and in whatever backs the phone up.
|
||||
//
|
||||
// So: one password per device, revocable per device, hashed with argon2 like the account password and
|
||||
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
|
||||
//
|
||||
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
|
||||
// this table — an app password is not a session, and a device that syncs a calendar has no business
|
||||
// reaching the wallet or the vault.
|
||||
//
|
||||
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
|
||||
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
|
||||
// noticeable at all.
|
||||
export const davAppPasswords = pgTable(
|
||||
'dav_app_passwords',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
|
||||
label: text('label').notNull(),
|
||||
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
/**
|
||||
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
|
||||
* authenticate with, and it is the only way for the owner to match a row in this list against the
|
||||
* password saved on a device they are looking at.
|
||||
*/
|
||||
hint: text('hint').notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
|
||||
// being free and it being a sequential scan on every PROPFIND.
|
||||
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
|
||||
);
|
||||
@@ -52,7 +52,6 @@ export * from './user-data';
|
||||
|
||||
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||
|
||||
export * from './dav';
|
||||
export * from './notify';
|
||||
|
||||
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
|
||||
|
||||
@@ -51,9 +51,11 @@ export * from './service-connections/schema'; // service_connections
|
||||
export * from './notify/schema'; // push_devices — core since 2026-08-19, see docs/push-notifications.md
|
||||
export * from './nudges/schema'; // nudges — the Nudger schedule
|
||||
|
||||
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
|
||||
|
||||
export * from './dav/schema'; // dav_app_passwords officer-caldav
|
||||
// ── Plugins — nothing here ───────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `dav_app_passwords` was the last entry and it left with caldav on 2026-08-27. A plugin's tables are
|
||||
// generated into `drizzle-schema.ts` from the plugin's own `db/schema.ts` when it is installed, which is
|
||||
// what this hand-maintained line was standing in for while caldav was core.
|
||||
|
||||
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { writeAgentBrief } from './agent-brief';
|
||||
|
||||
/**
|
||||
* Both harnesses have to find the brief.
|
||||
*
|
||||
* Claude Code discovers `CLAUDE.md`, opencode discovers `AGENTS.md` and nothing else. Every brief the
|
||||
* platform wrote was `CLAUDE.md`, so under opencode a commander, a bot and a plugin chat were each
|
||||
* briefed with nothing — silently, which is why it lasted.
|
||||
*/
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'agent-brief-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const dir = (name: string) => {
|
||||
const d = join(root, name);
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
Bun.spawnSync(['mkdir', '-p', d]);
|
||||
return d;
|
||||
};
|
||||
|
||||
describe('writeAgentBrief', () => {
|
||||
test('writes the content as AGENTS.md and a POINTER as CLAUDE.md', async () => {
|
||||
const d = dir('both');
|
||||
await writeAgentBrief(d, '# Brief\n\nthe real instructions\n');
|
||||
|
||||
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('the real instructions');
|
||||
const pointer = readFileSync(join(d, 'CLAUDE.md'), 'utf8');
|
||||
expect(pointer).toContain('AGENTS.md');
|
||||
// The pointer must NOT be a second copy — two copies of a file rewritten every turn will disagree,
|
||||
// and the stale one is the one somebody reads.
|
||||
expect(pointer).not.toContain('the real instructions');
|
||||
});
|
||||
|
||||
test('is idempotent — an unchanged brief writes nothing', async () => {
|
||||
const d = dir('idempotent');
|
||||
expect(await writeAgentBrief(d, 'same\n')).toBe(true);
|
||||
expect(await writeAgentBrief(d, 'same\n')).toBe(false);
|
||||
});
|
||||
|
||||
test('rewrites when the content changes, because that is how a template reaches a bot', async () => {
|
||||
const d = dir('rewrite');
|
||||
await writeAgentBrief(d, 'v1\n');
|
||||
expect(await writeAgentBrief(d, 'v2\n')).toBe(true);
|
||||
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toBe('v2\n');
|
||||
});
|
||||
|
||||
test('onlyIfAbsent never touches a brief the agent has extended', async () => {
|
||||
// A commander writes its own job into this file over days. Rewriting it because the template
|
||||
// changed would delete what it agreed with the owner.
|
||||
const d = dir('extended');
|
||||
await writeAgentBrief(d, 'generated\n');
|
||||
writeFileSync(join(d, 'AGENTS.md'), 'generated\n\n## JOB\n\nwatch the thing\n');
|
||||
|
||||
expect(await writeAgentBrief(d, 'a new template\n', { onlyIfAbsent: true })).toBe(false);
|
||||
expect(readFileSync(join(d, 'AGENTS.md'), 'utf8')).toContain('watch the thing');
|
||||
});
|
||||
|
||||
test('onlyIfAbsent still creates both files the first time', async () => {
|
||||
const d = dir('first');
|
||||
expect(await writeAgentBrief(d, 'generated\n', { onlyIfAbsent: true })).toBe(true);
|
||||
expect(existsSync(join(d, 'AGENTS.md'))).toBe(true);
|
||||
expect(existsSync(join(d, 'CLAUDE.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { writeAtomic } from './write-atomic';
|
||||
|
||||
/**
|
||||
* Writing a brief where BOTH harnesses will find it.
|
||||
*
|
||||
* ── The bug this exists to remove ──
|
||||
*
|
||||
* Claude Code discovers `CLAUDE.md` by walking up from its working directory. opencode discovers
|
||||
* `AGENTS.md` and nothing else — verified in the binary, whose only target is
|
||||
* `H.up({ targets: ["AGENTS.md"], start, stop })`. There is no overlap.
|
||||
*
|
||||
* Every brief the platform writes was named `CLAUDE.md`, so under opencode a commander got none of its
|
||||
* standing brief, a bot none of its arming instructions, and a plugin chat none of its plugin's
|
||||
* guidance. Nothing failed and nothing was logged: the agent simply knew nothing, which is
|
||||
* indistinguishable from the feature not existing and is why it survived this long.
|
||||
*
|
||||
* ── The shape, and why the pointer rather than two copies ──
|
||||
*
|
||||
* `AGENTS.md` holds the content; `CLAUDE.md` is six lines pointing at it. That is the same arrangement
|
||||
* the repository's own docs use, and it is chosen over writing the text twice for one reason: two copies
|
||||
* of a file that is REWRITTEN ON EVERY TURN will eventually disagree, and the one that is stale is the
|
||||
* one somebody reads.
|
||||
*
|
||||
* A harness that discovers neither name still gets nothing, and that is honest — it is a fact about the
|
||||
* harness rather than something this can paper over.
|
||||
*/
|
||||
|
||||
const POINTER = `# CLAUDE.md
|
||||
|
||||
**The instructions live in [\`AGENTS.md\`](AGENTS.md). Read it first — this file only points there.**
|
||||
|
||||
Written by the platform so that agents looking for \`AGENTS.md\` by convention and agents looking for
|
||||
\`CLAUDE.md\` find the same guidance, without the two drifting apart.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Put `content` in `dir` under both names.
|
||||
*
|
||||
* Content-diffed, so a directory's mtimes do not churn on every turn — these are rewritten per message
|
||||
* in the bots' case. Returns false when nothing was written.
|
||||
*
|
||||
* Best effort by contract: every caller must still work if this throws. An agent that knows less is
|
||||
* where all of them were before any of this existed.
|
||||
*/
|
||||
export async function writeAgentBrief(
|
||||
dir: string,
|
||||
content: string,
|
||||
/**
|
||||
* Never overwrite an existing `AGENTS.md`.
|
||||
*
|
||||
* For a brief the AGENT extends rather than one the platform owns. A commander's file is generated
|
||||
* once at creation and then edited by the commander over its whole life — rewriting it because the
|
||||
* template changed would silently delete the job it had agreed. The platform's own briefs (bots,
|
||||
* plugin chats) are the opposite: rewritten every turn ON PURPOSE, so a change reaches them.
|
||||
*/
|
||||
opts: { onlyIfAbsent?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
let wrote = false;
|
||||
|
||||
const agents = join(dir, 'AGENTS.md');
|
||||
if (opts.onlyIfAbsent && existsSync(agents)) return false;
|
||||
if (!existsSync(agents) || (await Bun.file(agents).text()) !== content) {
|
||||
await writeAtomic(agents, content);
|
||||
wrote = true;
|
||||
}
|
||||
|
||||
const claude = join(dir, 'CLAUDE.md');
|
||||
if (!existsSync(claude) || (await Bun.file(claude).text()) !== POINTER) {
|
||||
await writeAtomic(claude, POINTER);
|
||||
wrote = true;
|
||||
}
|
||||
|
||||
return wrote;
|
||||
}
|
||||
|
||||
/** The pointer's text, for a caller that generates its own file listing and has to describe both. */
|
||||
export const AGENT_BRIEF_POINTER = POINTER;
|
||||
@@ -0,0 +1,204 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { getBotsDir } from '@@/data-path';
|
||||
import { writeAgentBrief } from '@@/agent-brief';
|
||||
|
||||
/**
|
||||
* The instructions every bot runs under.
|
||||
*
|
||||
* Written into `$DATA_PATH/<email>/bots/CLAUDE.md`, which Claude Code reads from its working directory at
|
||||
* session start. Rewritten on every bot turn, so a change here reaches every bot on the next message with
|
||||
* nothing to migrate — the same arrangement as a plugin's chat brief.
|
||||
*
|
||||
* ── Why this file is short, and a commander's is not ──
|
||||
*
|
||||
* A commander is a job: it has procedures, a ledger, classification rules, a dispatch policy, and a setup
|
||||
* conversation to settle all of it. A bot is one sentence and a watch. Everything a commander's template
|
||||
* spends words on — proving the action on one item, arming only once the owner agrees, writing JOB.md —
|
||||
* is ceremony a bot does not have. The whole point is that the first message arms it.
|
||||
*/
|
||||
const BRIEF = `# Bots
|
||||
|
||||
Every conversation in this directory is a **bot**: one thing watched, one thing done when it changes.
|
||||
|
||||
**This file is the platform's and is rewritten on every turn.** Anything you want to keep goes in a file
|
||||
you create beside it.
|
||||
|
||||
## Arm on the first message. Do not interview.
|
||||
|
||||
The owner says what they want in one sentence. Work out what it means and **arm it in that same turn**.
|
||||
|
||||
> "notify me when x.com is down"
|
||||
> "tell the todo guy when nytimes.com has a new headline"
|
||||
|
||||
That is the whole input. Both are complete instructions and neither wants a conversation about it.
|
||||
|
||||
**Do not ask any of these.** Every one has a default below: how often, which selector, what counts as down,
|
||||
how many failures before it counts, what to do if the check itself errors, whether to notify on recovery.
|
||||
Pick the default, arm it, and say in ONE line what you armed and how often it checks. The owner corrects
|
||||
you in the next message if you guessed wrong, which is cheaper for them than answering six questions first.
|
||||
|
||||
**Ask only when you genuinely cannot proceed** — no address at all, or a request so vague that any watch
|
||||
you built would be a guess about the subject rather than about the details. "Watch my site" with no site
|
||||
is a question. "notify me when x.com is down" is not.
|
||||
|
||||
**Never ask the owner to phrase it differently.** They will not say "notify" or "nudge" and must not have
|
||||
to. "tell me", "let me know", "ping me", "message the todo guy" all mean the same two things below.
|
||||
|
||||
## Defaults — use these instead of asking
|
||||
|
||||
- **Interval: one minute.** Unless the owner says otherwise. It is cheap: the check is shell, not a turn.
|
||||
Use longer only when the source asks for it — a page that rate-limits, or something that changes daily.
|
||||
- **"Down" means** the site fails to answer, or answers 5xx, **twice in a row**. One failed request is a
|
||||
blip and firing on it produces a bot nobody trusts. Follow redirects. A slow answer is not down; a
|
||||
timeout of about ten seconds is.
|
||||
- **"Has a new headline" / "has changed" means** the thing you extracted differs from what you stored last
|
||||
time. Extract the smallest stable piece — the top headline's text, not the whole page, which changes on
|
||||
every ad and would fire constantly.
|
||||
- **Recovery is worth one message**, once, when something that was down comes back. Nothing else.
|
||||
- **The first run establishes the baseline** and does not fire. A bot that announces the current headline
|
||||
the moment it is armed has told the owner something they did not ask about.
|
||||
|
||||
## Arming — the whole mechanism, so you do not go looking for it
|
||||
|
||||
**Do not read the commander skill.** It is about spawning agents in a loop and none of it applies here.
|
||||
This is everything a bot needs:
|
||||
|
||||
1. **Write the check as a shell script in THIS directory**, named after the bot — \`newweb-uptime.sh\`, not
|
||||
somewhere under \`/tmp\`. It has to survive a reboot, and \`/tmp\` does not.
|
||||
2. **Run it with the Bash tool, \`run_in_background: true\`.** That is the arming. You will be handed back
|
||||
when it exits.
|
||||
3. **The script waits in the shell and exits when the condition is true**, printing one line saying what
|
||||
happened. The exit IS the signal — nothing else tells anyone it fired.
|
||||
|
||||
The shape, which almost every bot is a variation of:
|
||||
|
||||
\`\`\`bash
|
||||
#!/bin/bash
|
||||
# state lives beside this script, so a restart resumes rather than re-baselines
|
||||
STATE="$(dirname "$0")/newweb-uptime.state"
|
||||
fails=0
|
||||
while true; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 10 "https://example.com/" || echo 000)
|
||||
if [ "$code" = "000" ] || [ "$code" -ge 500 ]; then
|
||||
fails=$((fails + 1))
|
||||
# twice in a row, never once — a single failed request is a blip
|
||||
if [ "$fails" -ge 2 ]; then echo "DOWN http=$code"; exit 0; fi
|
||||
else
|
||||
fails=0
|
||||
fi
|
||||
sleep 60
|
||||
done
|
||||
\`\`\`
|
||||
|
||||
For a "has it changed" watch, the same shape with the comparison against \`$STATE\` instead of a counter:
|
||||
fetch, extract the smallest stable piece, compare, and \`exit 0\` printing the new value when it differs.
|
||||
Write the new value to \`$STATE\` **before** exiting, or the next arming fires on the same change again.
|
||||
|
||||
4. **Record what you armed, in \`<your-session-id>.bot.json\` in this directory.** This is not optional and
|
||||
it is not bookkeeping — it is the only thing that can stop your watch. Nothing else knows your pid, and
|
||||
a bot that is deleted while its record is missing leaves a process polling a website for ever.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"sessionId": "6305e570-f26a-4989-a0ae-2135c1076505",
|
||||
"watches": "newweb.fofosfluffybastards.com, every 60s, notify the owner when down",
|
||||
"script": "/abs/path/to/newweb-uptime.sh",
|
||||
"state": "/abs/path/to/newweb-uptime.state",
|
||||
"pids": [1316235],
|
||||
"armedAt": "2026-08-26T07:33:00Z"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Rewrite it every time you arm**, with the new pid. \`pids\` is an array because a re-arm can briefly
|
||||
overlap; list every process you started that is still meant to be running.
|
||||
|
||||
**To find your own session id**, list the bots and match on the title:
|
||||
|
||||
\`\`\`bash
|
||||
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
|
||||
"$OFFICER_API_URL/api/chat/sessions?context=bots"
|
||||
\`\`\`
|
||||
|
||||
Claude titles a session from its first message, so on the turn that arms you the entry titled with the
|
||||
owner's own opening sentence is you. That is only reliable BEFORE you rename yourself — which is the
|
||||
next step, so do them in this order: find your id, write the record, then rename.
|
||||
|
||||
5. **Give yourself a real name.** The automatic title is the owner's first sentence, truncated —
|
||||
\`"can you notifiy me when https://newweb.fofos…"\`, typos and all. It is a prompt, not a name, and it is
|
||||
what they will scan a list of bots for later.
|
||||
|
||||
\`\`\`bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \\
|
||||
-H 'content-type: application/json' -d '{"title":"newweb uptime"}' \\
|
||||
"$OFFICER_API_URL/api/chat/sessions/<your-session-id>/title"
|
||||
\`\`\`
|
||||
|
||||
Two to four words, the SUBJECT and what is watched about it: \`newweb uptime\`, \`NYT front page\`,
|
||||
\`gitea disk space\`. Not "Website Monitor Bot" — every bot is a monitor and the word earns nothing in a
|
||||
list of them. Lower case unless the subject is a proper noun. Rename again if the owner widens what you
|
||||
watch and the old name stops being true.
|
||||
|
||||
6. **When you are handed back**, do what the owner asked, then **re-arm the same script the same way,
|
||||
before you end the turn**, and rewrite the record. Act, re-arm, record — in the one turn. Never build a
|
||||
second loop on top of this.
|
||||
|
||||
7. **If you are woken and told your watch is gone**, read your own \`.bot.json\`, arm the script it names
|
||||
again, and rewrite it. That file is what survives a reboot; the conversation may be days behind.
|
||||
|
||||
Do not go hunting for how any of this works — it is above, and looking costs the owner a minute of tool
|
||||
calls before anything is armed.
|
||||
|
||||
## What to do when it fires
|
||||
|
||||
Two things, and the owner will not name either of them:
|
||||
|
||||
- **Tell the owner.** A notification reaches their phone. Send one when what fired is worth an interruption
|
||||
— a site down, a headline they asked to know about. Not "I checked and nothing happened".
|
||||
- **Tell another chat.** If they named one, deliver it there rather than to the owner.
|
||||
- An \`@Name [id]\` in their message carries the id already. It is a PREFIX — resolve it to the full
|
||||
session before using it.
|
||||
- A name in prose — "the todo guy", "my deploy commander" — is just as valid and is how they will
|
||||
usually say it. Look it up in the chat list and match on the title. If exactly one matches, use it and
|
||||
say which one you picked. If several do, that is worth the one question.
|
||||
|
||||
Then re-arm, and stay quiet until the next fire.
|
||||
|
||||
## Remembering things
|
||||
|
||||
The interesting watches compare against last time — a headline, a hash, a status. Keep that in a file in
|
||||
this directory named after this bot, and say in your first reply what you called it. Do not keep it only in
|
||||
the conversation: you are woken into a turn that may be days later, and the file is what survives.
|
||||
|
||||
## Adding to a bot
|
||||
|
||||
The owner will come back and add more — another site to ping, another page to watch. That is the same bot:
|
||||
widen the check, re-arm it, and confirm in one line what it now covers. Do not start a second watch running
|
||||
beside the first unless the two genuinely need different intervals.
|
||||
|
||||
## What a bot does not do
|
||||
|
||||
It watches and it tells. It does not fix things, deploy things, or change anything it is watching. A
|
||||
request that wants work done is a request for a commander or an ordinary chat — say so plainly and stop
|
||||
rather than quietly becoming one.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Put the brief where a bot will read it, and make sure the directory exists.
|
||||
*
|
||||
* Best effort by contract: a turn must still start if this fails. The bot then knows less, which is where
|
||||
* every chat was before this existed — worse than being briefed, better than not answering.
|
||||
*/
|
||||
export async function ensureBotsBrief(email: string): Promise<string> {
|
||||
const dir = getBotsDir(email);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Both names — a bot run under opencode was getting none of this. See `writeAgentBrief`.
|
||||
await writeAgentBrief(dir, BRIEF);
|
||||
} catch (err) {
|
||||
console.error('[bots] could not write the brief:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getBotsDir } from '@@/data-path';
|
||||
import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
/**
|
||||
* Stopping a bot, and knowing which ones have stopped themselves.
|
||||
*
|
||||
* ── Why a record exists at all ──
|
||||
*
|
||||
* A bot's watch is a detached shell process. Nothing else on the machine knows it belongs to that bot: not
|
||||
* the transcript, not the session list, not the process table, which sees an anonymous `bash` in a
|
||||
* temporary directory. So the bot writes `<sessionId>.bot.json` beside its script, and that file is the
|
||||
* only thing that can connect the two.
|
||||
*
|
||||
* The cost of not having it was paid before it existed: a bot was deleted on 2026-08-26 and its watcher
|
||||
* was still polling a website eleven minutes later, with nothing in the product able to find or stop it.
|
||||
*
|
||||
* ── Why the id is in the FILENAME ──
|
||||
*
|
||||
* Every bot shares one directory, so the record has to be addressable without reading all of them. Deleting
|
||||
* a session knows only its id; `join(dir, id + '.bot.json')` is then a lookup rather than a scan, and a
|
||||
* record whose contents are damaged still gets found and cleaned up.
|
||||
*/
|
||||
|
||||
export type BotRecord = {
|
||||
sessionId?: string;
|
||||
watches?: string;
|
||||
script?: string;
|
||||
state?: string;
|
||||
pids?: number[];
|
||||
armedAt?: string;
|
||||
};
|
||||
|
||||
const recordPath = (email: string, sessionId: string) => join(getBotsDir(email), `${sessionId}.bot.json`);
|
||||
|
||||
/** Refuse anything that is not a bare id — this reaches here from a URL. */
|
||||
const safeId = (sessionId: string) => /^[A-Za-z0-9._-]+$/.test(sessionId) && !sessionId.includes('..');
|
||||
|
||||
export function readBotRecord(email: string, sessionId: string): BotRecord | null {
|
||||
if (!safeId(sessionId)) return null;
|
||||
const path = recordPath(email, sessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as BotRecord;
|
||||
} catch {
|
||||
// Damaged rather than absent. The caller still deletes the file — a record it cannot read is a record
|
||||
// that can never stop anything, and leaving it would only make the next reader believe there is one.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const alive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Every recorded pid still running, which is how "is this bot actually watching" is answered. */
|
||||
export function liveBotPids(email: string, sessionId: string): number[] {
|
||||
const record = readBotRecord(email, sessionId);
|
||||
return (record?.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1 && alive(pid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a bot's watch and forget it.
|
||||
*
|
||||
* Children first, then the recorded process, so the loop cannot spawn a replacement between the two —
|
||||
* the same ordering `stopCommanderProcesses` uses and for the same reason.
|
||||
*
|
||||
* Best effort throughout: a bot whose record is missing, damaged, or names processes that are already
|
||||
* gone still has its files removed. Returns how many processes were signalled, which is worth logging —
|
||||
* "deleted a bot and stopped nothing" is a different event from "deleted a bot that was not armed".
|
||||
*/
|
||||
export function tearDownBot(email: string, sessionId: string): number {
|
||||
const record = readBotRecord(email, sessionId);
|
||||
if (!record) return 0;
|
||||
|
||||
const pids = (record.pids ?? []).filter((pid) => Number.isInteger(pid) && pid > 1);
|
||||
const tree = [...new Set(pids.flatMap((pid) => [...descendantPids(pid), pid]))];
|
||||
|
||||
let signalled = 0;
|
||||
for (const pid of tree) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
signalled += 1;
|
||||
} catch {
|
||||
// Gone between the walk and the signal. That is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
|
||||
// The script and its state go with it. They are the bot's, they are named in its own record, and leaving
|
||||
// them means the next bot with the same idea finds a stale baseline and does not fire.
|
||||
for (const path of [record.script, record.state]) {
|
||||
if (!path) continue;
|
||||
// Only inside the bots directory. A record is written by an agent, and a path out of that tree is
|
||||
// either a mistake or something worse — either way it is not this function's business to delete.
|
||||
if (!path.startsWith(getBotsDir(email))) continue;
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
} catch {
|
||||
// Not worth failing a delete over.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(recordPath(email, sessionId), { force: true });
|
||||
} catch {
|
||||
// Same.
|
||||
}
|
||||
|
||||
return signalled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill anything running out of the bots directory that no live bot claims.
|
||||
*
|
||||
* ── Why a record is not enough on its own ──
|
||||
*
|
||||
* `tearDownBot` needs the bot to have written `<sessionId>.bot.json`. A bot that never did — armed before
|
||||
* the instruction existed, or one that simply did not — cannot be stopped that way, and deleting it leaves
|
||||
* a process polling for ever.
|
||||
*
|
||||
* That is not the worst of it. **The orphan resurrects the bot it belonged to.** Its watch fires, the
|
||||
* harness hands back to the agent, the agent resumes by transcript id, and `claude --resume` RECREATES the
|
||||
* transcript that was deleted — so the bot reappears in the list, untitled, and does it again on the next
|
||||
* fire. Seen on 2026-08-26: a bot deleted three times and back each time. It is the same shape
|
||||
* `22a9d58d` fixed for commanders.
|
||||
*
|
||||
* ── The test ──
|
||||
*
|
||||
* A process is an orphan if it is running a script inside the bots directory and its pid appears in NO
|
||||
* record. Claimed pids are left alone whatever else is true — a live bot's watch is the thing this must
|
||||
* never touch. Anything else in there belongs to a bot that is gone.
|
||||
*
|
||||
* Reads `/proc` rather than shelling out to `ps`: this runs inside the request that deletes a bot, and a
|
||||
* subprocess per delete is a cost with no benefit.
|
||||
*/
|
||||
export function sweepOrphanWatches(email: string): number {
|
||||
const dir = getBotsDir(email);
|
||||
const claimed = new Set(listBotRecords(email).flatMap(({ record }) => record.pids ?? []));
|
||||
|
||||
let killed = 0;
|
||||
let pids: string[];
|
||||
try {
|
||||
pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name));
|
||||
} catch {
|
||||
return 0; // not Linux, or /proc unreadable — the record path still works
|
||||
}
|
||||
|
||||
for (const name of pids) {
|
||||
const pid = Number(name);
|
||||
if (claimed.has(pid)) continue;
|
||||
|
||||
let cmdline: string;
|
||||
try {
|
||||
// NUL-separated argv. A process that exits between the listing and this read is the ordinary case.
|
||||
cmdline = readFileSync(`/proc/${name}/cmdline`, 'utf-8').replace(/\0/g, ' ');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The script has to live in the bots directory — the brief requires it — so its path is in the argv of
|
||||
// anything running it. `.sh` narrows it to the watch itself rather than an editor or a grep over it.
|
||||
if (!cmdline.includes(dir) || !cmdline.includes('.sh')) continue;
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
killed += 1;
|
||||
console.warn(`[bots] orphaned watch ${pid} killed — no record claims it`);
|
||||
} catch {
|
||||
// Gone already, which is the outcome wanted.
|
||||
}
|
||||
}
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
/** Every bot that has a record, for the sweep that revives them. */
|
||||
export function listBotRecords(email: string): { sessionId: string; record: BotRecord }[] {
|
||||
let names: string[];
|
||||
try {
|
||||
names = readdirSync(getBotsDir(email));
|
||||
} catch {
|
||||
return []; // no directory yet is the ordinary state of an account with no bots
|
||||
}
|
||||
|
||||
return names
|
||||
.filter((name) => name.endsWith('.bot.json'))
|
||||
.map((name) => {
|
||||
const sessionId = name.slice(0, -'.bot.json'.length);
|
||||
return { sessionId, record: readBotRecord(email, sessionId) ?? {} };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Context } from 'hono';
|
||||
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
|
||||
|
||||
// One answer to "which directory is this chat about", for the two routers that need it.
|
||||
//
|
||||
// It lived in chat.ts as a private helper until 2026-08-27, when Command HQ grew a button that starts a
|
||||
// commander from a plugin chat. That commander watches the directory the chat runs in — and the whole
|
||||
// reason this function exists is that the CLIENT cannot name that directory. Two copies of the mapping
|
||||
// would be two chances for a commander to end up watching somewhere its chat has never been.
|
||||
|
||||
/**
|
||||
* Which directory a listing is about.
|
||||
*
|
||||
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
|
||||
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
|
||||
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
|
||||
* its own directory and then has no way to ask for them back.
|
||||
*
|
||||
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
|
||||
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
|
||||
* another directory shows you your own conversations elsewhere, never anybody else's.
|
||||
*/
|
||||
/**
|
||||
* The same mapping, from values rather than from a request.
|
||||
*
|
||||
* Command HQ takes the context in a JSON BODY — it is creating a commander, not listing anything — and
|
||||
* the request-reading version silently fell through to `who.home` for it, because there was nothing in
|
||||
* the query string. A commander watching the wrong directory is not an error anywhere: it just answers
|
||||
* questions about the wrong thing forever.
|
||||
*/
|
||||
export const chatContextDir = (
|
||||
context: string | undefined,
|
||||
contextId: string | undefined,
|
||||
who: { home: string; email: string },
|
||||
): string => {
|
||||
const id = contextId?.trim();
|
||||
if (context === 'plugin' && id) return getPluginChatsDir(who.email, id);
|
||||
if (context === 'bots') return getBotsDir(who.email);
|
||||
if (context === 'email') return resolveEmailAccountDir(who.email, id);
|
||||
return who.home;
|
||||
};
|
||||
|
||||
export const resolveChatContextDir = (ctx: Context, who: { home: string; email: string }): string => {
|
||||
const context = ctx.req.query('context');
|
||||
const contextId = ctx.req.query('contextId')?.trim();
|
||||
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
|
||||
// Every bot shares one directory, so this needs no id — the context IS the address.
|
||||
if (context === 'bots') return getBotsDir(who.email);
|
||||
// An email chat runs in the MAILBOX's directory, beside `emails.db`. `contextId` is the account picked on
|
||||
// screen, and is optional: without one the resolver takes the most recently synced account.
|
||||
//
|
||||
// This branch was missing, and the failure was quiet in the way that costs the most time — the context
|
||||
// fell through to `who.home`, so the picker above an email composer listed the home's conversations. A
|
||||
// message sent into the mailbox then could not be found in the list directly above it. Same resolver as
|
||||
// the socket, so the turn and the list cannot disagree about where the chat lives.
|
||||
if (context === 'email') return resolveEmailAccountDir(who.email, contextId);
|
||||
return ctx.req.query('cwd')?.trim() || who.home;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getPluginChatsDir } from '@@/data-path';
|
||||
import { getPluginChatsDir, getBotsDir, resolveEmailAccountDir } from '@@/data-path';
|
||||
import { tearDownBot, sweepOrphanWatches } from '../bots/teardown';
|
||||
import type { Context } from 'hono';
|
||||
import { emitToSession, sessionKeyForTranscript } from './websocket';
|
||||
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
|
||||
@@ -81,7 +82,16 @@ export async function chatIdentity(user: { id: number; email: string }): Promise
|
||||
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
|
||||
// `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
|
||||
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
|
||||
//
|
||||
// ── A THIRD thing now rides on this restriction (2026-08-27) ──
|
||||
//
|
||||
// The serve holds an Officer API credential minted for the OWNER, in its own environment, because one
|
||||
// process serves every session and there is nowhere else to put one — `sidecar/opencode/serve-env.ts`.
|
||||
// So lifting `isOwner` here without changing that hands a member the owner's API authority as well as the
|
||||
// owner's home and Linux account. It is the same blocker, not a new one, but it is easy to miss because it
|
||||
// lives in a sidecar rather than in this file. Whoever does the identity work: all three move together.
|
||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
import { resolveChatContextDir } from './chat-context';
|
||||
|
||||
export const chatRouter = createRouter();
|
||||
|
||||
@@ -108,25 +118,9 @@ export const chatRouter = createRouter();
|
||||
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
|
||||
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
|
||||
// selects there too.
|
||||
/**
|
||||
* Which directory a listing is about.
|
||||
*
|
||||
* `?context=plugin&contextId=<appName>` resolves server-side, and it exists because the client CANNOT
|
||||
* name this one: a plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats`, built by
|
||||
* `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in
|
||||
* its own directory and then has no way to ask for them back.
|
||||
*
|
||||
* The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked.
|
||||
* It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming
|
||||
* another directory shows you your own conversations elsewhere, never anybody else's.
|
||||
*/
|
||||
const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
|
||||
const context = ctx.req.query('context');
|
||||
const contextId = ctx.req.query('contextId')?.trim();
|
||||
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
|
||||
return ctx.req.query('cwd')?.trim() || who.home;
|
||||
};
|
||||
// selects there too. The resolver moved to `chat-context.ts` when Command HQ needed the same answer —
|
||||
// a commander started from a plugin chat watches the directory that chat runs in, and only the server
|
||||
// can turn a context into a path.
|
||||
|
||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||
chatRouter.get('/pwds', async (ctx) => {
|
||||
@@ -139,7 +133,7 @@ chatRouter.get('/pwds', async (ctx) => {
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await chatIdentity(user);
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
|
||||
// `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a
|
||||
// separate route because the response shape is identical and every consumer downstream (rename, delete,
|
||||
@@ -223,7 +217,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await chatIdentity(user);
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
|
||||
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
||||
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
|
||||
@@ -436,7 +430,7 @@ chatRouter.get('/live', async (ctx) => {
|
||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
if (isOpenCodeSessionId(id)) {
|
||||
if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404);
|
||||
@@ -468,6 +462,32 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
conversation — no live session holds it, so nothing is keyed on a key to remove.
|
||||
*/
|
||||
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
|
||||
|
||||
/*
|
||||
And stop anything a BOT armed, which nothing above would have touched.
|
||||
|
||||
A bot's watch is a detached shell process. Killing the harness ends the agent; the loop it started is
|
||||
not its child and carries on. That gap was paid for on 2026-08-26: a bot was deleted and its watcher
|
||||
was still polling a website eleven minutes later, with nothing in the product able to find it.
|
||||
|
||||
Every transcript removed, not just the id asked for — `deleteClaudeSession` returns a chain, and a
|
||||
resumed conversation's earlier transcripts can each carry their own record.
|
||||
|
||||
Unconditional rather than gated on "was this a bot": the record only exists in the bots directory and
|
||||
is keyed by session id, so for anything else this reads a path that is not there and returns 0. A
|
||||
branch on the cwd would be a second place to get the question wrong.
|
||||
*/
|
||||
for (const transcriptId of removed) {
|
||||
const stopped = tearDownBot(who.email, transcriptId);
|
||||
if (stopped > 0) console.log(`[bots] ${transcriptId} deleted — stopped ${stopped} process(es)`);
|
||||
}
|
||||
|
||||
// And anything left running out of the bots directory that no remaining record claims. This is what
|
||||
// catches a bot that never wrote one — which cannot be stopped by id, and whose watch RESURRECTS it on
|
||||
// its next fire, because the hand-back resumes a transcript that `claude --resume` then recreates.
|
||||
const orphaned = sweepOrphanWatches(who.email);
|
||||
if (orphaned > 0) console.log(`[bots] swept ${orphaned} orphaned watch process(es)`);
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -475,7 +495,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who);
|
||||
const cwd = resolveChatContextDir(ctx, who);
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
|
||||
@@ -78,31 +78,45 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
|
||||
const messages: ClaudeChatMessage[] = [];
|
||||
let modelId = '';
|
||||
|
||||
for (const m of stored) {
|
||||
const role = m.info?.role;
|
||||
const parts = m.parts ?? [];
|
||||
/*
|
||||
OLDEST FIRST. The endpoint returns newest first, and nothing downstream re-sorts — so the
|
||||
conversation rendered backwards: the answer above the question that produced it, and the opening
|
||||
message at the bottom. Sorting here rather than at the call site because this is the only place
|
||||
that knows the wire order, and the display shape it returns has no timestamps left to sort by.
|
||||
*/
|
||||
const ordered = [...stored].sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0));
|
||||
|
||||
for (const m of ordered) {
|
||||
// `type` is the current build, `info.role` the older one. See OpenCodeStoredMessage.
|
||||
const role = m.type ?? m.info?.role;
|
||||
const parts = m.content ?? m.parts ?? [];
|
||||
|
||||
if (role === 'user') {
|
||||
const text = parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
.trim();
|
||||
// The current build hands a user message's text over directly; the older one wraps it in parts.
|
||||
const text = (
|
||||
m.text ??
|
||||
parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('')
|
||||
).trim();
|
||||
if (text) messages.push({ role: 'user', text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
if (!modelId) modelId = m.info?.model?.modelID ?? m.info?.modelID ?? '';
|
||||
if (!modelId) modelId = m.model?.id ?? m.model?.modelID ?? m.info?.model?.modelID ?? m.info?.modelID ?? '';
|
||||
for (const p of parts) {
|
||||
if (p.type === 'text' && p.text) {
|
||||
messages.push({ role: 'assistant', id: `${sessionId}-${messages.length}`, text: p.text });
|
||||
} else if (p.type === 'tool' && p.callID) {
|
||||
} else if (p.type === 'tool') {
|
||||
const callId = p.callID ?? p.id;
|
||||
if (!callId) continue;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
toolName: p.tool ?? 'tool',
|
||||
toolName: p.name ?? p.tool ?? 'tool',
|
||||
toolInput: p.state?.input ?? {},
|
||||
toolCallId: p.callID,
|
||||
toolCallId: callId,
|
||||
output: p.state?.output,
|
||||
isError: Boolean(p.state?.error),
|
||||
});
|
||||
@@ -112,6 +126,11 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
|
||||
}
|
||||
}
|
||||
|
||||
// The session record carries the model too, and it is the only source for a conversation whose
|
||||
// assistant turns have not landed yet. Without it the detail reports a bare `opencode`, the composer
|
||||
// cannot match that to anything, and reopening the chat silently switches the harness back to Claude.
|
||||
if (!modelId) modelId = info?.model?.id ?? info?.model?.modelID ?? '';
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
model: modelId ? `opencode/${modelId}` : 'opencode',
|
||||
|
||||
@@ -47,10 +47,22 @@ class ServerConnection {
|
||||
return (await res.json()) as OpenCodeSessionInfo;
|
||||
}
|
||||
|
||||
/*
|
||||
`/api/session/…/message`, NOT `/session/…/message`.
|
||||
|
||||
Both paths exist and only one still works. The legacy one answers 200 with an empty array for a
|
||||
session that plainly has messages — verified 2026-08-27 against a session showing 4 on the `/api`
|
||||
path and 0 on the other. An empty transcript is indistinguishable from a new conversation, so
|
||||
reopening an OpenCode chat showed "Send a message to start" and every message the owner had sent was
|
||||
apparently gone. It also emptied the model, which is what made the composer fall back to Claude.
|
||||
|
||||
Every other read this client does is fine on either path; this is the only one that moved.
|
||||
*/
|
||||
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
|
||||
const res = await fetch(`${this.baseUrl}/session/${sessionId}/message`);
|
||||
if (!res.ok) throw new Error(`opencode GET /session/${sessionId}/message → ${res.status}`);
|
||||
return (await res.json()) as OpenCodeStoredMessage[];
|
||||
const res = await fetch(`${this.baseUrl}/api/session/${sessionId}/message`);
|
||||
if (!res.ok) throw new Error(`opencode GET /api/session/${sessionId}/message → ${res.status}`);
|
||||
const body = (await res.json()) as { data?: OpenCodeStoredMessage[] } | OpenCodeStoredMessage[];
|
||||
return Array.isArray(body) ? body : (body.data ?? []);
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
@@ -89,7 +101,12 @@ class ServerConnection {
|
||||
export type OpenCodeSessionInfo = {
|
||||
id: string;
|
||||
title?: string;
|
||||
time?: { created?: number; updated?: number };
|
||||
time?: {
|
||||
created?: number;
|
||||
updated?: number;
|
||||
};
|
||||
/** The model this conversation is on. Present before any assistant turn has landed. */
|
||||
model?: { id?: string; modelID?: string; providerID?: string };
|
||||
/** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */
|
||||
directory?: string | null;
|
||||
};
|
||||
@@ -102,11 +119,36 @@ export type OpenCodeStoredPart = {
|
||||
text?: string;
|
||||
tool?: string;
|
||||
callID?: string;
|
||||
state?: { input?: Record<string, unknown>; output?: string; error?: string };
|
||||
state?: { input?: Record<string, unknown>; output?: string; error?: string; status?: string };
|
||||
/** Current build names a tool part here; the older one used `tool`. */
|
||||
name?: string;
|
||||
/** Current build's id for a tool call, where the older one used `callID`. */
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Two shapes, because two builds.
|
||||
*
|
||||
* The older one nests everything: `{ info: { role }, parts: [...] }`. The current one flattens it —
|
||||
* `{ type: 'user' | 'assistant', text, content: [...] }` — and puts a user message's text directly on
|
||||
* the message rather than in a part. Both are accepted rather than pinned to today's build: this reader
|
||||
* is the only thing standing between a working transcript and a chat that looks erased, and a host on a
|
||||
* different opencode should degrade to fewer features, never to an empty conversation.
|
||||
*/
|
||||
export type OpenCodeStoredMessage = {
|
||||
/** Wire order is newest-first, so the reader sorts on this. */
|
||||
time?: { created?: number };
|
||||
/** Current build: the role, flattened. */
|
||||
type?: string;
|
||||
/** Current build: a user message's text, with no part wrapping it. */
|
||||
text?: string;
|
||||
/** Current build: the assistant's parts. */
|
||||
content?: OpenCodeStoredPart[];
|
||||
/** Current build: `{ id, providerID }`. */
|
||||
model?: { id?: string; modelID?: string; providerID?: string };
|
||||
/** Older build. */
|
||||
info?: { role?: string; modelID?: string; model?: { modelID?: string; providerID?: string } };
|
||||
/** Older build. */
|
||||
parts?: OpenCodeStoredPart[];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { logger } from '../logger';
|
||||
import { setOpenCodeSession } from './state';
|
||||
import { invalidateModelCache } from '../list-models';
|
||||
|
||||
// The OpenCode sidecar (officer-opencode) starts its `opencode serve` on a random port and reports it
|
||||
// here on connect. We remember it so the OpenCode harness always routes to the current server. It also
|
||||
@@ -13,6 +14,19 @@ sidecar.on('opencode:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
/*
|
||||
A new serve means a possibly different catalogue, so the cached model list has to go.
|
||||
|
||||
`listOpenCodeModels` caches for the platform process's lifetime, which is right — the catalogue moves
|
||||
about once a release. But it is the PLATFORM that holds it and the SIDECAR that changes, so signing
|
||||
into a provider and restarting `officer-opencode` left the platform serving the list it read before
|
||||
the login: six free models, no paid ones, and nothing anywhere saying why. Reported 2026-08-27, after
|
||||
a successful login that appeared to do nothing.
|
||||
|
||||
The port report is the one event that always accompanies a new serve, which makes it the right place
|
||||
to drop the cache.
|
||||
*/
|
||||
invalidateModelCache();
|
||||
logger.info('OpenCode sidecar server registered', { port, url: getOpenCodeServerUrl() });
|
||||
});
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
getOwnerHomeDir,
|
||||
getEmailAccountsDir,
|
||||
getPluginChatsDir,
|
||||
findEmailAccountName,
|
||||
resolveEmailAccountDir,
|
||||
getCommanderDir,
|
||||
} from '../../../servers/data-path';
|
||||
import { attachedFilePaths } from './attached-files';
|
||||
@@ -43,6 +42,8 @@ import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
import { snapshotPlugins } from '@@/plugins/mount';
|
||||
import { writeAtomic } from '@@/write-atomic';
|
||||
import { writeAgentBrief } from '@@/agent-brief';
|
||||
import { ensureBotsBrief } from '../bots/brief';
|
||||
import { logger } from './logger';
|
||||
import { isSuperAdmin } from '@@/super-admin';
|
||||
import { snapshotClaudeUsage } from './usage-store';
|
||||
@@ -196,12 +197,18 @@ export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, c
|
||||
// `accountEmail` will come from the account selector (msg.contextId) later; for now default to the
|
||||
// owner's first enabled account. Falls back to the email_accounts root if there are no accounts.
|
||||
async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promise<string> {
|
||||
// Found on disk, never queried. Email is a plugin and the platform may not import its queries; the
|
||||
// directory layout is already the platform's, so reading it answers the question with no coupling and
|
||||
// nothing to throw when the plugin is not installed.
|
||||
const account = accountEmail?.trim() || findEmailAccountName(ownerEmail);
|
||||
const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
|
||||
// `resolveEmailAccountDir` is shared with `GET /chat/sessions`, deliberately: the turn and the list of
|
||||
// conversations must resolve the same directory, and they did not — the sessions route knew nothing about
|
||||
// `email` and fell through to the owner's home, so the picker above this composer offered chats from
|
||||
// somewhere else. See its own comment for why `accountEmail` is verified rather than sanitised.
|
||||
const dir = resolveEmailAccountDir(ownerEmail, accountEmail);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
// The same brief a plugin's own chats get, written where an email chat actually runs.
|
||||
//
|
||||
// `installPluginChatBrief` is not reached by the `plugin` branch below, because an email chat does not
|
||||
// run in the plugin's chats directory — it runs in the ACCOUNT's, beside `emails.db`, which is the whole
|
||||
// reason this branch exists. The brief follows the chat rather than the chat moving to the brief.
|
||||
await installPluginChatBrief('email', dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -240,14 +247,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise<voi
|
||||
const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled);
|
||||
if (!state) return;
|
||||
|
||||
const source = join(state.plugin.dir, 'chat', 'CLAUDE.md');
|
||||
if (!existsSync(source)) return;
|
||||
// `chat/AGENTS.md`, with the old name still accepted: a plugin is its own repository on its own
|
||||
// release cadence, so the platform cannot rename a file inside one and assume every installed copy
|
||||
// followed. Dropping the fallback would silently un-brief a plugin that had not been updated —
|
||||
// silently, because a missing brief is indistinguishable from a plugin that ships none.
|
||||
const source = ['AGENTS.md', 'CLAUDE.md']
|
||||
.map((name) => join(state.plugin.dir, 'chat', name))
|
||||
.find((path) => existsSync(path));
|
||||
if (!source) return;
|
||||
|
||||
const target = join(dir, 'CLAUDE.md');
|
||||
const next = await Bun.file(source).text();
|
||||
// Skip the write when nothing changed, so a directory's mtimes do not churn on every chat start.
|
||||
if (existsSync(target) && (await Bun.file(target).text()) === next) return;
|
||||
await writeAtomic(target, next);
|
||||
// Both names, so opencode finds it too — see `writeAgentBrief`. Content-diffed inside, so a
|
||||
// directory's mtimes do not churn on every chat start.
|
||||
await writeAgentBrief(dir, await Bun.file(source).text());
|
||||
} catch (err) {
|
||||
console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
@@ -336,6 +347,18 @@ async function resolveChatCwd(
|
||||
return dir;
|
||||
}
|
||||
|
||||
/*
|
||||
Every bot runs in ONE directory — `$DATA_PATH/<email>/bots` — rather than one each.
|
||||
|
||||
A commander gets its own because it is a job with procedures and a ledger. A bot is a sentence and a
|
||||
watch, and a directory per bot would mean a creation step before anything can be said. Sharing it also
|
||||
means the list is free: `/chat/sessions?cwd=` already answers "what bots do I have", because Claude
|
||||
groups transcripts by the directory they ran in.
|
||||
|
||||
The brief is written here on every turn, so a change to it reaches every bot on the next message.
|
||||
*/
|
||||
if (msg.context === 'bots') return ensureBotsBrief(email);
|
||||
|
||||
if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId);
|
||||
if (msg.context === 'chat') {
|
||||
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { writeAgentBrief } from '@@/agent-brief';
|
||||
import { getUserByEmail } from 'officerdb';
|
||||
import { lookupOsUser } from '@@/os-user';
|
||||
import { join } from 'node:path';
|
||||
@@ -10,10 +11,11 @@ import { writeCommanderCredential } from './credential';
|
||||
A commander's own directory, created once, up front — `docs/commander-directory.md`.
|
||||
|
||||
$DATA_PATH/<email>/commanders/<dirName>/
|
||||
CLAUDE.md written here, extended by the commander as the job is agreed
|
||||
AGENTS.md written here, extended by the commander as the job is agreed
|
||||
CLAUDE.md a pointer at it, so both harnesses discover the same brief
|
||||
state/ INSTANCE data: what it spawned, its loop's pid. Empty at creation.
|
||||
|
||||
Both exist before the first turn spawns, which is the whole point: Claude Code reads a CLAUDE.md from the
|
||||
Both exist before the first turn spawns, which is the whole point: a coding CLI reads its brief from the
|
||||
working directory at session start, so a commander told about itself here needs no per-turn injection —
|
||||
see the deleted `commander-block.ts` for what this replaced.
|
||||
*/
|
||||
@@ -365,7 +367,8 @@ export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
'something broken.',
|
||||
'',
|
||||
'```',
|
||||
"CLAUDE.md this file. the platform's. never edited.",
|
||||
"AGENTS.md this file. the platform's, extended by you as the job is agreed.",
|
||||
"CLAUDE.md a pointer at AGENTS.md. the platform's. never edited.",
|
||||
'JOB.md what was agreed: what you watch for and what you do. yours, written once',
|
||||
' the job is settled and kept true after that.',
|
||||
'CLASSIFICATION.md the category rules, if it classifies. yours, and expected to change.',
|
||||
@@ -671,8 +674,14 @@ export async function createCommanderDirectory(
|
||||
credentialError = err instanceof Error ? err.message : 'could not mint a credential';
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(dir, 'CLAUDE.md'),
|
||||
// AGENTS.md with the brief in it, CLAUDE.md pointing at it — both, because opencode discovers only
|
||||
// the first and Claude Code only the second, and a commander can run under either.
|
||||
//
|
||||
// `onlyIfAbsent`, and that flag is the whole reason this is not the same call the bots make: a
|
||||
// commander EXTENDS this file over its life. Rewriting it when the template changes would delete the
|
||||
// job it had agreed with the owner.
|
||||
await writeAgentBrief(
|
||||
dir,
|
||||
commanderClaudeMd({
|
||||
name: params.name,
|
||||
referencePath: params.referencePath,
|
||||
@@ -680,6 +689,7 @@ export async function createCommanderDirectory(
|
||||
spawnedPath: spawnedRecordPath(email, dirName),
|
||||
credentialPath,
|
||||
}),
|
||||
{ onlyIfAbsent: true },
|
||||
);
|
||||
|
||||
// Last, so it covers CLAUDE.md, officer.env and state/ as well as the directory itself.
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type ClaudeSessionSummary,
|
||||
} from '../chat/claude-sessions';
|
||||
import { collectRunningTasks, startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
||||
import { chatContextDir } from '../chat/chat-context';
|
||||
import {
|
||||
listCommandHqCategories,
|
||||
createCommandHqCategory,
|
||||
@@ -527,13 +528,33 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
||||
commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ name?: string; referencePath?: string; categoryId?: number | null }>()
|
||||
.catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null });
|
||||
type Body = {
|
||||
name?: string;
|
||||
referencePath?: string;
|
||||
categoryId?: number | null;
|
||||
/** A chat context to watch, instead of a path — see below. */
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
};
|
||||
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
|
||||
const name = body.name?.trim();
|
||||
const referencePath = body.referencePath?.trim();
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
/*
|
||||
A commander started FROM a chat watches the directory that chat runs in.
|
||||
|
||||
The client sends the context it already has — `{context:'email', contextId:'me@…'}` — and never a
|
||||
path, because it cannot know one: a plugin's chats live under `$DATA_PATH/<email>/plugins/<app>/chats`
|
||||
and an email chat beside its own `emails.db`, both built server-side and deliberately not sent to the
|
||||
browser. `resolveChatContextDir` is the same function the chat router uses, so a commander cannot end
|
||||
up watching a directory its own chat has never been in.
|
||||
|
||||
This is also what makes the feature reachable by somebody who is not a super user. Pointing a
|
||||
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
|
||||
and an account allowed to walk the disk. The button asks for neither.
|
||||
*/
|
||||
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
|
||||
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { listAllCommanders, getUserById } from 'officerdb';
|
||||
import { getCommanderStateDir } from '@@/data-path';
|
||||
import { getCommanderStateDir, getBotsDir } from '@@/data-path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startClaudeTurn, COMMANDER_DEFAULT_MODEL } from '../chat/websocket';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { clearLoopPid, liveLoopPids } from './spawned';
|
||||
import { listBotRecords, liveBotPids } from '../bots/teardown';
|
||||
import { getNotifyServerUrl, notifyProducerHeaders } from '../notify/router';
|
||||
|
||||
// Waking a commander whose watch has died.
|
||||
@@ -60,6 +61,18 @@ const FIRST_TICK_MS = 120_000;
|
||||
const PID_FILE = 'loop-pid';
|
||||
const ATTEMPTS_FILE = 'revive-attempts';
|
||||
|
||||
/**
|
||||
* Where a subject's wake attempts are counted.
|
||||
*
|
||||
* A commander keeps them in its own `state/`. A bot has no directory of its own, so its key is
|
||||
* `bot-<sessionId>` and the file goes in the bots directory under that name — same bound, same window,
|
||||
* one implementation.
|
||||
*/
|
||||
const attemptsPath = (email: string, key: string) =>
|
||||
key.startsWith('bot-')
|
||||
? join(getBotsDir(email), `${key}.${ATTEMPTS_FILE}`)
|
||||
: join(getCommanderStateDir(email, key), ATTEMPTS_FILE);
|
||||
|
||||
/**
|
||||
* How many times this will wake one commander, and over what window.
|
||||
*
|
||||
@@ -76,7 +89,7 @@ const ATTEMPT_WINDOW_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/** Wake timestamps inside the window. Anything older is dropped rather than kept — this is a rate, not a history. */
|
||||
function recentAttempts(email: string, dirName: string): number[] {
|
||||
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
|
||||
const path = attemptsPath(email, dirName);
|
||||
if (!existsSync(path)) return [];
|
||||
const cutoff = Date.now() - ATTEMPT_WINDOW_MS;
|
||||
try {
|
||||
@@ -93,7 +106,7 @@ function recentAttempts(email: string, dirName: string): number[] {
|
||||
}
|
||||
|
||||
function recordAttempt(email: string, dirName: string): void {
|
||||
const path = join(getCommanderStateDir(email, dirName), ATTEMPTS_FILE);
|
||||
const path = attemptsPath(email, dirName);
|
||||
try {
|
||||
writeFileSync(path, [...recentAttempts(email, dirName), Date.now()].join('\n'));
|
||||
} catch (err) {
|
||||
@@ -267,6 +280,82 @@ async function tellTheOwner(userId: number, dirName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question, asked of bots.
|
||||
*
|
||||
* A bot's watch dies exactly as a commander's does — a reboot, a killed process tree — and with the same
|
||||
* silence: the card still lists it and nothing is polling anything. The difference is only where the
|
||||
* record lives. A commander writes a pid file into its own `state/`; a bot writes `<sessionId>.bot.json`
|
||||
* beside its script, because bots share one directory and the id has to be in the filename.
|
||||
*
|
||||
* A record with no live pid is a watch that has stopped. There is no `armed` flag to consult and there
|
||||
* should not be: the record is written when it arms, so its existence IS the intent, and the pids in it
|
||||
* are the fact.
|
||||
*/
|
||||
async function reviveBots(userId: number, email: string): Promise<void> {
|
||||
for (const { sessionId, record } of listBotRecords(email)) {
|
||||
if (!record.pids?.length) continue; // never armed, or already torn down
|
||||
if (liveBotPids(email, sessionId).length > 0) continue;
|
||||
|
||||
const attempts = recentAttempts(email, `bot-${sessionId}`);
|
||||
if (attempts.length >= MAX_ATTEMPTS) continue;
|
||||
|
||||
console.warn(`[watcher-revive] bot ${sessionId}: watch is dead — waking it`);
|
||||
recordAttempt(email, `bot-${sessionId}`);
|
||||
await tellTheOwner(userId, `bot ${sessionId.slice(0, 8)}`);
|
||||
await wakeBot(userId, sessionId, email);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a bot is told. Shorter than a commander's, because a bot has one job and its record names it.
|
||||
*/
|
||||
const WAKE_BOT_PROMPT = [
|
||||
'Your watch is not running — the recorded process is gone, usually a reboot or something that killed',
|
||||
'the tree it was started under.',
|
||||
'',
|
||||
'Read your own `<session-id>.bot.json` in this directory, arm the script it names again exactly as',
|
||||
'before, and rewrite the record with the new pid. Do not start a second watch beside it, and do not',
|
||||
'change what it watches.',
|
||||
'',
|
||||
'If the record or the script is missing, say what is gone and stop. Do not reconstruct a watch from',
|
||||
'memory of the conversation — a watch that is subtly different from the one the owner agreed to is',
|
||||
'worse than none, because it looks like it is working.',
|
||||
].join('\n');
|
||||
|
||||
async function wakeBot(userId: number, sessionId: string, email: string): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const done = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(done, 60_000);
|
||||
|
||||
void startClaudeTurn({
|
||||
email,
|
||||
username: email,
|
||||
userId,
|
||||
ws: null,
|
||||
sessionId,
|
||||
model: COMMANDER_DEFAULT_MODEL,
|
||||
msg: { prompt: WAKE_BOT_PROMPT, resumeSessionId: sessionId },
|
||||
effectivePrompt: WAKE_BOT_PROMPT,
|
||||
onOutcome: (outcome) => {
|
||||
clearTimeout(timer);
|
||||
if (!outcome.ok) console.error(`[watcher-revive] bot ${sessionId}: ${outcome.error}`);
|
||||
done();
|
||||
},
|
||||
}).catch((err) => {
|
||||
clearTimeout(timer);
|
||||
console.error(`[watcher-revive] bot ${sessionId} threw:`, err instanceof Error ? err.message : err);
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
@@ -305,6 +394,17 @@ export function startWatcherRevive(): void {
|
||||
await tellTheOwner(commander.userId, commander.dirName);
|
||||
await wake(commander.userId, commander.sessionId, commander.dirName, user.email);
|
||||
}
|
||||
|
||||
// Bots, same tick. Their records live per account, so this needs the set of owners rather than the
|
||||
// commander list — an account with no commanders can still have bots.
|
||||
const owners = new Map<number, string>();
|
||||
for (const c of await listAllCommanders()) {
|
||||
const u = await getUserById(c.userId);
|
||||
if (u) owners.set(c.userId, u.email);
|
||||
}
|
||||
const me = await getUserById(1);
|
||||
if (me) owners.set(1, me.email);
|
||||
for (const [userId, email] of owners) await reviveBots(userId, email);
|
||||
} catch (err) {
|
||||
console.error('[watcher-revive] tick failed:', err instanceof Error ? err.message : err);
|
||||
} finally {
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
|
||||
// Generates the iOS configuration profile that sets up CalDAV + CardDAV in one install, and holds it
|
||||
// against a single-use token until Safari fetches it.
|
||||
//
|
||||
// WHY A TOKEN AND NOT A SESSION. The profile is fetched by Safari, which has no platform JWT — the app
|
||||
// hands the URL to the OS browser and loses all control of the request. So the token IS the credential.
|
||||
// It is therefore minted long, used once, and dropped after five minutes, because the thing it protects
|
||||
// is a live DAV password sitting in a file on a public endpoint. A profile URL that stays valid is a
|
||||
// password that stays valid.
|
||||
//
|
||||
// WHY IN MEMORY. The profile contains the app password in PLAINTEXT — it has to, that is what makes the
|
||||
// install one-tap. `createDavAppPassword` promises the plaintext "is not stored", and writing the
|
||||
// profile to Postgres or to disk would quietly make that false. A process restart inside the five-minute
|
||||
// window costs the owner one extra tap; persisting the secret costs the guarantee.
|
||||
//
|
||||
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.
|
||||
|
||||
const TTL_MS = 5 * 60_000;
|
||||
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
|
||||
const MAX_PENDING = 32;
|
||||
|
||||
type PendingProfile = { body: Uint8Array; expiresAt: number };
|
||||
|
||||
const pending = new Map<string, PendingProfile>();
|
||||
|
||||
const sweep = () => {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of pending) if (entry.expiresAt <= now) pending.delete(token);
|
||||
};
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
type ProfileParams = {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
/** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */
|
||||
principalUrl: string;
|
||||
/** What the user sees as the account name in Settings. */
|
||||
accountName: string;
|
||||
};
|
||||
|
||||
// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
|
||||
// CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts
|
||||
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
|
||||
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
|
||||
function buildPlist(params: ProfileParams): string {
|
||||
const { host, port, username, password, principalUrl, accountName } = params;
|
||||
// Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
|
||||
// two Officer instances on one phone do not overwrite each other.
|
||||
const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
||||
const e = escapeXml;
|
||||
|
||||
const account = (kind: 'CalDAV' | 'CardDAV', label: string) => ` <dict>
|
||||
<key>PayloadType</key><string>com.apple.${kind.toLowerCase()}.account</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>${identifier}.${kind.toLowerCase()}</string>
|
||||
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
|
||||
<key>PayloadDisplayName</key><string>${e(label)}</string>
|
||||
<key>${kind}AccountDescription</key><string>${e(accountName)}</string>
|
||||
<key>${kind}HostName</key><string>${e(host)}</string>
|
||||
<key>${kind}Port</key><integer>${port}</integer>
|
||||
<key>${kind}UseSSL</key><true/>
|
||||
<key>${kind}Username</key><string>${e(username)}</string>
|
||||
<key>${kind}Password</key><string>${e(password)}</string>
|
||||
<key>${kind}PrincipalURL</key><string>${e(principalUrl)}</string>
|
||||
</dict>`;
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadType</key><string>Configuration</string>
|
||||
<key>PayloadVersion</key><integer>1</integer>
|
||||
<key>PayloadIdentifier</key><string>${identifier}</string>
|
||||
<key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
|
||||
<key>PayloadDisplayName</key><string>${e(accountName)} — Calendar & Contacts</string>
|
||||
<key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
|
||||
<key>PayloadOrganization</key><string>Officer</string>
|
||||
<key>PayloadScope</key><string>User</string>
|
||||
<key>PayloadRemovalDisallowed</key><false/>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
${account('CalDAV', 'Officer Calendar')}
|
||||
${account('CardDAV', 'Officer Contacts')}
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
// Signing is OPT-IN and off by default, because this machine has no TLS certificate on it — TLS
|
||||
// terminates upstream. An unsigned profile installs exactly the same way; the only difference is that
|
||||
// the install screen says "Not Signed" in red instead of naming the signer.
|
||||
//
|
||||
// Point these at a cert whose chain iOS already trusts (a public CA — the TLS cert of the reverse proxy
|
||||
// works, iOS checks the trust chain and not the key usage) and profiles are signed from then on:
|
||||
// DAV_PROFILE_SIGN_CERT leaf certificate, PEM
|
||||
// DAV_PROFILE_SIGN_KEY its private key, PEM
|
||||
// DAV_PROFILE_SIGN_CHAIN intermediates, PEM (iOS ships roots, not intermediates — without this the
|
||||
// signature cannot be verified and shows as Not Verified)
|
||||
//
|
||||
// Signed at mint time, reading the cert from disk on every call, so a renewed certificate is picked up
|
||||
// with no restart and no renewal hook — the failure mode the mobile team flagged cannot happen here.
|
||||
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
|
||||
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
|
||||
// old one is removed. Nothing detects that for you.
|
||||
const signingConfig = () => {
|
||||
const cert = process.env.DAV_PROFILE_SIGN_CERT;
|
||||
const key = process.env.DAV_PROFILE_SIGN_KEY;
|
||||
if (!cert || !key) return null;
|
||||
return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
|
||||
};
|
||||
|
||||
let warnedUnsigned = false;
|
||||
|
||||
async function sign(plist: string): Promise<Uint8Array | null> {
|
||||
const config = signingConfig();
|
||||
if (!config) {
|
||||
if (!warnedUnsigned) {
|
||||
warnedUnsigned = true;
|
||||
console.warn(
|
||||
'[dav] serving UNSIGNED iOS profiles — iOS will show "Not Signed" in red. Set DAV_PROFILE_SIGN_CERT/KEY to sign.',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Piped through stdin/stdout rather than temp files: the plist holds a live password in plaintext and
|
||||
// there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
|
||||
// load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
|
||||
// the file is a signature with no profile embedded in it.
|
||||
const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
|
||||
if (config.chain) args.push('-certfile', config.chain);
|
||||
args.push('-md', 'sha256');
|
||||
|
||||
const proc = Bun.spawn(['openssl', ...args], {
|
||||
stdin: new TextEncoder().encode(plist),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [out, err, code] = await Promise.all([
|
||||
new Response(proc.stdout).arrayBuffer(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (code !== 0 || out.byteLength === 0) {
|
||||
// Deliberately not fatal. An unsigned profile still installs and still works; failing the whole
|
||||
// provisioning request over a cosmetic signature would be the worse outcome.
|
||||
console.error(`[dav] profile signing failed (exit ${code}), serving unsigned: ${err.trim().slice(0, 200)}`);
|
||||
return null;
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
export type StoredProfile = { url: string; expiresAt: string; signed: boolean };
|
||||
|
||||
/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
|
||||
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
|
||||
sweep();
|
||||
if (pending.size >= MAX_PENDING) throw new Error('too many pending profiles');
|
||||
|
||||
const plist = buildPlist(params);
|
||||
const signed = await sign(plist);
|
||||
const body = signed ?? new TextEncoder().encode(plist);
|
||||
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const expiresAt = Date.now() + TTL_MS;
|
||||
pending.set(token, { body, expiresAt });
|
||||
|
||||
return {
|
||||
url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
signed: signed !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Take the profile for this token, if it is still valid. Consumes it — a second fetch gets nothing. */
|
||||
export function claimIosProfile(token: string): Uint8Array | null {
|
||||
sweep();
|
||||
const entry = pending.get(token);
|
||||
if (!entry) return null;
|
||||
pending.delete(token);
|
||||
return entry.body;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { listDavAppPasswords, createDavAppPassword, revokeDavAppPassword, deleteDavAppPassword } from 'officerdb';
|
||||
import * as errors from '../../custom-errors';
|
||||
import { stashIosProfile } from './ios-profile';
|
||||
|
||||
// Management of DAV app passwords, for Officer's own UI. Behind userMiddleware like everything else
|
||||
// under /api — this is the owner administering their devices from a logged-in browser, which is a
|
||||
// completely different act from a phone syncing (that is /dav, see sync-router.ts).
|
||||
//
|
||||
// The plaintext credential exists for exactly one response and is never stored, so POST is the only
|
||||
// place it appears. There is deliberately no "show me it again" endpoint: if it is lost, revoke the
|
||||
// row and mint another. That is cheaper than any design where the secret can be read back.
|
||||
|
||||
export const davRouter = createRouter();
|
||||
|
||||
davRouter.get('/passwords', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json({ passwords: await listDavAppPasswords(user.id) });
|
||||
});
|
||||
|
||||
davRouter.post('/passwords', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { label?: string } | undefined;
|
||||
const label = body?.label?.trim();
|
||||
if (!label) throw errors.BAD_REQUEST('label is required');
|
||||
|
||||
const { entry, password } = await createDavAppPassword(user.id, label);
|
||||
// `password` is returned once, here. Nothing else in the system can produce it again.
|
||||
return ctx.json({ entry, password, username: user.email });
|
||||
});
|
||||
|
||||
// One-tap iOS setup: mint a credential, wrap it in a configuration profile, hand back a URL for the app
|
||||
// to open in Safari. See docs/mobile-dav-provisioning.md §3, and ios-profile.ts for why the profile is
|
||||
// held in memory behind a one-shot token rather than stored.
|
||||
//
|
||||
// This is the only endpoint that returns a route to a live password instead of the password itself. It
|
||||
// exists because the alternative is the owner typing a 25-character secret into a phone keyboard twice.
|
||||
davRouter.post('/provision/ios', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const body = ctx.get('body') as { deviceLabel?: string } | undefined;
|
||||
const deviceLabel = body?.deviceLabel?.trim();
|
||||
if (!deviceLabel) throw errors.BAD_REQUEST('deviceLabel is required');
|
||||
|
||||
// The profile has to name a host, and PUBLIC_URL is the only place that knows the one a phone can
|
||||
// actually reach — the request's own Host header is whatever the reverse proxy forwarded.
|
||||
const publicUrl = process.env.OFFICERDEV_PUBLIC_URL ?? process.env.PUBLIC_URL;
|
||||
if (!publicUrl) throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not set; cannot build a profile');
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(publicUrl);
|
||||
} catch {
|
||||
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL is not a valid URL');
|
||||
}
|
||||
if (url.protocol !== 'https:') {
|
||||
// CalDAVUseSSL is hardcoded true in the payload, so an http PUBLIC_URL would produce a profile that
|
||||
// cannot work. Better to say so than to ship a phone an account that silently never syncs.
|
||||
throw errors.INTERNAL_SERVER_ERROR('PUBLIC_URL must be https to provision a device');
|
||||
}
|
||||
|
||||
const { entry, password } = await createDavAppPassword(user.id, deviceLabel);
|
||||
|
||||
const stored = await stashIosProfile(
|
||||
{
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 443,
|
||||
username: user.email,
|
||||
password,
|
||||
// The principal, not a collection: iOS discovers every calendar and address book under it, so a
|
||||
// collection the owner adds later appears without re-provisioning.
|
||||
principalUrl: `/dav/${user.id}/`,
|
||||
accountName: 'Officer',
|
||||
},
|
||||
url.origin,
|
||||
);
|
||||
|
||||
console.log(`[dav] provisioned ios profile for "${deviceLabel}" (password #${entry.id}, signed=${stored.signed})`);
|
||||
return ctx.json(stored);
|
||||
});
|
||||
|
||||
davRouter.post('/passwords/:id/revoke', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
|
||||
if (!(await revokeDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
davRouter.delete('/passwords/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
|
||||
if (!(await deleteDavAppPassword(user.id, id))) throw errors.NOT_FOUND('no such app password');
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
// The officer-caldav sidecar has TWO consumers in the platform and they need different things, so the
|
||||
// port capture lives here and both import it.
|
||||
//
|
||||
// • `caldavRouter` — the ordinary JSON door for Officer's own UI. Behind userMiddleware, forwarded by
|
||||
// the shared factory with `X-Officer-User`, exactly like every other sidecar.
|
||||
// • `davSyncRouter` — the DAV door for phones. Cannot use the factory: it forwards only three headers
|
||||
// (`create-proxy.ts:88`) and DAV dies without `Depth`, and it derives the user from a platform JWT
|
||||
// that a DAV client has no way to hold.
|
||||
//
|
||||
// Hence one registration, two forwarders. The factory's own comment says it must never grow per-app
|
||||
// logic, so the DAV-specific half is a separate file rather than a branch inside it.
|
||||
const proxy = createSidecarProxy({ name: 'caldav', prefix: '/api/caldav' });
|
||||
|
||||
export const caldavRouter = proxy.router;
|
||||
export const getCaldavUrl = proxy.getHttpUrl;
|
||||
@@ -1,143 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserByEmail, verifyDavAppPassword } from 'officerdb';
|
||||
import { getCaldavUrl } from './sidecar-server';
|
||||
|
||||
// The DAV door: `/dav/*`, mounted TOP-LEVEL, outside protectedRouter.
|
||||
//
|
||||
// WHY IT IS NOT UNDER /api AND NOT BEHIND userMiddleware. DAVx5, iOS Calendar, macOS and Thunderbird
|
||||
// speak CalDAV over HTTPS with HTTP Basic auth on every single request. They cannot log into Officer,
|
||||
// cannot hold a 30-day JWT, cannot refresh one and cannot answer a passkey challenge. This is the same
|
||||
// situation `/api/vault` is in — "the Bitwarden client carries its own bearer token, not a platform
|
||||
// session JWT, so userMiddleware would 401 it" (hono.ts) — with a different credential type.
|
||||
//
|
||||
// The credential is a DAV app password (`dav_app_passwords`), scoped to this mount and nothing else.
|
||||
// An app password is NOT a session: userMiddleware must never learn to accept one, or a device that
|
||||
// syncs a calendar would also reach the wallet.
|
||||
//
|
||||
// Everything is forwarded verbatim to the caldav sidecar, which forwards it to Radicale. The platform
|
||||
// authenticates and moves bytes; it does not parse a single line of iCalendar. That restraint is the
|
||||
// same one create-proxy.ts documents, and for the same reason.
|
||||
|
||||
export const davSyncRouter = createRouter();
|
||||
|
||||
const unauthorized = () =>
|
||||
new Response('Unauthorized', {
|
||||
status: 401,
|
||||
// Without this header a DAV client will not prompt for credentials at all — it just fails. The
|
||||
// realm string is what the phone shows in its password dialog.
|
||||
headers: { 'WWW-Authenticate': 'Basic realm="Officer DAV", charset="UTF-8"' },
|
||||
});
|
||||
|
||||
type BasicCreds = { username: string; password: string };
|
||||
|
||||
function parseBasic(header: string | undefined): BasicCreds | null {
|
||||
if (!header?.startsWith('Basic ')) return null;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Split on the FIRST colon only — a generated password never contains one, but an email local-part
|
||||
// legally can, and splitting greedily would break those accounts in a way that looks like a wrong
|
||||
// password.
|
||||
const at = decoded.indexOf(':');
|
||||
if (at < 0) return null;
|
||||
return { username: decoded.slice(0, at), password: decoded.slice(at + 1) };
|
||||
}
|
||||
|
||||
// Same list the sidecar forwards, and for the same reason: `Depth` decides whether PROPFIND returns a
|
||||
// collection, its children or the whole tree, so dropping it makes every calendar look empty.
|
||||
const REQUEST_HEADERS = [
|
||||
'depth',
|
||||
'content-type',
|
||||
'content-length',
|
||||
'if',
|
||||
'if-match',
|
||||
'if-none-match',
|
||||
'destination',
|
||||
'overwrite',
|
||||
'lock-token',
|
||||
'timeout',
|
||||
'prefer',
|
||||
'user-agent',
|
||||
] as const;
|
||||
|
||||
const RESPONSE_HEADERS = [
|
||||
'content-type',
|
||||
'dav',
|
||||
'allow',
|
||||
'etag',
|
||||
'last-modified',
|
||||
'location',
|
||||
'lock-token',
|
||||
'preference-applied',
|
||||
'vary',
|
||||
] as const;
|
||||
|
||||
davSyncRouter.all('/*', async (ctx) => {
|
||||
const creds = parseBasic(ctx.req.header('authorization'));
|
||||
if (!creds) return unauthorized();
|
||||
|
||||
// A credential-less request is the normal opening move — the client is asking to be challenged — so it
|
||||
// is not worth a line. A credential that is present and wrong is the interesting case, and it is
|
||||
// indistinguishable from the normal one at the client end: both just say "password incorrect".
|
||||
const user = await getUserByEmail(creds.username);
|
||||
if (!user) {
|
||||
console.warn(`[dav] auth failed: no such user ${creds.username}`);
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const userId = await verifyDavAppPassword(user.id, creds.password);
|
||||
if (!userId) {
|
||||
console.warn(`[dav] auth failed: bad app password for ${creds.username}`);
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const base = getCaldavUrl();
|
||||
if (!base) return new Response('caldav sidecar not available', { status: 503 });
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
// The sidecar mounts DAV at /dav too, so the prefix is preserved rather than stripped — Radicale
|
||||
// generates absolute hrefs in its multistatus responses, and if the path the client sees differs
|
||||
// from the path the server thinks it is at, every href points somewhere that 404s.
|
||||
const target = `${base}${url.pathname}${url.search}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const name of REQUEST_HEADERS) {
|
||||
const value = ctx.req.header(name);
|
||||
if (value) headers[name] = value;
|
||||
}
|
||||
headers['X-Officer-User'] = String(userId);
|
||||
|
||||
const method = ctx.req.method;
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[dav] proxy fetch failed', { path: url.pathname, error: String(err) });
|
||||
return new Response('caldav sidecar unreachable', { status: 502 });
|
||||
}
|
||||
|
||||
const out = new Headers();
|
||||
for (const name of RESPONSE_HEADERS) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) out.set(name, value);
|
||||
}
|
||||
|
||||
// One line per request, with the client's own name for itself. A DAV client tells you almost nothing
|
||||
// when it fails — iOS in particular reports every setup failure as "Cannot connect using SSL" — so the
|
||||
// only way to know whether a phone ever asked for an address book is to record that it did. Cheap:
|
||||
// a sync is a handful of requests, and a body is never logged.
|
||||
console.log(
|
||||
`[dav] ${method} ${url.pathname} -> ${upstream.status} (user=${userId}, ua=${(ctx.req.header('user-agent') ?? '-').slice(0, 60)})`,
|
||||
);
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: out });
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUsers } from 'officerdb';
|
||||
import * as errors from '../../custom-errors';
|
||||
|
||||
@@ -9,6 +10,23 @@ import * as errors from '../../custom-errors';
|
||||
|
||||
const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' });
|
||||
|
||||
/*
|
||||
ORDER IS LOAD-BEARING, and getting it wrong is silent.
|
||||
|
||||
`createSidecarProxy` mounts `router.all('/*')` the moment it is created — a catch-all. Hono matches in
|
||||
registration order, so anything added to the proxy's OWN router afterwards is shadowed: the request is
|
||||
forwarded to the sidecar, which has never heard of it and answers a plain `not found`.
|
||||
|
||||
That is what happened to `/broadcast`. It was written on 2026-08-24 to replace somebody POSTing to the
|
||||
sidecar's ephemeral port by hand, it typechecked, it was reviewed, and it answered 404 from the day it
|
||||
landed — for three days, to a route whose whole purpose was to stop people going around the platform.
|
||||
A shadowed route does not fail like a missing one; it fails like a sidecar that does not implement it.
|
||||
|
||||
So: the platform's own routes on this router first, the catch-all proxy mounted LAST. Same rule, and
|
||||
the same wording, as `plugins/caldav/api/router.ts`, which learned it the same way.
|
||||
*/
|
||||
export const notifyRouter = createRouter();
|
||||
|
||||
/*
|
||||
POST /api/notify/broadcast — one message to every account, owner only.
|
||||
|
||||
@@ -19,7 +37,7 @@ const proxy = createSidecarProxy({ name: 'notify', prefix: '/api/notify' });
|
||||
|
||||
Teams will narrow this later. Today the choice is everyone or one person, and everyone is owner-only.
|
||||
*/
|
||||
proxy.router.post('/broadcast', async (ctx) => {
|
||||
notifyRouter.post('/broadcast', async (ctx) => {
|
||||
type BroadcastBody = { title?: string; body?: string; code?: string };
|
||||
const body: BroadcastBody = await ctx.req.json<BroadcastBody>().catch(() => ({}) as BroadcastBody);
|
||||
const title = body.title?.trim();
|
||||
@@ -54,7 +72,56 @@ proxy.router.post('/broadcast', async (ctx) => {
|
||||
return ctx.json({ sent: results.filter((r) => r.ok).length, total: results.length, results });
|
||||
});
|
||||
|
||||
export const notifyRouter = proxy.router;
|
||||
/*
|
||||
POST /api/notify/user — one message to ONE named account, owner only.
|
||||
|
||||
The gap this fills, in the words of the route above: the choice was everyone or yourself. Notifying a
|
||||
SINGLE OTHER PERSON — the thing an owner actually wants when somebody's device finishes registering, or
|
||||
when one member needs telling and the other five do not — had no door, so the honest answer to "notify
|
||||
him" was a broadcast to the whole household or nothing.
|
||||
|
||||
Same authority as `/broadcast` and for the same reason: everything else under this permission acts on
|
||||
the account asking, and reaching somebody else's phone is administration however few phones it is.
|
||||
|
||||
Deliberately NOT reachable by an agent's own reasoning about who deserves a notification — it is
|
||||
`ownerOnly`, so a member's session key cannot call it at all, and the owner's carries the owner's
|
||||
authority because it IS the owner. The producer secret stays where it is; this route is the platform
|
||||
speaking, which is the only thing that ever holds it.
|
||||
*/
|
||||
notifyRouter.post('/user', async (ctx) => {
|
||||
type UserBody = { userId?: number; title?: string; body?: string; code?: string };
|
||||
const body: UserBody = await ctx.req.json<UserBody>().catch(() => ({}) as UserBody);
|
||||
const title = body.title?.trim();
|
||||
const message = body.body?.trim();
|
||||
const userId = Number(body.userId);
|
||||
if (!Number.isInteger(userId) || userId <= 0) throw errors.BAD_REQUEST('userId is required');
|
||||
if (!title || !message) throw errors.BAD_REQUEST('title and body are required');
|
||||
|
||||
// Refuse a stranger rather than hand the sidecar an id that resolves to nobody: a 404 here says the
|
||||
// account does not exist, where the sidecar's silence would read as a delivered notification.
|
||||
const target = (await getUsers()).find((u) => u.id === userId);
|
||||
if (!target) throw errors.createError(404, `no account with id ${userId}`);
|
||||
|
||||
const base = proxy.getHttpUrl();
|
||||
const producer = notifyProducerHeaders();
|
||||
if (!base || !producer) throw errors.createError(503, 'notify sidecar not available');
|
||||
|
||||
const res = await fetch(`${base}/_officer/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...producer },
|
||||
// `userId` in the BODY and no `X-Officer-User` — the platform speaking about somebody else, which is
|
||||
// the distinction `sidecar/notify/resolve-user.ts` draws.
|
||||
body: JSON.stringify({ code: body.code ?? 'officer/core:message', userId, title, body: message }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw errors.createError(502, `notify sidecar answered ${res.status}`);
|
||||
|
||||
// The email, so the caller can see WHO was reached rather than trusting an id they typed.
|
||||
return ctx.json({ sent: 1, userId, email: target.email });
|
||||
});
|
||||
|
||||
// LAST. Everything above is the platform's; everything else belongs to the sidecar.
|
||||
notifyRouter.route('/', proxy.router);
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getNotifyServerUrl = proxy.getHttpUrl;
|
||||
|
||||
@@ -99,6 +99,9 @@ pluginsRouter.get('/', async (ctx) => {
|
||||
prefix: e.publisher === 'officerdev' ? `/${e.appName}` : `/p/${e.publisher}/${e.appName}`,
|
||||
label: e.label,
|
||||
summary: e.summary,
|
||||
// What it is FOR, which is how the store is browsed. From the catalogue for a plugin that is not
|
||||
// here yet, from the manifest below for one that is — the same field either way.
|
||||
tags: e.tags ?? [],
|
||||
image: e.iconUrl,
|
||||
// Same precedence the installed list uses: artwork wins, the glyph name is the fallback.
|
||||
...(e.iconUrl ? {} : { icon: e.icon }),
|
||||
@@ -126,11 +129,12 @@ pluginsRouter.get('/', async (ctx) => {
|
||||
};
|
||||
})(),
|
||||
})),
|
||||
plugins: states.map(({ plugin, install, outdated }, i) => ({
|
||||
plugins: states.map(({ plugin, install, outdated, release }, i) => ({
|
||||
appName: plugin.appName,
|
||||
prefix: mountPrefix(plugin),
|
||||
label: plugin.manifest.label,
|
||||
summary: plugin.manifest.summary,
|
||||
tags: plugin.manifest.tags ?? [],
|
||||
icon: plugin.manifest.icon,
|
||||
// The plugin's own artwork, once published. Only while INSTALLED: `publishAssets` copies
|
||||
// `assets/` into `public/plugins/<appName>/` at install and `unpublishAssets` removes it, so a
|
||||
@@ -147,9 +151,9 @@ pluginsRouter.get('/', async (ctx) => {
|
||||
version: plugin.manifest.version,
|
||||
platform: plugin.manifest.platform,
|
||||
permissions: plugin.manifest.permissions,
|
||||
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
|
||||
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
|
||||
settings: plugin.manifest.settings ?? [],
|
||||
// What the owner must be able to set. Declared rather than rendered by the plugin, so a backend
|
||||
// plugin with no frontend of its own still gets a settings form — see `PluginSettings.tsx`.
|
||||
settings: plugin.manifest.settings ?? [],
|
||||
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
|
||||
has: {
|
||||
api: !!plugin.api,
|
||||
@@ -161,6 +165,11 @@ pluginsRouter.get('/', async (ctx) => {
|
||||
enabled: install?.enabled ?? false,
|
||||
installedVersion: install?.version ?? null,
|
||||
outdated,
|
||||
// The tag rule — whether the REMOTE has something this checkout does not. See plugins/release.ts.
|
||||
updateable: release.updateable,
|
||||
releaseReason: release.reason,
|
||||
tagAtHead: release.tagAtHead,
|
||||
newestTag: release.newestTag,
|
||||
processStatus: statuses[i] ?? null,
|
||||
// Probed live, against this machine's PATH, on every read.
|
||||
//
|
||||
|
||||
@@ -32,6 +32,14 @@ export const assetPublicDir = (sidecarId: string): string => join(process.cwd(),
|
||||
/** The URL a manifest should use for a shipped icon. */
|
||||
export const iconUrl = (sidecarId: string): string => `/plugins/${sidecarId}/icon.png`;
|
||||
|
||||
/**
|
||||
* The URL for one SCREEN's own artwork, in a plugin that has more than one.
|
||||
*
|
||||
* Same published directory and the same copy — `publishAssets` takes the whole `assets/` folder, so
|
||||
* `assets/calendar.png` needs no separate step to reach the browser.
|
||||
*/
|
||||
export const screenArtUrl = (sidecarId: string, segment: string): string => `/plugins/${sidecarId}/${segment}.png`;
|
||||
|
||||
/** The URL for a shipped splash. Published by the same copy — `publishAssets` takes the whole dir. */
|
||||
export const splashUrl = (sidecarId: string): string => `/plugins/${sidecarId}/splash.png`;
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ export async function startProcess(name: string, cwd: string): Promise<Pm2Result
|
||||
|
||||
const { code, out } = await pm2(args, cwd);
|
||||
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
|
||||
// The list changed, so the dump has to. See `saveProcessList`.
|
||||
await saveProcessList(cwd);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -105,9 +107,34 @@ export async function deleteProcess(name: string, cwd: string): Promise<Pm2Resul
|
||||
if (code !== 0 && !/not found|doesn't exist/i.test(out)) {
|
||||
return { ok: false, error: out || `pm2 delete ${name} exited ${code}` };
|
||||
}
|
||||
// Saved on REMOVAL too, or the dump keeps resurrecting a sidecar whose plugin is gone — which comes
|
||||
// back after a reboot as a crash-looping process for code that is no longer on the machine.
|
||||
await saveProcessList(cwd);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the process list, so a reboot brings back what is actually installed.
|
||||
*
|
||||
* ── Why this is not optional ──
|
||||
*
|
||||
* PM2 resurrects from `~/.pm2/dump.pm2`, written only by `pm2 save`. Nothing in the plugin system ever
|
||||
* called it, so the dump was whatever a human last happened to save — on 2026-08-27 it was five days old
|
||||
* and held 13 processes while 26 were running. A reboot would have silently lost every sidecar installed
|
||||
* since, and the symptom is the worst kind: the machine comes up, the platform is fine, and thirteen
|
||||
* features are simply gone with nothing saying why.
|
||||
*
|
||||
* ── Best effort, and never fatal ──
|
||||
*
|
||||
* A failed save leaves the estate correct and only the dump stale, which is exactly where things were
|
||||
* before this existed. Failing an install over it would trade a working plugin for a bookkeeping error.
|
||||
* It is logged rather than returned for the same reason.
|
||||
*/
|
||||
export async function saveProcessList(cwd: string): Promise<void> {
|
||||
const { code, out } = await pm2(['save'], cwd);
|
||||
if (code !== 0) console.error(`[pm2] could not save the process list — a reboot may not restore it: ${out}`);
|
||||
}
|
||||
|
||||
/** 'online' | 'stopped' | 'errored' | … , or null when PM2 has never heard of it. */
|
||||
export async function processStatus(name: string, cwd: string): Promise<string | null> {
|
||||
const { code, out } = await pm2(['jlist'], cwd);
|
||||
|
||||
@@ -263,6 +263,26 @@ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'comma
|
||||
*/
|
||||
export const getPluginsDir = (email: string) => join(DATA_PATH, email, 'plugins');
|
||||
|
||||
/**
|
||||
* Where every bot runs — `$DATA_PATH/<email>/bots`.
|
||||
*
|
||||
* ── One directory, not one per bot ──
|
||||
*
|
||||
* A commander gets its own, because a commander is a job with procedures, a ledger and its own rules. A
|
||||
* bot is one sentence and a watch, and giving each one a directory would mean a tree of near-empty
|
||||
* folders and a creation step before anything can be said.
|
||||
*
|
||||
* Sharing it also gives the list for free: every bot is a chat whose cwd is this, so Claude groups their
|
||||
* transcripts together and `/chat/sessions?cwd=` is already the answer to "what bots do I have".
|
||||
*
|
||||
* A bot that needs to remember something — the headline it saw last time — writes a file here named after
|
||||
* itself. The `CLAUDE.md` in this directory says so; it is the platform's and is rewritten on every bot
|
||||
* turn, the same arrangement as a plugin's chat brief.
|
||||
*
|
||||
* Confined like `commanders` and `plugins`: a member's bot runs as the member, so this must be theirs.
|
||||
*/
|
||||
export const getBotsDir = (email: string) => join(DATA_PATH, email, 'bots');
|
||||
|
||||
export const getPluginChatsDir = (email: string, appName: string): string => {
|
||||
if (!appName || appName.includes('/') || appName.includes('\\') || appName === '.' || appName === '..') {
|
||||
throw new Error(`refusing to resolve a plugin directory for ${JSON.stringify(appName)}`);
|
||||
@@ -351,6 +371,35 @@ export function findEmailAccountName(ownerEmail: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory an email chat belongs to — the mailbox's own, the one holding `emails.db`.
|
||||
*
|
||||
* ── Why this is a function and not two lines at each call site ──
|
||||
*
|
||||
* It IS an address, and it had two resolvers. The chat socket resolved it to run the turn; the sessions
|
||||
* route resolved it to list the conversations. Only the socket knew about `email`, so the picker fell
|
||||
* through to the owner's home and offered chats from somewhere else entirely — you could send a message
|
||||
* into the mailbox and then not find it in the list above the composer. Two answers to "where does this
|
||||
* chat live" is one too many.
|
||||
*
|
||||
* `requested` reaches here from the client (the mailbox picker's `?account=`) and is verified against disk
|
||||
* before it is joined, never sanitised into shape. That matters because the socket's caller ends in
|
||||
* `mkdirSync`: an unrecognised value would not fail, it would CREATE a directory named after whatever
|
||||
* arrived. The list's unified view uses the sentinel `all`, which is the value most likely to show up by
|
||||
* accident.
|
||||
*
|
||||
* Falls back to the most recently synced account, and to the accounts root when there is no account at
|
||||
* all — a real state for a fresh install, and a directory that exists rather than one invented here.
|
||||
*
|
||||
* Pure: no mkdir, no writes. The caller that needs the directory to exist says so itself.
|
||||
*/
|
||||
export function resolveEmailAccountDir(ownerEmail: string, requested?: string): string {
|
||||
const named = requested?.trim();
|
||||
const known = named && existsSync(join(getEmailAccountsDir(ownerEmail), named, 'emails.db')) ? named : null;
|
||||
const account = known ?? findEmailAccountName(ownerEmail);
|
||||
return account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
|
||||
}
|
||||
|
||||
// Sanitises a display username or email into a bare, lowercase, shell-safe token. The name and the
|
||||
// 32-char Linux limit are the last trace of the per-container architecture, where this really did name
|
||||
// a Linux user inside the user's container. Nothing creates a Linux user now — the value is carried
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getEmailAccountsDir, resolveEmailAccountDir } from './data-path';
|
||||
|
||||
/**
|
||||
* Where an email chat runs.
|
||||
*
|
||||
* This is regression cover rather than API cover. The address had two resolvers — the chat socket's, for
|
||||
* running the turn, and `GET /chat/sessions`'s, for listing the conversations — and only the socket knew
|
||||
* about the `email` context. The list fell through to the owner's home, so the picker above an email
|
||||
* composer offered conversations from somewhere else and a message sent into the mailbox could not be
|
||||
* found in the list directly above it. One resolver now, and these are the three answers it owes.
|
||||
*/
|
||||
|
||||
const OWNER = 'resolve-email-dir-test@officer.dev';
|
||||
const ROOT = getEmailAccountsDir(OWNER);
|
||||
|
||||
const account = (name: string, withDb = true) => {
|
||||
const dir = join(ROOT, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
if (withDb) writeFileSync(join(dir, 'emails.db'), '');
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe('resolveEmailAccountDir', () => {
|
||||
beforeAll(() => {
|
||||
rmSync(ROOT, { recursive: true, force: true });
|
||||
mkdirSync(ROOT, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
test('a named mailbox that exists is the answer', () => {
|
||||
const dir = account('someone@gmail.com');
|
||||
expect(resolveEmailAccountDir(OWNER, 'someone@gmail.com')).toBe(dir);
|
||||
});
|
||||
|
||||
test("`all` is the list's sentinel, not an address — it never becomes a directory", () => {
|
||||
// The caller ends in mkdirSync, so accepting this would CREATE a mailbox named `all` and run turns in
|
||||
// it. The panel drops the sentinel; this is the half that does not depend on the panel remembering to.
|
||||
expect(resolveEmailAccountDir(OWNER, 'all')).not.toBe(join(ROOT, 'all'));
|
||||
expect(resolveEmailAccountDir(OWNER, 'all')).toBe(join(ROOT, 'someone@gmail.com'));
|
||||
});
|
||||
|
||||
test('a mailbox with no database is not a mailbox', () => {
|
||||
account('half-configured@gmail.com', false);
|
||||
expect(resolveEmailAccountDir(OWNER, 'half-configured@gmail.com')).toBe(join(ROOT, 'someone@gmail.com'));
|
||||
});
|
||||
|
||||
test('no account at all resolves to the accounts root, which exists', () => {
|
||||
const empty = 'no-accounts-test@officer.dev';
|
||||
expect(resolveEmailAccountDir(empty)).toBe(getEmailAccountsDir(empty));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildHonoApp } from './hono';
|
||||
import { CustomError } from './custom-errors';
|
||||
|
||||
/*
|
||||
The property: every error the Hono app renders is JSON — `{ error: string }`, or the object a
|
||||
CustomError's returnValue supplied. Success responses were always JSON; a text/plain failure was the
|
||||
one response every client had to special-case, and the one that broke `| jq`.
|
||||
|
||||
The app under test is the REAL one: `buildHonoApp` is pure, and Hono builds its matcher lazily on the
|
||||
first fetch, so a throwing route added here reaches the same `onError` production traffic does.
|
||||
*/
|
||||
|
||||
const appThatThrows = (error: Error) => {
|
||||
const app = buildHonoApp();
|
||||
app.get('/boom', () => {
|
||||
throw error;
|
||||
});
|
||||
return app;
|
||||
};
|
||||
|
||||
const request = (app: ReturnType<typeof appThatThrows>) => app.request('/boom');
|
||||
|
||||
describe('onError renders JSON', () => {
|
||||
test('a CustomError with a message → { error: message }, its own status', async () => {
|
||||
const res = await request(appThatThrows(new CustomError(404, 'no such playlist')));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
expect(await res.json()).toEqual({ error: 'no such playlist' });
|
||||
});
|
||||
|
||||
// An object returnValue passes through UNWRAPPED — callers read its fields (TOO_MANY_REQUESTS
|
||||
// carries `retryAfter`), so `{ error: {...} }` would break them.
|
||||
test('a CustomError with an object returnValue → that object, untouched', async () => {
|
||||
const res = await request(appThatThrows(new CustomError(429, 'Too many requests', { retryAfter: 30 })));
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
expect(await res.json()).toEqual({ retryAfter: 30 });
|
||||
});
|
||||
|
||||
// A string returnValue was the text/plain case before 2026-08-25; it takes the same shape as a message.
|
||||
test('a CustomError with a string returnValue → { error: returnValue }', async () => {
|
||||
const res = await request(appThatThrows(new CustomError(400, 'ignored', 'the field is required')));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
expect(await res.json()).toEqual({ error: 'the field is required' });
|
||||
});
|
||||
|
||||
// A plain Error is a bug, not a message for the caller — the body must be JSON but must NOT leak
|
||||
// error.message, which nobody wrote for an outside reader.
|
||||
test('a plain Error → 500, { error: "Internal Server Error" }, message not leaked', async () => {
|
||||
const res = await request(appThatThrows(new Error('secret internal detail')));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
expect(await res.json()).toEqual({ error: 'Internal Server Error' });
|
||||
});
|
||||
});
|
||||
+21
-59
@@ -25,17 +25,13 @@ import { dashboardsRouter } from './api/dashboards';
|
||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||
import { devProxyRouter } from './api/dev-proxy/router';
|
||||
import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders, pluginForRootPath } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { subAgentsRouter } from './api/sub-agents/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
import { davSyncRouter } from './api/dav/sync-router';
|
||||
import { davRouter } from './api/dav/router';
|
||||
import { claimIosProfile } from './api/dav/ios-profile';
|
||||
import { notifyRouter } from './api/notify/router';
|
||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||
import { activityRouter } from './api/activity/router';
|
||||
@@ -104,17 +100,21 @@ const corsMiddleware = cors({
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// The DAV doors are the one place CORS must NOT run, because hono's cors() answers every OPTIONS itself
|
||||
// as a preflight — 204, no body, no DAV headers — and never calls the route beneath it.
|
||||
// A plugin's TOP-LEVEL paths are the one place CORS must NOT run, because hono's cors() answers every
|
||||
// OPTIONS itself as a preflight — 204, no body, none of the route's own headers — and never calls the
|
||||
// route beneath it.
|
||||
//
|
||||
// OPTIONS is not a preflight to a DAV client. It is how the client asks what the server can do, and the
|
||||
// answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook` header. iOS sends it during account
|
||||
// setup and refuses the account when it is missing, reporting the failure as "Cannot connect using SSL" —
|
||||
// a message about TLS for a problem that has nothing to do with TLS, which is how this cost an evening.
|
||||
// The case that taught this is DAV. OPTIONS is not a preflight to a DAV client: it is how the client asks
|
||||
// what the server can do, and the answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook`
|
||||
// header. iOS sends it during account setup and refuses the account when it is missing, reporting the
|
||||
// failure as "Cannot connect using SSL" — a message about TLS for a problem that has nothing to do with
|
||||
// TLS, which is how this cost an evening.
|
||||
//
|
||||
// Nothing is lost by skipping it: a CalDAV client is not a browser and has no origin to check.
|
||||
const isDavPath = (path: string) =>
|
||||
path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav';
|
||||
// Nothing is lost by skipping it. A top-level path exists because something that is NOT a browser has to
|
||||
// reach it — a DAV client, a phone, a protocol handler — and those have no origin to check. This was
|
||||
// `isDavPath`, a literal list of four paths, until caldav became a plugin and took them with it; asking
|
||||
// the claim registry means the carve-out follows whoever owns the route instead of naming it here.
|
||||
const isPluginRootPath = (path: string) => pluginForRootPath(path) !== null;
|
||||
|
||||
// The mount table, as DATA rather than forty statements.
|
||||
//
|
||||
@@ -150,8 +150,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
|
||||
['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
|
||||
['/notify', notifyRouter], // core since 2026-08-19 — the one outbound-notification surface
|
||||
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
||||
['/system-monitor', systemMonitorRouter],
|
||||
@@ -195,7 +193,7 @@ export const UNPROTECTED_API_PREFIXES: string[] = [
|
||||
export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> {
|
||||
const app = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||
app.use((ctx, next) => (isPluginRootPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||
|
||||
/*
|
||||
A plugin's PUBLIC router, mounted above everything — the gate, `userMiddleware`, the whole platform API.
|
||||
@@ -253,45 +251,6 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
|
||||
// OWNER's token on `/session` and requires the cookie that issues on everything else.
|
||||
app.route('/api/dev-proxy', devProxyRouter);
|
||||
|
||||
// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is:
|
||||
// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a
|
||||
// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see
|
||||
// api/dav/sync-router.ts.
|
||||
// The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration
|
||||
// order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to
|
||||
// offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the
|
||||
// authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts.
|
||||
app.get('/dav/provision/:file', (ctx) => {
|
||||
const file = ctx.req.param('file');
|
||||
const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null;
|
||||
const body = token ? claimIosProfile(token) : null;
|
||||
// Expired, already used, or never existed — all the same 404. There is nothing useful to tell a
|
||||
// caller who has the wrong token, and distinguishing the cases would confirm that a token once existed.
|
||||
if (!body) return ctx.text('not found', 404);
|
||||
|
||||
return new Response(body as unknown as BodyInit, {
|
||||
headers: {
|
||||
// Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or
|
||||
// text/xml the file downloads and the OS does nothing with it.
|
||||
'Content-Type': 'application/x-apple-aspen-config',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.route('/dav', davSyncRouter);
|
||||
|
||||
// Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of
|
||||
// fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any
|
||||
// credential, so they must sit above every auth gate. Without them iOS in particular degrades to
|
||||
// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel
|
||||
// worse than the commercial product it is replacing.
|
||||
// `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually
|
||||
// wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real
|
||||
// client while looking perfectly healthy in a browser.
|
||||
app.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
|
||||
app.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
|
||||
|
||||
const protectedRouter = createRouter();
|
||||
protectedRouter.use(bodyParser());
|
||||
/*
|
||||
@@ -318,21 +277,24 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
|
||||
|
||||
app.route('/api', protectedRouter);
|
||||
|
||||
// Every error is JSON, shape `{ error: string }` — success is already JSON, so a text/plain failure
|
||||
// was the one response a client had to special-case. An OBJECT returnValue passes through untouched:
|
||||
// callers rely on its fields, and wrapping it would break them.
|
||||
app.onError((error, ctx) => {
|
||||
if (error instanceof CustomError) {
|
||||
if (error.returnValue) {
|
||||
if (typeof error.returnValue === 'string') {
|
||||
return ctx.text(error.returnValue, error.statusCode);
|
||||
return ctx.json({ error: error.returnValue }, error.statusCode);
|
||||
} else {
|
||||
return ctx.json(error.returnValue, error.statusCode);
|
||||
}
|
||||
}
|
||||
return ctx.text(error.message, error.statusCode);
|
||||
return ctx.json({ error: error.message }, error.statusCode);
|
||||
}
|
||||
|
||||
console.error('Unexpected error:', error.message);
|
||||
console.log(error.stack);
|
||||
return ctx.text('Internal Server Error', 500);
|
||||
return ctx.json({ error: 'Internal Server Error' }, 500);
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
+18
-2
@@ -2,7 +2,15 @@ import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { secretStorePath } from 'officerdb/secret-store';
|
||||
import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, getPluginsDir, toShellUsername } from './data-path';
|
||||
import {
|
||||
DATA_PATH,
|
||||
USER_DIRS,
|
||||
getOwnerHomeDir,
|
||||
getCommandersDir,
|
||||
getPluginsDir,
|
||||
getBotsDir,
|
||||
toShellUsername,
|
||||
} from './data-path';
|
||||
|
||||
// Real Linux accounts for members, so the surfaces that execute code can run as them.
|
||||
//
|
||||
@@ -347,7 +355,7 @@ export async function confineUserTree(params: {
|
||||
// dies on its own cwd before running anything, and it reads as the agent being broken.
|
||||
const entries = await readdir(accountDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'home' || entry.name === 'commanders' || entry.name === 'plugins') continue;
|
||||
if (['home', 'commanders', 'plugins', 'bots'].includes(entry.name)) continue;
|
||||
if (!entry.isDirectory()) continue;
|
||||
await chmod(join(accountDir, entry.name), 0o700);
|
||||
}
|
||||
@@ -450,6 +458,14 @@ export async function confineUserTree(params: {
|
||||
const closePlugins = await run(['sudo', '-n', 'chmod', '700', pluginsDir]);
|
||||
if (!closePlugins.ok) return { ok: false, error: `chmod of ${pluginsDir} failed: ${closePlugins.out}` };
|
||||
|
||||
// And `bots`, for the same reason a third time: a member's bot runs as the member, in this directory.
|
||||
const botsDir = getBotsDir(params.email);
|
||||
if (!existsSync(botsDir)) await mkdir(botsDir, { recursive: true });
|
||||
const giveBots = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, botsDir]);
|
||||
if (!giveBots.ok) return { ok: false, error: `chown of ${botsDir} failed: ${giveBots.out}` };
|
||||
const closeBots = await run(['sudo', '-n', 'chmod', '700', botsDir]);
|
||||
if (!closeBots.ok) return { ok: false, error: `chmod of ${botsDir} failed: ${closeBots.out}` };
|
||||
|
||||
// ── One directory where container bind mounts can live ──
|
||||
//
|
||||
// Created for every account, not only the ones that get a daemon: it is two `install` calls, and
|
||||
|
||||
@@ -49,8 +49,10 @@ describe('resolving what an account holds', () => {
|
||||
});
|
||||
|
||||
test('an app grant is honoured at the level given', () => {
|
||||
expect(resolve(member(), grants(['calendar', 'read'])).grants.get('calendar')).toBe('read');
|
||||
expect(resolve(member(), grants(['calendar', 'write'])).grants.get('calendar')).toBe('write');
|
||||
// `memos`, not `calendar` — calendar left the registry with the caldav plugin on 2026-08-27 and is
|
||||
// registered at runtime now, so a static test cannot see it. Any `kind: 'app'` entry makes the point.
|
||||
expect(resolve(member(), grants(['memos', 'read'])).grants.get('memos')).toBe('read');
|
||||
expect(resolve(member(), grants(['memos', 'write'])).grants.get('memos')).toBe('write');
|
||||
});
|
||||
|
||||
// The rule that only exists because getOwnerHomeDir ignores its argument: a confined grant with no Linux
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user