Compare commits
31
Commits
eb1fd8c31a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 | ||
|
|
fe0012635a | ||
|
|
547662842b |
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
|
||||
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
|
||||
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||
broken panel or an endless spinner rather than a clean refusal.
|
||||
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
# Offscale — the first real plugin
|
||||
|
||||
**Status: LIVE DOCUMENT, opened 2026-08-14.** Decisions and findings from the session that started the
|
||||
plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||
|
||||
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
|
||||
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
|
||||
|
||||
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
|
||||
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
|
||||
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
|
||||
stock. The distinct name marks a distinct product, not a badge on someone else's.
|
||||
|
||||
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
|
||||
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
|
||||
|
||||
---
|
||||
|
||||
## The reframe
|
||||
|
||||
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
|
||||
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
|
||||
intuition is that it is one plugin requiring two sidecars.
|
||||
|
||||
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
|
||||
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
|
||||
written on runs five.
|
||||
|
||||
### Two words, because "core" was doing two jobs
|
||||
|
||||
- **baseline** — what a fresh install actually runs
|
||||
- **first-party** — what Officer Dev publishes
|
||||
|
||||
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
|
||||
"is X core?" a question with two answers.
|
||||
|
||||
---
|
||||
|
||||
## What a plugin is made of
|
||||
|
||||
Combined per plugin as needed. **Only `meta` and the ID are always required.**
|
||||
|
||||
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
|
||||
- an **ID** (see below)
|
||||
- a **sidecar**
|
||||
- a **backend router** and its routes
|
||||
- a **db schema**
|
||||
- **default permissions per user group**
|
||||
- what it stores in the **secret store**, and whether that is per-user or plugin-global
|
||||
- a **frontend router**, its routes, and the frontend code
|
||||
- how it **mounts into the file browser context menu**
|
||||
- a set of **capabilities added to officer-items**
|
||||
- **plugin settings page** definitions
|
||||
- an accompanying **mobile app**
|
||||
|
||||
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
|
||||
mount, whether its sidecar is in the ecosystem file, and so on.
|
||||
|
||||
### What offscale needs
|
||||
|
||||
db schema · backend router + routes · frontend router + routes · sidecar.
|
||||
|
||||
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
|
||||
|
||||
---
|
||||
|
||||
## Identity and routing
|
||||
|
||||
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
|
||||
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
|
||||
across two namespaces means whichever is weaker becomes the real attack surface.
|
||||
|
||||
**Uniqueness comes from two mechanisms**, because one is not enough:
|
||||
|
||||
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
|
||||
generic as `notes` gets refused: it is a name Officer Dev may want later.
|
||||
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
|
||||
Needed because a private plugin never asks the marketplace anything.
|
||||
|
||||
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
|
||||
restrictions; publishing is what invites review.
|
||||
|
||||
### Mount prefixes
|
||||
|
||||
```
|
||||
first-party /api/<app-name> e.g. /api/offscale
|
||||
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
|
||||
```
|
||||
|
||||
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
|
||||
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
|
||||
|
||||
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
|
||||
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
|
||||
special case in the router, a bypassed check, a different install branch — first-party and third-party
|
||||
become two systems, and only one of them gets tested.
|
||||
|
||||
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
|
||||
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
|
||||
adding core routes forever without breaking installs.
|
||||
|
||||
---
|
||||
|
||||
## The database
|
||||
|
||||
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||
|
||||
### A Postgres schema per plugin was tested and rejected
|
||||
|
||||
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
|
||||
earns. Recorded so nobody re-runs the experiment:
|
||||
|
||||
| Property | Result |
|
||||
| ----------------------------------------------------------------- | ------------------------- |
|
||||
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
|
||||
| Cross-schema FK to `public.users` | works |
|
||||
| Partial unique index preserved | works |
|
||||
| Push is idempotent, no spurious re-creation | works |
|
||||
| Cascade delete across the schema boundary | works |
|
||||
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
|
||||
|
||||
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
|
||||
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
|
||||
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
|
||||
naming the schema in `schemaFilter` made the identical push work.
|
||||
|
||||
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
|
||||
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
|
||||
costume of a normal result.
|
||||
|
||||
---
|
||||
|
||||
## Mounting — rebuild and swap, at runtime
|
||||
|
||||
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
|
||||
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
|
||||
|
||||
### What was actually tested
|
||||
|
||||
| Router | `app.route()` after serving has begun |
|
||||
| -------------------------------- | --------------------------------------------------------------------- |
|
||||
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
|
||||
| `RegExpRouter` | **throws**, same reason |
|
||||
| `TrieRouter` | works |
|
||||
| `PatternRouter` | works |
|
||||
|
||||
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
|
||||
remove a route**, which uninstall needs.
|
||||
|
||||
### The approach that solves both
|
||||
|
||||
Rebuild the whole app from the current plugin set and **reassign the variable**:
|
||||
|
||||
```ts
|
||||
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
|
||||
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
|
||||
|
||||
// install: app = buildApp([...installed, 'offscale'])
|
||||
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
|
||||
```
|
||||
|
||||
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
|
||||
|
||||
```
|
||||
no plugins /offscale/x -> 404 | /core -> 200
|
||||
installed /offscale/x -> 200 | /core -> 200
|
||||
uninstalled /offscale/x -> 404 | /core -> 200
|
||||
```
|
||||
|
||||
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
|
||||
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
|
||||
|
||||
### The one line that has to change
|
||||
|
||||
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
|
||||
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
|
||||
This is the whole mechanical cost.
|
||||
|
||||
### Websockets are a separate table, and they reload
|
||||
|
||||
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
|
||||
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
|
||||
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
|
||||
|
||||
```
|
||||
before reload /api/offscale/ws -> refused | /core -> 200
|
||||
after reload /api/offscale/ws -> CONNECTED | /core -> 200
|
||||
after remove /api/offscale/ws -> refused | /core -> 200
|
||||
```
|
||||
|
||||
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
|
||||
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
|
||||
|
||||
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
|
||||
before a plugin install can interrupt somebody's terminal.
|
||||
|
||||
The two tables remain two lists, which is the same seam as the totality bug below.
|
||||
|
||||
### What this means for `assertCapabilityTotality`
|
||||
|
||||
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
|
||||
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
|
||||
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
|
||||
where mounting actually happens instead of once at start-up.
|
||||
|
||||
Two things it must survive, both live today:
|
||||
|
||||
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
|
||||
uninstalled plugin's routes are not mounted, so nothing can reach them.
|
||||
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
|
||||
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
|
||||
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
|
||||
still needs pointing at reality.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
|
||||
|
||||
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
|
||||
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
|
||||
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||
platform's to assign.
|
||||
|
||||
### The platform grants read or write. Everything richer is the plugin's own job
|
||||
|
||||
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
|
||||
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
|
||||
the surface; `write` permits everything.
|
||||
|
||||
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
||||
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
|
||||
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
|
||||
means; the platform only knows whether this account got through the door.
|
||||
|
||||
### Offscale v1 uses that model exactly, with nothing added
|
||||
|
||||
One shared resource, role-gated:
|
||||
|
||||
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
|
||||
- **write** — can change them, including deleting a server the owner registered
|
||||
|
||||
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
|
||||
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
|
||||
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
|
||||
use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||
|
||||
Two implementation consequences, both inside the plugin:
|
||||
|
||||
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||
|
||||
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
|
||||
- `POST /ssh-test` — a reachability probe that mutates nothing
|
||||
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
|
||||
|
||||
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
|
||||
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
|
||||
delete, policy `PUT` — is a genuine write.
|
||||
|
||||
### Music is where the richer model gets designed
|
||||
|
||||
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
|
||||
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
|
||||
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
|
||||
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
|
||||
where offscale's is just "the owner's".
|
||||
|
||||
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
|
||||
|
||||
### Three different things are called "capability" here
|
||||
|
||||
A manifest needs three names, not one:
|
||||
|
||||
1. `capabilities/registry.ts` — **permissions** (`headscale`, `vpn`)
|
||||
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
|
||||
3. `sidecar-registry` `capabilities: ['music']` — **routing keys** for `sendCommand`
|
||||
|
||||
Offscale needs (1) and (3), and not (2).
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
Two stores, and a plugin author will reach for the wrong one unless told:
|
||||
|
||||
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
|
||||
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
|
||||
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
|
||||
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
|
||||
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
|
||||
parties touch it.
|
||||
|
||||
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
|
||||
the host:
|
||||
|
||||
```ts
|
||||
import { db } from '../db'; // the connection
|
||||
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
|
||||
```
|
||||
|
||||
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
|
||||
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
|
||||
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
|
||||
the pilot rather than being invented.
|
||||
|
||||
---
|
||||
|
||||
## `/api/vpn` is being deleted
|
||||
|
||||
Officer had two headscale surfaces:
|
||||
|
||||
| | `/api/vpn` | `/api/headscale` |
|
||||
| ---------- | ---------------------------------------- | -------------------------------------- |
|
||||
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
|
||||
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||
| surface | one route, `POST /enroll` | the whole admin API |
|
||||
|
||||
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
|
||||
anywhere.** Verified against the mobile monorepo:
|
||||
|
||||
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
|
||||
2. `enroll()` is reached only via `if (embedded) await enroll()`
|
||||
3. `embedded` is optional and defaults to `false`
|
||||
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
|
||||
|
||||
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
|
||||
does the Officer web app. The live database holds no `vpn` grants.
|
||||
|
||||
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
|
||||
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
|
||||
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
|
||||
|
||||
### Everything collapses to one namespace
|
||||
|
||||
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
|
||||
contract has no counterparty.
|
||||
|
||||
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
|
||||
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
|
||||
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
|
||||
|
||||
- **phone → Companion** — untouched by anything here
|
||||
- **web admin → Officer → sidecar** — ours to rename freely
|
||||
|
||||
### There are THREE components, not two
|
||||
|
||||
Easy to miss, and worth stating because two of them contain the word "enroll":
|
||||
|
||||
| Component | Repo | Enrolment surface |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------------ |
|
||||
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
|
||||
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
|
||||
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
|
||||
|
||||
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
|
||||
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
|
||||
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
|
||||
|
||||
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
|
||||
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
|
||||
in the invite (`https://<domain>/officer-api`).
|
||||
|
||||
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
|
||||
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
|
||||
`POST /_officer/enroll` handler below it is dead.
|
||||
|
||||
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
|
||||
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
|
||||
The exemption must be declared with a reason or the boot check refuses. Not needed today.
|
||||
|
||||
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
|
||||
is correct. The invite flow supersedes it completely:
|
||||
|
||||
1. the Officer headscale app holds an admin API key for the Headscale server
|
||||
2. from it the owner mints an **invite** — a URL pointing at the Companion
|
||||
3. the Companion turns that into the redirect the phone app claims
|
||||
4. the device joins
|
||||
|
||||
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
||||
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
|
||||
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
|
||||
|
||||
---
|
||||
|
||||
## What headscale actually is — the inventory
|
||||
|
||||
Read end to end on 2026-08-14. This is what has to move.
|
||||
|
||||
### Backend — 2,406 lines
|
||||
|
||||
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||
|
||||
Three things worth knowing before touching it:
|
||||
|
||||
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
|
||||
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
|
||||
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
|
||||
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
|
||||
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
|
||||
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
|
||||
object back. One file containing all of it is the model for a plugin's client layer, not something to
|
||||
undo.
|
||||
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
|
||||
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
|
||||
branches on whether the body parses.
|
||||
|
||||
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
|
||||
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
|
||||
|
||||
### Frontend — 29 files, 27 endpoints
|
||||
|
||||
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||
Diagnostics · Console.
|
||||
|
||||
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
|
||||
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
|
||||
|
||||
The whole frontend↔host coupling, which becomes the plugin API:
|
||||
|
||||
| Import | Why it matters |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
|
||||
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||
| `AppRegistryMeta` | the panel-contribution contract |
|
||||
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
||||
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
|
||||
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||
|
||||
### `assist.ts` travels, but stays unwired
|
||||
|
||||
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||
later. Do not tidy it away as unused code.
|
||||
|
||||
---
|
||||
|
||||
## The manifest — proposal
|
||||
|
||||
Written against offscale rather than invented in the abstract, on the principle that a field list designed
|
||||
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
|
||||
is the floor, not the ceiling.
|
||||
|
||||
```ts
|
||||
// plugins/offscale/manifest.ts
|
||||
export const manifest = {
|
||||
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
|
||||
publisher: 'officerdev',
|
||||
/** The plugin's own semver. Updates compare against this. */
|
||||
version: '1.0.0',
|
||||
/** Which platforms this build is good for. Refused at install when it does not match. */
|
||||
platform: '>=1.0.0 <2.0.0',
|
||||
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
|
||||
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
|
||||
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
|
||||
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
label: 'Offscale',
|
||||
description: 'The tailnet: machines, routes and ACLs',
|
||||
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
|
||||
ownerOnly: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
```
|
||||
|
||||
### Everything the tree can say, the tree says
|
||||
|
||||
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
|
||||
a human chose. Everything structural is convention, and **presence is the declaration**:
|
||||
|
||||
| Path | Means |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
|
||||
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
|
||||
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
|
||||
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
|
||||
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
|
||||
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
|
||||
|
||||
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
|
||||
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
|
||||
duplication that could only ever drift.
|
||||
|
||||
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
|
||||
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
|
||||
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
|
||||
field that can contradict the file it describes.
|
||||
|
||||
### Install asks nothing, and that is the default
|
||||
|
||||
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
|
||||
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
|
||||
and no external service to point at.
|
||||
|
||||
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
|
||||
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
|
||||
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
|
||||
how it works today.
|
||||
|
||||
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
|
||||
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
|
||||
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
|
||||
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
|
||||
|
||||
### Dropped from the first draft
|
||||
|
||||
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
|
||||
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
|
||||
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
|
||||
when something consumes it.
|
||||
- **`kind`** — see below.
|
||||
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
|
||||
|
||||
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
|
||||
means a tile. Fine for offscale; add a flag the first time something needs it.
|
||||
|
||||
### `admin` has to be allowed, and the pilot proved it immediately
|
||||
|
||||
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
|
||||
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
|
||||
|
||||
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
|
||||
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
|
||||
nothing is gained by forbidding it.
|
||||
|
||||
Corrected rule:
|
||||
|
||||
| Kind | May a plugin declare it? | Why |
|
||||
| ----------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `app` | yes | the ordinary grantable surface |
|
||||
| `admin` | yes | self-restriction, never an escalation |
|
||||
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
|
||||
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
|
||||
| `confined` | **no** | implies a Linux identity the platform provisions |
|
||||
|
||||
### One function decides the prefix
|
||||
|
||||
`publisher` is the only input, so first-party and third-party cannot become two code paths:
|
||||
|
||||
```ts
|
||||
const mountPrefix = (m: Manifest) =>
|
||||
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
|
||||
```
|
||||
|
||||
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
|
||||
|
||||
### Notes on the fields
|
||||
|
||||
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
|
||||
everything else is bun. One plugin already needs it, so it is not speculative generality.
|
||||
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
|
||||
1.0 is expected before anyone outside Officer Dev writes a plugin.
|
||||
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
|
||||
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
|
||||
degrade. This is for the human reading the store.
|
||||
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
|
||||
- **No `migrations`.** Deferred; a field can be added without redesign.
|
||||
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
|
||||
|
||||
---
|
||||
|
||||
## What is built, as of 2026-08-14
|
||||
|
||||
The plugin system works end to end for a plugin that has an `api/router.ts`. Verified against a running
|
||||
server, with **no restart at any point**:
|
||||
|
||||
```
|
||||
/api/example/ping BEFORE install 404
|
||||
AFTER install 200 {"plugin":"example","ok":true}
|
||||
AFTER disable 404
|
||||
AFTER enable 200
|
||||
AFTER uninstall 404
|
||||
core routes throughout 200
|
||||
```
|
||||
|
||||
| Piece | Where |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| Manifest type, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||
| Disk ⋈ database, and the rebuild | `servers/plugins/mount.ts` |
|
||||
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||
| The closure that makes the swap take effect | `server.tsx`, the `/api/*` route |
|
||||
| Install state | `plugin_installs` (`officer_db/src/plugins/`) |
|
||||
| The four verbs | `servers/api/plugins/router.ts`, owner-only |
|
||||
| The screen | `/plugins` — two panels, `?selected=` |
|
||||
| The reference plugin | `plugins/example/` — meant to be read |
|
||||
|
||||
### Not wired yet
|
||||
|
||||
- **The schema push.** A plugin with `db/schema.ts` installs, but its tables are not created. Marked
|
||||
`[open]` in the router.
|
||||
- **The sidecar's PM2 entry.** A plugin with `sidecar/` installs, but no process starts. This is the same
|
||||
hole `app-store/pm2.ts:23-29` documents for the app store, and it is the next thing to build.
|
||||
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||
still the six hardcoded providers.
|
||||
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` remains the core list, so plugin mounts are
|
||||
not covered by the boot check — and the check is reading the wrong list anyway (see below). The
|
||||
assertion wants moving into `buildHonoApp`, which is now the single place routes are mounted.
|
||||
|
||||
### Deliberately not done
|
||||
|
||||
**Offscale is not extracted.** The infrastructure is ready for it, but moving it means deleting working
|
||||
code across ~50 files, and that should happen with someone watching rather than unattended.
|
||||
|
||||
---
|
||||
|
||||
## The state of the app store, as found
|
||||
|
||||
It **is** the plugin system, roughly 90% built, with one structural hole.
|
||||
|
||||
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
|
||||
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
|
||||
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
|
||||
|
||||
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
|
||||
> and it is not built."_
|
||||
|
||||
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
|
||||
written, assets publish, the dock tile appears — and the sidecar never starts.
|
||||
|
||||
Also found:
|
||||
|
||||
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
|
||||
`bun db:push`.
|
||||
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
|
||||
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
|
||||
that the directory exists.
|
||||
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
|
||||
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
|
||||
means editing two files and rebuilding.
|
||||
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
|
||||
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
|
||||
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
|
||||
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
|
||||
renders against it. Not to be confused with any of the above.
|
||||
|
||||
---
|
||||
|
||||
## Where the code lives
|
||||
|
||||
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
|
||||
|
||||
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
|
||||
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
|
||||
used in addition rather than instead — they span orgs, which matters because browser extensions under
|
||||
`extensions/` may become plugins later.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
|
||||
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
|
||||
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
|
||||
platform checkout, so dev-time and build-time are the same mechanism.
|
||||
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
|
||||
something has to apply schema changes over time. Cheap now, miserable to retrofit.
|
||||
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
|
||||
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
|
||||
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
|
||||
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
|
||||
future, not before.
|
||||
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
|
||||
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
|
||||
→ `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
|
||||
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
|
||||
Console section does not travel with the plugin.
|
||||
5. **`service_connections.service` namespacing** before third parties touch it.
|
||||
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
|
||||
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
|
||||
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
|
||||
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
|
||||
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||
//
|
||||
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||
export const router = createRouter();
|
||||
|
||||
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
|
||||
// the smallest thing that is still a real one: a manifest and one route.
|
||||
//
|
||||
// Everything structural is convention, so this directory IS the documentation:
|
||||
//
|
||||
// manifest.ts you are here — only what a directory listing cannot say
|
||||
// api/router.ts exports `router`; mounted at /api/example
|
||||
// db/schema.ts tables, if it had any (every name prefixed `example_`)
|
||||
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
|
||||
// web/Router.tsx a frontend, if it had one
|
||||
//
|
||||
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
|
||||
// code sits.
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Example',
|
||||
summary: 'The reference plugin — one route, nothing else',
|
||||
icon: 'Puzzle',
|
||||
color: '#94a3b8',
|
||||
|
||||
// Empty is meaningful: this plugin gates nothing of its own and is reachable by anyone who can reach
|
||||
// the platform. A plugin with a surface worth protecting declares a permission here instead.
|
||||
permissions: [],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// The reference sidecar: a long-lived process PM2 supervises.
|
||||
//
|
||||
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||
//
|
||||
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||
// installer, and adding a socket here would test Bun rather than that.
|
||||
|
||||
const name = 'officer-example';
|
||||
console.log(`[${name}] started (pid ${process.pid})`);
|
||||
|
||||
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
console.log(`[${name}] ${signal} — exiting`);
|
||||
clearInterval(beat);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -96,23 +96,24 @@ pkgs_core() {
|
||||
# separate decision from removing the tool that wanted it.
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
|
||||
fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||
echo gnupg git jq wget btop htop tree ripgrep fd eza
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -2311,6 +2311,41 @@ EOF
|
||||
ok "~/.local/bin and ~/.opencode/bin added to PATH"
|
||||
fi
|
||||
|
||||
# ── Keys ──
|
||||
#
|
||||
# This block existed only in `src/servers/shell-skel/zshrc`, the file the platform seeds into
|
||||
# PROVISIONED MEMBER accounts. The owner's .zshrc is assembled here instead, and never got it — so
|
||||
# the owner had a strictly worse shell than the members they provision: no ctrl-arrow, no
|
||||
# history-prefix search, no Home/End. Confirmed on this machine before writing it, with
|
||||
# `zsh -i -c bindkey`: the owner had `^[b`/`^[f` and nothing else.
|
||||
#
|
||||
# The two files are still separate — one is a template the platform copies, the other is an
|
||||
# idempotent append — but the KEYS have to agree, because a member and the owner sit at the same
|
||||
# web terminal and neither should have to learn which account they are on.
|
||||
if append_once "$ZSHRC" keybindings <<'EOF'
|
||||
bindkey -e
|
||||
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
|
||||
zle -N up-line-or-beginning-search
|
||||
zle -N down-line-or-beginning-search
|
||||
bindkey '^[[A' up-line-or-beginning-search
|
||||
bindkey '^[[B' down-line-or-beginning-search
|
||||
bindkey '^[[1;5C' forward-word
|
||||
bindkey '^[[1;5D' backward-word
|
||||
bindkey '^[[1;3C' forward-word
|
||||
bindkey '^[[1;3D' backward-word
|
||||
bindkey '^[[3~' delete-char
|
||||
bindkey '^[[H' beginning-of-line
|
||||
bindkey '^[[F' end-of-line
|
||||
bindkey '^[[1~' beginning-of-line
|
||||
bindkey '^[[4~' end-of-line
|
||||
bindkey '^H' backward-kill-word
|
||||
bindkey '^[^?' backward-kill-word
|
||||
bindkey '^[[3;5~' kill-word
|
||||
EOF
|
||||
then
|
||||
ok "shell keybindings added (ctrl/alt-arrow, history search, Home/End)"
|
||||
fi
|
||||
|
||||
# The eza aliases are GUARDED and the rest are not, for one reason: these
|
||||
# replace `ls`. An unguarded `alias ls='eza --icons'` on a machine where eza
|
||||
# failed to install leaves the owner with no working `ls` at all, in every new
|
||||
|
||||
@@ -446,6 +446,11 @@ if ! skip; then
|
||||
echo " network: ${OFFICER_NETWORK} (already there)"
|
||||
fi
|
||||
|
||||
# The client goes on the HOST, before any of the container work, because it is the half
|
||||
# that is not in the container. A member has their own Postgres role and no access to the
|
||||
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
|
||||
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
|
||||
|
||||
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
|
||||
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
|
||||
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
|
||||
|
||||
@@ -39,6 +39,73 @@ PG_DATABASE="${PG_DATABASE:-officer}"
|
||||
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
|
||||
PG_PORT="${PG_PORT:-5432}"
|
||||
|
||||
# ── The CLIENT, on the host, matching the server in the container ──
|
||||
#
|
||||
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
|
||||
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
|
||||
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
|
||||
# owner's Docker socket.
|
||||
#
|
||||
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
|
||||
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
|
||||
# version 16.x — aborting"). Ubuntu 24.04 ships client 16 against this 18 server, so the
|
||||
# archive package is not merely old, it is unusable for dumps. That is also why this lives
|
||||
# beside the server definition rather than in machine-setup's package list — one constant,
|
||||
# one place to bump.
|
||||
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
|
||||
|
||||
pg_client_installed() {
|
||||
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
|
||||
}
|
||||
|
||||
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
|
||||
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
|
||||
# just a more annoying one to operate.
|
||||
install_pg_client() {
|
||||
local major codename
|
||||
major="$(pg_client_major)"
|
||||
[[ -n "$major" ]] || {
|
||||
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
|
||||
return 1
|
||||
}
|
||||
|
||||
if pg_client_installed; then
|
||||
ok "psql ${major} already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
|
||||
[[ -n "$codename" ]] || {
|
||||
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
|
||||
return 1
|
||||
}
|
||||
|
||||
install -d -m 0755 /usr/share/postgresql-common/pgdg
|
||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
|
||||
warn "could not fetch the PostgreSQL signing key"
|
||||
return 1
|
||||
}
|
||||
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
|
||||
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
|
||||
>/etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
|
||||
warn "postgresql-client-${major} did not install"
|
||||
return 1
|
||||
}
|
||||
|
||||
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
|
||||
# matters is whether the binary is there AND is the version we asked for, because apt can
|
||||
# succeed while holding an older client back.
|
||||
pg_client_installed || {
|
||||
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
|
||||
return 1
|
||||
}
|
||||
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
|
||||
}
|
||||
|
||||
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
|
||||
ensure_docker_network() {
|
||||
docker_network_exists && return 1
|
||||
|
||||
@@ -44,7 +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"
|
||||
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
|
||||
)
|
||||
|
||||
write_ecosystem() {
|
||||
|
||||
@@ -24,6 +24,12 @@ bind - split-window -v
|
||||
unbind r
|
||||
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||
|
||||
# Meta keys are ESC-prefixed on the wire, and tmux waits `escape-time` to decide whether an incoming ESC is
|
||||
# a lone Escape or the start of one. The default is 500ms, so every Alt-chord below — and every Escape in
|
||||
# vim — pays half a second before anything happens. 10ms is enough to disambiguate a sequence that arrives
|
||||
# in one TCP frame, which over a websocket relay it always does.
|
||||
set -sg escape-time 10
|
||||
|
||||
# switch panes using Alt-arrow without prefix
|
||||
bind -n M-Left select-pane -L
|
||||
bind -n M-Right select-pane -R
|
||||
|
||||
@@ -64,6 +64,7 @@ export function App() {
|
||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
|
||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
|
||||
@@ -149,6 +149,7 @@ import {
|
||||
Clapperboard,
|
||||
GitBranch,
|
||||
Store,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@@ -181,6 +182,8 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
|
||||
// things that disappears when uninstalled.
|
||||
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
|
||||
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
|
||||
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
|
||||
//
|
||||
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
|
||||
// handler. This screen is the courtesy half of that.
|
||||
//
|
||||
// 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);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
|
||||
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
|
||||
// and destroy the side-by-side. See docs/navigation-audit.md.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './PluginsScreen';
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './AppStore';
|
||||
export * from './Plugins';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
|
||||
@@ -23,6 +23,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
||||
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
|
||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
|
||||
@@ -29,6 +29,7 @@ export * as schema from './schema';
|
||||
export * from './agent-panels';
|
||||
export * from './api-keys';
|
||||
export * from './app-store';
|
||||
export * from './plugins';
|
||||
export * from './auth';
|
||||
export * from './capabilities';
|
||||
export * from './chat-events';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { pluginInstalls } from './schema';
|
||||
|
||||
export type PluginInstall = typeof pluginInstalls.$inferSelect;
|
||||
|
||||
/** Every installed plugin, oldest first so the list is stable across renders. */
|
||||
export async function listPluginInstalls(): Promise<PluginInstall[]> {
|
||||
return db.select().from(pluginInstalls).orderBy(pluginInstalls.appName);
|
||||
}
|
||||
|
||||
export async function getPluginInstall(appName: string): Promise<PluginInstall | null> {
|
||||
const [row] = await db.select().from(pluginInstalls).where(eq(pluginInstalls.appName, appName)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an install, or update the version of one already there.
|
||||
*
|
||||
* Upsert rather than insert, because re-installing is how a plugin is upgraded: the code on disk moved,
|
||||
* and the row should follow it rather than refuse. `enabled` is deliberately NOT touched on the update
|
||||
* path — re-installing a plugin the owner had disabled must not silently switch it back on.
|
||||
*/
|
||||
export async function recordPluginInstall(appName: string, version: string): Promise<PluginInstall> {
|
||||
const [row] = await db
|
||||
.insert(pluginInstalls)
|
||||
.values({ appName, version })
|
||||
.onConflictDoUpdate({
|
||||
target: pluginInstalls.appName,
|
||||
set: { version, updatedAt: new Date() },
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function setPluginEnabled(appName: string, enabled: boolean): Promise<PluginInstall | null> {
|
||||
const [row] = await db
|
||||
.update(pluginInstalls)
|
||||
.set({ enabled, updatedAt: new Date() })
|
||||
.where(eq(pluginInstalls.appName, appName))
|
||||
.returning();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** Forget the install. Drops no tables and deletes no data — see the note in schema.ts. */
|
||||
export async function removePluginInstall(appName: string): Promise<boolean> {
|
||||
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
|
||||
return rows.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
// Which plugins are installed on this machine, and whether they should be mounted.
|
||||
//
|
||||
// ── Why a row is needed at all, when the code is already on disk ──
|
||||
//
|
||||
// Plugins live in the repository (`platform/plugins/<app-name>/`), so PRESENCE is not installation. A
|
||||
// developer working on a plugin has the directory there and has not installed anything; a plugin that
|
||||
// ships in a checkout should not mount itself because someone cloned it. The directory answers "what
|
||||
// could run here", this table answers "what does".
|
||||
//
|
||||
// ── Separate from `sidecar_installs`, deliberately ──
|
||||
//
|
||||
// That table belongs to the app store's model, where an install means provisioning a container or
|
||||
// pointing at a remote instance, and it carries `mode`, `compose_dir` and `completed_steps` to say so.
|
||||
// A plugin install has none of those: put the code there, push its schema, start its sidecar, mount its
|
||||
// routes. Reusing the table would have meant a `mode` that lies about every plugin. The two models
|
||||
// coexist until the app store is rebuilt on this one.
|
||||
//
|
||||
// ── No userId, for the same reason as `sidecar_installs` ──
|
||||
//
|
||||
// installed server-level, owner-only — this row
|
||||
// permitted per role — role_capabilities
|
||||
// configured per user — the plugin's own tables
|
||||
//
|
||||
// ── `enabled` is not `installed` ──
|
||||
//
|
||||
// Installed means the schema is pushed and the code is ready. Enabled means it should be mounted and its
|
||||
// sidecar running. Disabling is the reversible middle: routes come down, the process stops, and every
|
||||
// table and row it owns survives untouched. Uninstalling drops the row and unmounts, and still does not
|
||||
// delete data — dropping a plugin's tables is a separate, deliberate act with the cost shown.
|
||||
export const pluginInstalls = pgTable(
|
||||
'plugin_installs',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/**
|
||||
* The plugin's app name — its directory, its route segment and its table prefix, all the same string.
|
||||
* Text rather than an enum: adding a plugin must never be a schema change.
|
||||
*/
|
||||
appName: text('app_name').notNull(),
|
||||
/**
|
||||
* The manifest version at the moment it was installed.
|
||||
*
|
||||
* Kept so an upgrade has something to compare against, and so "installed" can be told from "installed,
|
||||
* then the code on disk moved underneath it" — which is the normal state on a developer's machine and
|
||||
* a thing worth being able to see rather than infer.
|
||||
*/
|
||||
version: text('version').notNull(),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One row per plugin, machine-wide. uniqueIndex rather than unique() — see databases/CLAUDE.md on
|
||||
// drizzle-kit re-creating named composite constraints on every push.
|
||||
uniqueIndex('uq_plugin_installs_app_name').on(t.appName),
|
||||
],
|
||||
);
|
||||
@@ -38,6 +38,7 @@ export * from './headscale/schema'; // headscale_servers
|
||||
// The app store itself, and the credentials it stores for what it installs. `app-store/effects.ts`
|
||||
// reads service_connections, so this is core however few plugins are installed.
|
||||
export * from './app-store/schema'; // sidecar_installs
|
||||
export * from './plugins/schema'; // plugin_installs — core: the plugin system is the platform's own
|
||||
export * from './service-connections/schema'; // service_connections
|
||||
|
||||
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
|
||||
|
||||
+7
-2
@@ -24,7 +24,6 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod
|
||||
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||
import { toShellUsername } from './servers/data-path';
|
||||
|
||||
|
||||
// Build static file routes from public/
|
||||
const publicRoutes: Record<string, (req: Request) => Response> = {};
|
||||
for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) {
|
||||
@@ -319,7 +318,13 @@ const server = serve({
|
||||
'/': officerWeb,
|
||||
'/*': officerWeb,
|
||||
'/api': honoServer.fetch,
|
||||
'/api/*': honoServer.fetch,
|
||||
// A CLOSURE, deliberately, and not the bound `honoServer.fetch`.
|
||||
//
|
||||
// Installing a plugin swaps the whole Hono app (`rebuildHonoApp` — Hono cannot add routes to a live
|
||||
// app, and cannot remove one at all). The bound method would capture whichever app existed when
|
||||
// `serve()` ran, so every rebuild after boot would be invisible and an install would silently do
|
||||
// nothing. Reading `honoServer` per request is what makes the reassignment the swap.
|
||||
'/api/*': (req: Request, server: unknown) => honoServer.fetch(req, server),
|
||||
},
|
||||
|
||||
websocket: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import type { TurnMessage } from '../chat/types';
|
||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||
import { renameClaudeSession } from '../chat/claude-sessions';
|
||||
import { renameClaudeSession, type ChatIdentity } from '../chat/claude-sessions';
|
||||
import { getAgentRunsDir, getOwnerHomeDir } from '../../data-path';
|
||||
import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files';
|
||||
import { logger } from '../chat/logger';
|
||||
@@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unkn
|
||||
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
|
||||
*/
|
||||
function titleRun(
|
||||
who: { email: string; home: string },
|
||||
who: ChatIdentity,
|
||||
cwd: string,
|
||||
claudeSessionId: string,
|
||||
agentName: string,
|
||||
@@ -172,7 +172,11 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
||||
if (msg.claudeSessionId) {
|
||||
run.claudeSessionId = msg.claudeSessionId;
|
||||
titleRun(
|
||||
{ email: params.user.email, home: homeDir },
|
||||
// `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which
|
||||
// discards the email it is given, so an agent run is always the owner's — its transcript is theirs
|
||||
// and readable directly. `agents` is an `execution` capability, so no other account reaches this.
|
||||
// If agent runs ever reach members, this and line 134 have to move together.
|
||||
{ email: params.user.email, home: homeDir, osUser: null, isOwner: true },
|
||||
cwd,
|
||||
msg.claudeSessionId,
|
||||
agent.name || agent.dirName,
|
||||
|
||||
@@ -37,14 +37,31 @@ import { readSttConfig } from '../server-settings/stt';
|
||||
* `resolveTurnIdentity` refuses: there is no
|
||||
* safe home to substitute, and the owner's is the one wrong answer.
|
||||
*
|
||||
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
|
||||
* before it is opened, not a live fix.
|
||||
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
|
||||
* used to stand at the top of this router.
|
||||
*/
|
||||
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
const resolved = await resolveHomeDir(user.id);
|
||||
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
|
||||
return { email: user.email, home: resolved.home };
|
||||
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
|
||||
}
|
||||
|
||||
// ── OpenCode is owner-only, temporarily ──
|
||||
//
|
||||
// The Claude harness earned its way to members: the turn runs as their Linux account, the credential and
|
||||
// transcripts are theirs, and every sidecar command refuses a session belonging to someone else. NONE of that
|
||||
// is true of OpenCode. One `opencode serve` runs as the SERVICE user for everyone, `sendOpenCodeStreaming`
|
||||
// accepts `userId`/`email`/`username` and forwards none of them, and its session store has no per-user
|
||||
// scoping at all — `loadOpenCodeSession(id)` takes an id and no identity.
|
||||
//
|
||||
// Two consequences, both reachable by any account holding the `chat` grant, which every role has by default:
|
||||
// a turn ran in the OWNER'S home as the owner, and any session on the box could be read, renamed or deleted
|
||||
// by id. `handleOpenCodeChat` carried a comment calling itself owner-only; nothing enforced it.
|
||||
//
|
||||
// So this is a stopgap, not a design: `who.isOwner` applied at every door below, until OpenCode carries an
|
||||
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the capability layer because
|
||||
// `chat` is one capability covering both harnesses, and splitting it would strand the grants already issued.
|
||||
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
|
||||
import { transcribeAudio } from '../stt/transcribe';
|
||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
|
||||
@@ -88,7 +105,9 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
const opencode = await listOpenCodeSessions(cwd);
|
||||
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
|
||||
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
|
||||
const opencode = who.isOwner ? await listOpenCodeSessions(cwd) : [];
|
||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return ctx.json({ sessions });
|
||||
});
|
||||
@@ -105,6 +124,9 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
// 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
|
||||
// from one that does not exist, which is the honest answer when the store has no notion of whose it is.
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
const detail = isOpenCodeSessionId(id)
|
||||
? await loadOpenCodeSession(id)
|
||||
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
|
||||
@@ -151,9 +173,11 @@ chatRouter.get('/live', async (ctx) => {
|
||||
const email = who.email;
|
||||
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
|
||||
// both registry calls swallow their errors and return [].
|
||||
// The Claude call is already scoped by userId; the OpenCode one has no such argument, so it is asked only
|
||||
// for the owner. A member's Live panel therefore shows their own turns and nothing else.
|
||||
const [live, liveOpenCode] = await Promise.all([
|
||||
sidecar.listLiveClaudeSessions(user.id),
|
||||
sidecar.listLiveOpenCodeSessions(),
|
||||
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
|
||||
]);
|
||||
const sessions = live.map((session) => {
|
||||
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
||||
@@ -213,6 +237,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
@@ -225,6 +250,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
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);
|
||||
const ok = isOpenCodeSessionId(id)
|
||||
? await renameOpenCodeSession(id, title.trim())
|
||||
: renameClaudeSession(who, cwd, id, title.trim());
|
||||
@@ -246,8 +272,12 @@ chatRouter.get('/tasks/:id', async (ctx) => {
|
||||
|
||||
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
|
||||
chatRouter.get('/models', async (ctx: Context) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
try {
|
||||
const models = await listChatModels();
|
||||
const all = await listChatModels();
|
||||
// Hiding these is a courtesy — the socket refuses them regardless — but offering a model that cannot run
|
||||
// is how a member ends up reporting "chat is broken" for a choice the UI made available.
|
||||
const models = who.isOwner ? all : all.filter((m) => m.provider === 'claude-code');
|
||||
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code', opencode: 'OpenCode Zen' };
|
||||
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import {
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
statSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
appendFileSync,
|
||||
openSync,
|
||||
readSync,
|
||||
closeSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import {
|
||||
appendTextAs,
|
||||
listTranscriptsAs,
|
||||
readHeadAs,
|
||||
readTailAs,
|
||||
readTextAs,
|
||||
removeAs,
|
||||
type AsUser,
|
||||
} from '../../read-as-user';
|
||||
|
||||
// ── Claude session store (source of truth) ──
|
||||
// The `claude` CLI persists every session as a JSONL transcript at
|
||||
@@ -41,10 +38,50 @@ export type ChatIdentity = {
|
||||
email: string;
|
||||
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
|
||||
home: string;
|
||||
/**
|
||||
* Whose identity to read a member's transcripts as — `null` for the owner. From `resolveHomeDir`.
|
||||
*
|
||||
* This said locating them "never needed this: their directories are 775". There are no 775 directories on
|
||||
* this path — `claude` creates `projects/` and every group at 700, which clamps the platform's ACL entry
|
||||
* to nothing exactly as mode 600 does for the files. So `readdirSync`, `statSync` and unlink all fail too,
|
||||
* and `existsSync` answers **false** rather than throwing, which is why it read as "no such session"
|
||||
* everywhere instead of as an error. See `read-as-user.ts`; the listing goes through `listTranscriptsAs`.
|
||||
*/
|
||||
osUser: string | null;
|
||||
/**
|
||||
* From `resolveHomeDir`. Carried as its own fact rather than inferred from `osUser === null` — that
|
||||
* equivalence holds today only because `resolveHomeDir` refuses a member without one, so reading it as
|
||||
* "is the owner" would silently become wrong the moment that refusal is relaxed.
|
||||
*
|
||||
* Used to gate the OpenCode harness, which is owner-only until it carries an identity. See `chat.ts`.
|
||||
*/
|
||||
isOwner: boolean;
|
||||
};
|
||||
|
||||
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
|
||||
|
||||
/**
|
||||
* Where a session's transcript actually is, or null.
|
||||
*
|
||||
* One listing as the transcripts' owner, then a lookup — never `existsSync` on a candidate path. For a
|
||||
* member `existsSync` answers **false** on a file that is plainly there, because the group directory is
|
||||
* mode 700 and the service user cannot traverse it, and every caller read that false as "no such session".
|
||||
*
|
||||
* `cwd` names the group to prefer, not the group to trust: a session's group and the caller's current one
|
||||
* disagree routinely (a deep link has not resolved its group yet, or the list is showing another). Reads
|
||||
* have always fallen back like this; writes did not, so delete and rename returned "not found" for a
|
||||
* session that was on screen.
|
||||
*/
|
||||
function locateTranscript(who: ChatIdentity, sessionId: string, cwd?: string): string | null {
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
const files = listTranscriptsAs(who.osUser, projectsDir);
|
||||
const preferred = cwd ? projectSlug(cwd) : null;
|
||||
const hit =
|
||||
(preferred && files.find((f) => f.id === sessionId && f.slug === preferred)) ||
|
||||
files.find((f) => f.id === sessionId);
|
||||
return hit ? join(projectsDir, hit.slug, `${sessionId}.jsonl`) : null;
|
||||
}
|
||||
|
||||
/** Claude's folder name for a working directory. */
|
||||
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
|
||||
@@ -126,22 +163,17 @@ type Entry = {
|
||||
*/
|
||||
const summaryCache = new Map<string, { mtimeMs: number; summary: TranscriptSummary }>();
|
||||
|
||||
function summarizeTranscript(filePath: string, id: string): TranscriptSummary | null {
|
||||
let mtimeMs: number;
|
||||
let mtime: string;
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
mtime = stat.mtime.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// `mtimeMs` is passed in rather than stat'ed here: `statSync` is EACCES on a member's transcript, and the
|
||||
// listing that found the file already carries its mtime. Stat'ing again would be a second fork per file AND
|
||||
// would fail for exactly the accounts this exists to serve.
|
||||
function summarizeTranscript(osUser: AsUser, filePath: string, id: string, mtimeMs: number): TranscriptSummary | null {
|
||||
const mtime = new Date(mtimeMs).toISOString();
|
||||
const cached = summaryCache.get(filePath);
|
||||
if (cached && cached.mtimeMs === mtimeMs) return cached.summary;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf-8');
|
||||
raw = readTextAs(osUser, filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -418,15 +450,31 @@ function userMessageFrom(text: string): ClaudeChatMessage | null {
|
||||
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
|
||||
|
||||
/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
|
||||
function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd = ''): ClaudeSessionDetail | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
function parseClaudeTranscript(
|
||||
osUser: AsUser,
|
||||
filePath: string,
|
||||
sessionId: string,
|
||||
fallbackCwd = '',
|
||||
): ClaudeSessionDetail | null {
|
||||
// No `existsSync` guard: it is redundant with the catch below (a missing file throws there just the same)
|
||||
// and it is actively WRONG for a member, answering false on a transcript that exists — which is how a
|
||||
// /chat/<id> deep link 404'd on a session the member was looking at.
|
||||
const messages: ClaudeChatMessage[] = [];
|
||||
const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
|
||||
let model = '';
|
||||
let sessionCwd = fallbackCwd;
|
||||
|
||||
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
|
||||
// `existsSync` above passes for a member's transcript and the read still fails — the file is theirs at
|
||||
// mode 600. That combination used to escape as a 500 from `GET /chat/sessions/:id`, because only the
|
||||
// list path caught its read. "Not found" is the honest answer for a transcript we cannot open.
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readTextAs(osUser, filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let entry: Entry & { message?: { role?: string; content?: unknown; model?: string } };
|
||||
try {
|
||||
@@ -526,7 +574,7 @@ function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): Cl
|
||||
const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
|
||||
const earlier: ClaudeChatMessage[] = [];
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
|
||||
const segment = parseClaudeTranscript(who.osUser, join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
|
||||
if (!segment) continue;
|
||||
earlier.push(...segment.messages, { role: 'divider', sessionId: part.id });
|
||||
}
|
||||
@@ -537,6 +585,7 @@ function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): Cl
|
||||
/** Load a session when its cwd (project group) is known. */
|
||||
export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
|
||||
const detail = parseClaudeTranscript(
|
||||
who.osUser,
|
||||
join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
|
||||
sessionId,
|
||||
cwd,
|
||||
@@ -548,20 +597,10 @@ export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: str
|
||||
* refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
|
||||
* caller uses to scope the list + cwd picker. */
|
||||
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
let slugs: string[];
|
||||
try {
|
||||
slugs = readdirSync(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const slug of slugs) {
|
||||
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
||||
if (!existsSync(filePath)) continue;
|
||||
const detail = parseClaudeTranscript(filePath, sessionId);
|
||||
return detail && loadChainTranscript(who, detail);
|
||||
}
|
||||
return null;
|
||||
const filePath = locateTranscript(who, sessionId);
|
||||
if (!filePath) return null;
|
||||
const detail = parseClaudeTranscript(who.osUser, filePath, sessionId);
|
||||
return detail && loadChainTranscript(who, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,23 +610,8 @@ export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): Cla
|
||||
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
|
||||
* not, so delete and rename returned "not found" for a session that was plainly on screen.
|
||||
*/
|
||||
function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null {
|
||||
const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`);
|
||||
if (existsSync(preferred)) return preferred;
|
||||
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
let slugs: string[];
|
||||
try {
|
||||
slugs = readdirSync(projectsDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const slug of slugs) {
|
||||
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
||||
if (existsSync(filePath)) return filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): string | null =>
|
||||
locateTranscript(who, sessionId, cwd);
|
||||
|
||||
/**
|
||||
* Delete a conversation by removing its transcript file — and, when it is a `/clear` chain, the files
|
||||
@@ -601,13 +625,13 @@ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
|
||||
const filePath = findTranscript(who, cwd, sessionId);
|
||||
if (!filePath) return false;
|
||||
|
||||
const ownCwd = firstCwd(filePath);
|
||||
const ownCwd = firstCwd(who.osUser, filePath);
|
||||
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
|
||||
// `removeAs`, not `rmSync`: unlinking needs `w`+`x` on the DIRECTORY, which the service user does not have
|
||||
// on a member's group. `rmSync` guarded by `existsSync` therefore deleted nothing and still reported
|
||||
// success — the conversation reappeared on the next refresh.
|
||||
const dir = dirname(filePath);
|
||||
for (const id of ids) {
|
||||
const partPath = join(dir, `${id}.jsonl`);
|
||||
if (existsSync(partPath)) rmSync(partPath);
|
||||
}
|
||||
for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -622,7 +646,7 @@ export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
|
||||
|
||||
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
|
||||
let leafUuid = sessionId;
|
||||
const lines = readFileSync(filePath, 'utf-8').split('\n');
|
||||
const lines = readTextAs(who.osUser, filePath).split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (!lines[i]!.trim()) continue;
|
||||
try {
|
||||
@@ -636,7 +660,7 @@ export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
|
||||
}
|
||||
}
|
||||
|
||||
appendFileSync(filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`);
|
||||
appendTextAs(who.osUser, filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -689,21 +713,10 @@ function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
|
||||
}
|
||||
|
||||
/** The tail of a file, as text, without reading the whole thing. */
|
||||
function tailFile(filePath: string, bytes: number): { text: string; truncated: boolean } {
|
||||
const size = statSync(filePath).size;
|
||||
const start = Math.max(0, size - bytes);
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(filePath, 'r');
|
||||
const buf = Buffer.alloc(size - start);
|
||||
const n = readSync(fd, buf, 0, buf.length, start);
|
||||
let text = buf.toString('utf-8', 0, n);
|
||||
// A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head.
|
||||
if (start > 0) text = text.slice(text.indexOf('\n') + 1);
|
||||
return { text, truncated: start > 0 };
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: string; truncated: boolean } {
|
||||
const { text, truncated } = readTailAs(osUser, filePath, bytes);
|
||||
// A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head.
|
||||
return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -723,7 +736,7 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun
|
||||
}
|
||||
|
||||
if (target.endsWith('.jsonl')) {
|
||||
const detail = parseClaudeTranscript(target, taskId);
|
||||
const detail = parseClaudeTranscript(who.osUser, target, taskId);
|
||||
if (!detail) return null;
|
||||
const messages = detail.messages.map((m) =>
|
||||
m.role === 'tool' && m.output && m.output.length > OUTPUT_CAP
|
||||
@@ -734,7 +747,7 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun
|
||||
}
|
||||
|
||||
try {
|
||||
const { text, truncated } = tailFile(target, LOG_TAIL_BYTES);
|
||||
const { text, truncated } = tailFile(who.osUser, target, LOG_TAIL_BYTES);
|
||||
return { kind: 'log', text, truncated };
|
||||
} catch {
|
||||
return null;
|
||||
@@ -746,17 +759,11 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun
|
||||
// real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions".
|
||||
|
||||
/** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */
|
||||
function firstCwd(filePath: string): string {
|
||||
let fd: number | undefined;
|
||||
function firstCwd(osUser: AsUser, filePath: string): string {
|
||||
try {
|
||||
fd = openSync(filePath, 'r');
|
||||
const buf = Buffer.alloc(32768);
|
||||
const n = readSync(fd, buf, 0, buf.length, 0);
|
||||
return buf.toString('utf-8', 0, n).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
|
||||
return readHeadAs(osUser, filePath, 32768).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,30 +775,30 @@ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
|
||||
const defaultCwd = who.home;
|
||||
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
||||
|
||||
if (existsSync(projectsDir)) {
|
||||
for (const group of readdirSync(projectsDir)) {
|
||||
const groupDir = join(projectsDir, group);
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(groupDir).filter((f) => f.endsWith('.jsonl'));
|
||||
} catch {
|
||||
continue; // not a directory
|
||||
}
|
||||
if (files.length === 0) continue;
|
||||
// One listing for the whole tree, grouped here. This used to be `readdirSync` + a `statSync` per file,
|
||||
// and BOTH are EACCES for a member — the readdir threw uncaught, so this endpoint answered 500 rather
|
||||
// than answering wrongly. That 500 was the visible half of the empty conversation list.
|
||||
const byGroup = new Map<string, { count: number; newest: number; first: string }>();
|
||||
for (const file of listTranscriptsAs(who.osUser, projectsDir)) {
|
||||
const prev = byGroup.get(file.slug);
|
||||
byGroup.set(file.slug, {
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
newest: Math.max(prev?.newest ?? 0, file.mtimeMs),
|
||||
first: prev?.first ?? file.id,
|
||||
});
|
||||
}
|
||||
|
||||
const cwd = firstCwd(join(groupDir, files[0]!));
|
||||
if (!cwd) continue;
|
||||
let updatedAt = '';
|
||||
for (const f of files) {
|
||||
const m = statSync(join(groupDir, f)).mtime.toISOString();
|
||||
if (m > updatedAt) updatedAt = m;
|
||||
}
|
||||
const prev = byCwd.get(cwd);
|
||||
byCwd.set(cwd, {
|
||||
count: (prev?.count ?? 0) + files.length,
|
||||
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
|
||||
});
|
||||
}
|
||||
for (const [slug, group] of byGroup) {
|
||||
// The cwd is a property of the transcript's entries, not of the slug, which is lossy and cannot be
|
||||
// reversed. Any file in the group answers it.
|
||||
const cwd = firstCwd(who.osUser, join(projectsDir, slug, `${group.first}.jsonl`));
|
||||
if (!cwd) continue;
|
||||
const updatedAt = new Date(group.newest).toISOString();
|
||||
const prev = byCwd.get(cwd);
|
||||
byCwd.set(cwd, {
|
||||
count: (prev?.count ?? 0) + group.count,
|
||||
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' });
|
||||
@@ -809,13 +816,12 @@ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
|
||||
* mtime-cached, which is what makes calling this on every request cheap.
|
||||
*/
|
||||
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
||||
const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
|
||||
if (!existsSync(dir)) return [];
|
||||
const slug = projectSlug(cwd);
|
||||
const dir = join(claudeProjectsDir(who.home), slug);
|
||||
|
||||
const sessions: TranscriptSummary[] = [];
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!file.endsWith('.jsonl')) continue;
|
||||
const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, ''));
|
||||
for (const file of listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), slug)) {
|
||||
const summary = summarizeTranscript(who.osUser, join(dir, `${file.id}.jsonl`), file.id, file.mtimeMs);
|
||||
if (summary) sessions.push(summary);
|
||||
}
|
||||
return applyLineage(sessions);
|
||||
@@ -864,40 +870,28 @@ export function claudeSessionContext(
|
||||
* anywhere hotter.
|
||||
*/
|
||||
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
let slugs: string[];
|
||||
const filePath = locateTranscript(who, sessionId);
|
||||
if (!filePath) return null;
|
||||
|
||||
// The cwd is a property of the transcript's entries, so the first one carrying it settles which group
|
||||
// this session belongs to — no need to reverse the slug, which is lossy.
|
||||
let cwd: string | null = null;
|
||||
try {
|
||||
slugs = readdirSync(projectsDir);
|
||||
for (const line of readTextAs(who.osUser, filePath).split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const entry = JSON.parse(line) as { cwd?: string };
|
||||
if (entry.cwd) {
|
||||
cwd = entry.cwd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!cwd) return null;
|
||||
|
||||
for (const slug of slugs) {
|
||||
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
||||
if (!existsSync(filePath)) continue;
|
||||
|
||||
// The cwd is a property of the transcript's entries, so the first one carrying it settles which
|
||||
// group this session belongs to — no need to reverse the slug, which is lossy.
|
||||
let cwd: string | null = null;
|
||||
try {
|
||||
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const entry = JSON.parse(line) as { cwd?: string };
|
||||
if (entry.cwd) {
|
||||
cwd = entry.cwd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!cwd) return null;
|
||||
|
||||
const context = claudeSessionContext(who, cwd, sessionId);
|
||||
return context ? { title: context.title, cwd } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
const context = claudeSessionContext(who, cwd, sessionId);
|
||||
return context ? { title: context.title, cwd } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -351,9 +351,30 @@ async function handleChat(
|
||||
logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null });
|
||||
|
||||
// Route by harness: claude-code → Claude sidecar; anything else → OpenCode server.
|
||||
return isClaudeModel(model)
|
||||
? handleClaudeCodeChat(ws, sessionId, model, msg, prompt)
|
||||
: handleOpenCodeChat(ws, sessionId, model, msg, prompt);
|
||||
if (isClaudeModel(model)) return handleClaudeCodeChat(ws, sessionId, model, msg, prompt);
|
||||
|
||||
// ── OpenCode is owner-only until it carries an identity ──
|
||||
//
|
||||
// `handleOpenCodeChat` resolves its cwd against `getOwnerHomeDir(email)` — a function that discards the
|
||||
// email it is given and always answers the owner — and the sidecar runs one shared `opencode serve` as the
|
||||
// service user. So a turn here executes AS THE OWNER, IN THE OWNER'S HOME, whoever asked. It carried a
|
||||
// comment describing itself as owner-only; this is the check that comment assumed existed.
|
||||
//
|
||||
// Reached by any account with the `chat` grant, which every role holds by default, and `isClaudeModel` is a
|
||||
// `startsWith` — so a typo'd model string lands here too, not just a deliberate choice. `model` is
|
||||
// client-supplied and never validated against the catalogue, so hiding these in `/chat/models` is not a
|
||||
// substitute for refusing them here.
|
||||
const identity = await resolveTurnIdentity(userId);
|
||||
if (identity.kind !== 'owner') {
|
||||
logger.warn('Refused an OpenCode turn for a non-owner', { userId, model, sessionId });
|
||||
sendToClient(ws, {
|
||||
type: 'error',
|
||||
message: 'OpenCode is only available to the server owner. Pick a Claude model instead.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return handleOpenCodeChat(ws, sessionId, model, msg, prompt);
|
||||
}
|
||||
|
||||
async function handleClaudeCodeChat(
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '../../custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { mountPrefix } from '../../plugins/manifest';
|
||||
import { snapshotPlugins } from '../../plugins/mount';
|
||||
import {
|
||||
installPlugin,
|
||||
pluginProcessStatus,
|
||||
setPluginRunning,
|
||||
uninstallPlugin,
|
||||
type PluginActionResult,
|
||||
} from '../../plugins/install';
|
||||
|
||||
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
|
||||
//
|
||||
// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is
|
||||
// an administrative act however many members share the server. The capability layer covers it too; this
|
||||
// is the belt to that braces, the same shape `/api/app-store` uses.
|
||||
//
|
||||
// ── This is not the app store ──
|
||||
//
|
||||
// The app store installs SIDECARS from a compiled-in catalogue, provisioning containers and asking the
|
||||
// user questions. This installs PLUGINS from the tree, and asks nothing: put the code there, push the
|
||||
// schema, mount the routes. The two coexist until the app store is rebuilt on this.
|
||||
|
||||
export const pluginsRouter = createRouter();
|
||||
|
||||
pluginsRouter.use(async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Plugins are owner-only');
|
||||
return next();
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins — every plugin in the tree, with what the database knows about each.
|
||||
*
|
||||
* Reports `broken` alongside rather than failing: a directory with an unreadable manifest is something to
|
||||
* show the owner, and refusing the whole list because one plugin is malformed would hide the nine that
|
||||
* are fine.
|
||||
*/
|
||||
pluginsRouter.get('/', async (ctx) => {
|
||||
const { states, broken } = await snapshotPlugins();
|
||||
// Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to
|
||||
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
|
||||
// rendering differently — it is the difference between "off" and "broken".
|
||||
const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
|
||||
return ctx.json({
|
||||
plugins: states.map(({ plugin, install, outdated }, i) => ({
|
||||
appName: plugin.appName,
|
||||
prefix: mountPrefix(plugin),
|
||||
label: plugin.manifest.label,
|
||||
summary: plugin.manifest.summary,
|
||||
icon: plugin.manifest.icon,
|
||||
color: plugin.manifest.color,
|
||||
publisher: plugin.manifest.publisher,
|
||||
version: plugin.manifest.version,
|
||||
platform: plugin.manifest.platform,
|
||||
permissions: plugin.manifest.permissions,
|
||||
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
|
||||
has: {
|
||||
api: !!plugin.api,
|
||||
schema: !!plugin.schema,
|
||||
sidecar: !!plugin.sidecar,
|
||||
web: !!plugin.web,
|
||||
},
|
||||
installed: !!install,
|
||||
enabled: install?.enabled ?? false,
|
||||
installedVersion: install?.version ?? null,
|
||||
outdated,
|
||||
processStatus: statuses[i] ?? null,
|
||||
})),
|
||||
broken,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The four verbs. Each returns the ordered list of what actually happened, rather than a bare `ok` —
|
||||
* "installed" and "installed but the sidecar would not start" are different outcomes and the second is
|
||||
* the one worth reading. See `plugins/install.ts` for why the order inside each is what it is.
|
||||
*/
|
||||
pluginsRouter.post('/:appName/install', async (ctx) => {
|
||||
const result = await installPlugin(ctx.req.param('appName'));
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
pluginsRouter.post('/:appName/uninstall', async (ctx) => {
|
||||
const result = await uninstallPlugin(ctx.req.param('appName'));
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
pluginsRouter.post('/:appName/enable', async (ctx) => {
|
||||
const result = await setPluginRunning(ctx.req.param('appName'), true);
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
pluginsRouter.post('/:appName/disable', async (ctx) => {
|
||||
const result = await setPluginRunning(ctx.req.param('appName'), false);
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
});
|
||||
|
||||
// ── The same four verbs, streamed ──
|
||||
//
|
||||
// An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that
|
||||
// and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could
|
||||
// not". Streaming each step as it completes turns the same work into something you can watch, and — more
|
||||
// usefully — leaves the log on screen naming the step that failed.
|
||||
//
|
||||
// POST rather than GET, so `EventSource` cannot be used: it sends no `Authorization` header, and these
|
||||
// routes are owner-only. The client reads the body and parses frames itself, which is exactly what
|
||||
// `useCompanionLogStream` already does for the headscale container logs.
|
||||
|
||||
const VERBS = {
|
||||
install: (appName: string, onStep: (s: string) => Promise<void>) => installPlugin(appName, onStep),
|
||||
uninstall: (appName: string, onStep: (s: string) => Promise<void>) => uninstallPlugin(appName, onStep),
|
||||
enable: (appName: string, onStep: (s: string) => Promise<void>) => setPluginRunning(appName, true, onStep),
|
||||
disable: (appName: string, onStep: (s: string) => Promise<void>) => setPluginRunning(appName, false, onStep),
|
||||
} as const;
|
||||
|
||||
type Verb = keyof typeof VERBS;
|
||||
const isVerb = (v: string): v is Verb => v in VERBS;
|
||||
|
||||
pluginsRouter.post('/:appName/:verb/stream', async (ctx) => {
|
||||
const appName = ctx.req.param('appName');
|
||||
const verb = ctx.req.param('verb');
|
||||
if (!isVerb(verb)) throw errors.NOT_FOUND(`Unknown action: ${verb}`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
// The client may have navigated away mid-install. The work continues — it is the server's job
|
||||
// now — but writing to a closed stream would throw and abort it halfway.
|
||||
try {
|
||||
controller.enqueue(frame(event, data));
|
||||
} catch {
|
||||
/* client gone */
|
||||
}
|
||||
};
|
||||
|
||||
let result: PluginActionResult;
|
||||
try {
|
||||
result = await VERBS[verb](appName, async (step) => send('step', { step }));
|
||||
} catch (err) {
|
||||
result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
send('done', result);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
// Through nginx as well as our own proxy chain: without it a buffering hop holds every frame until
|
||||
// the response ends, which is precisely the behaviour this endpoint exists to avoid.
|
||||
'x-accel-buffering': 'no',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getHeadscaleServerUrl } from '../headscale/router';
|
||||
|
||||
// Enrollment for OffTail, the in-app Tailscale. Authenticate the owner, forward to officer-headscale, and
|
||||
// hold no Headscale knowledge whatsoever — no URL, no admin key, no user name.
|
||||
//
|
||||
// This file used to mint the pre-auth key itself, from HEADSCALE_URL / HEADSCALE_API_KEY / HEADSCALE_USER
|
||||
// read out of the host env. Three globals describe exactly one server; Officer keeps a registry of many in
|
||||
// `headscale_servers`, one active at a time, so the env could contradict the server the owner had selected.
|
||||
// The two credential vars were later removed and the failure was silent — `if (!base || !apiKey)` returned
|
||||
// 503 before the rest of the route ever ran, so enrollment had simply stopped working and said nothing.
|
||||
// The logic now lives in the sidecar that owns the registry (src/servers/sidecar/headscale/enroll.ts).
|
||||
//
|
||||
// It stays mounted at /api/vpn rather than moving under /api/headscale because the path is a contract:
|
||||
// enrollVpn() in the mobile core POSTs exactly /api/vpn/enroll. createSidecarProxy strips its own prefix
|
||||
// and cannot express that rewrite, so this one forward is spelled out by hand.
|
||||
|
||||
export const vpnRouter = createRouter();
|
||||
|
||||
// POST /api/vpn/enroll → { controlUrl, authKey }
|
||||
//
|
||||
// The response shape is the other half of the contract: enrollVpn() in @officer/core destructures exactly
|
||||
// those two fields, so changing them means changing the mobile app too.
|
||||
vpnRouter.post('/enroll', async (ctx) => {
|
||||
const baseUrl = getHeadscaleServerUrl();
|
||||
if (!baseUrl) return ctx.json({ error: 'headscale sidecar not available' }, 503);
|
||||
|
||||
// Forwarded verbatim: an optional {userId} picks the owning Headscale user when the server has several.
|
||||
const body = await ctx.req.arrayBuffer();
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${baseUrl}/_officer/enroll`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': ctx.req.header('content-type') ?? 'application/json',
|
||||
// The authenticated owner. The sidecar binds loopback only, so its presence is the trust signal.
|
||||
'X-Officer-User': String(ctx.get('user').id),
|
||||
},
|
||||
body: body.byteLength ? body : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[vpn] headscale sidecar unreachable:', err);
|
||||
return ctx.json({ error: 'headscale sidecar unreachable' }, 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -26,14 +26,7 @@ import { CAPABILITIES } from '../capabilities/registry';
|
||||
// The processes a core install runs, mirroring CORE_PROCESSES in
|
||||
// scripts/setup/officer-setup/lib/services.sh. Duplicated deliberately: the generator is shell and this
|
||||
// is a test, and the alternative is the test reading a file the repository does not contain.
|
||||
const CORE = [
|
||||
'officer',
|
||||
'officer-anthropic-proxy',
|
||||
'officer-claude-code',
|
||||
'officer-opencode',
|
||||
'officer-pty',
|
||||
'officer-headscale',
|
||||
];
|
||||
const CORE = ['officer', 'officer-anthropic-proxy', 'officer-claude-code', 'officer-opencode', 'officer-pty'];
|
||||
|
||||
describe('the catalogue against the real estate', () => {
|
||||
it('does not offer to install the baseline', () => {
|
||||
|
||||
@@ -236,16 +236,17 @@ export const CAPABILITIES: Capability[] = [
|
||||
api: [],
|
||||
routes: ['/invoices'],
|
||||
},
|
||||
{
|
||||
key: 'vpn',
|
||||
label: 'VPN',
|
||||
description: 'Enrol your own devices on the tailnet',
|
||||
kind: 'app',
|
||||
api: ['/vpn'],
|
||||
// Minting a pre-auth key for your own device is the entire point of the capability, and the key is
|
||||
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
|
||||
personal: ['/'],
|
||||
},
|
||||
// `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale,
|
||||
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
|
||||
// OffScale app gates it on `embedded`, which it never sets, and it never will — the app is permanently
|
||||
// independent of the platform, since the thing that gets you to the platform cannot itself need it.
|
||||
//
|
||||
// Device enrolment did not go away, it moved out. A phone claims an invite from the Companion at
|
||||
// `${invite.base}/api/v1/enroll/claim`, which is a different component entirely and does not involve
|
||||
// Officer. Confirmed against the mobile monorepo and the companion repo before removal.
|
||||
//
|
||||
// What this does cost: `headscale` is `admin`, so with `vpn` gone no member-grantable headscale surface
|
||||
// remains. Reintroduce one here if members ever need to enrol their own devices through Officer.
|
||||
// Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is
|
||||
// the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`,
|
||||
// `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it
|
||||
@@ -367,6 +368,20 @@ export const CAPABILITIES: Capability[] = [
|
||||
api: ['/app-store'],
|
||||
routes: ['/app-store'],
|
||||
},
|
||||
{
|
||||
key: 'plugins',
|
||||
label: 'Plugins',
|
||||
description: 'Install, enable and remove the plugins this server runs',
|
||||
// Admin for the same reason as the app store above: installing a plugin mounts routes and starts a
|
||||
// process, which is process control rather than a feature to grant a read of.
|
||||
//
|
||||
// Note this capability guards the MANAGEMENT surface, not the plugins themselves. A plugin declares
|
||||
// its own permissions in its manifest, and those are what gate its routes — so a member can hold
|
||||
// `offscale` at read without being able to install or remove anything.
|
||||
kind: 'admin',
|
||||
api: ['/plugins'],
|
||||
routes: ['/plugins'],
|
||||
},
|
||||
{
|
||||
key: 'server-admin',
|
||||
label: 'Server settings',
|
||||
|
||||
@@ -22,6 +22,16 @@ import { homedir } from 'node:os';
|
||||
// is the check that says so out loud instead of silently writing to the wrong place.
|
||||
export const OFFICER_ROOT = resolve(process.cwd(), '..');
|
||||
|
||||
/**
|
||||
* The repo itself — the working directory, named rather than re-derived at each call site.
|
||||
*
|
||||
* Plugins live under this rather than beside it (`platform/plugins/<app-name>/`), which is the whole
|
||||
* developer story: bun links the workspace packages into the root `node_modules`, so anything inside the
|
||||
* repo can `import { useClient } from 'hooks/useClient'` with no publishing and no version negotiation.
|
||||
* A plugin one directory higher would resolve none of it.
|
||||
*/
|
||||
export const PLATFORM_DIR = process.cwd();
|
||||
|
||||
export const DATA_PATH = join(OFFICER_ROOT, 'data');
|
||||
|
||||
// Unified, file-based store for all agent items, living outside the repo. Every skill/tool/task/
|
||||
|
||||
+162
-106
@@ -19,6 +19,7 @@ import { uploadRouter } from './api/upload/upload';
|
||||
import { settingsRouter } from './api/settings/settings';
|
||||
import { dashboardsRouter } from './api/dashboards';
|
||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||
import { pluginsRouter } from './api/plugins/router';
|
||||
// import { musicRouter } from './api/music/router';
|
||||
// import { vaultRouter } from './api/vault/router';
|
||||
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
|
||||
@@ -30,7 +31,6 @@ import { headscaleRouter } from './api/headscale/router';
|
||||
// import { jellyfinRouter } from './api/jellyfin/router';
|
||||
// import { photosRouter } from './api/photos/router';
|
||||
// import { walletRouter } from './api/wallet/router';
|
||||
import { vpnRouter } from './api/vpn/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
// import { caldavRouter } from './api/dav/sidecar-server';
|
||||
// import { memosRouter } from './api/memos/router';
|
||||
@@ -64,7 +64,29 @@ export { Hono };
|
||||
export { createRouter };
|
||||
export type { HonoVariables };
|
||||
|
||||
export const honoServer = new Hono<{ Variables: HonoVariables }>();
|
||||
/**
|
||||
* A plugin's router and where it mounts — a plain pair, so this file needs no plugin knowledge at all.
|
||||
* Built by `plugins/mount.ts`, which is the side that knows what a manifest is.
|
||||
*/
|
||||
export type MountedPlugin = { prefix: string; router: ReturnType<typeof createRouter> };
|
||||
|
||||
// ── The app is BUILT, not assembled once ──
|
||||
//
|
||||
// It used to be a module-level `new Hono()` with forty statements run at import. That cannot express
|
||||
// installing a plugin: Hono's default SmartRouter throws `Can not add a route since the matcher is
|
||||
// already built` the moment a route is added after serving begins, and Hono has no API to REMOVE a route
|
||||
// at all — so uninstall was impossible even with a router that allowed adding.
|
||||
//
|
||||
// So nothing is added to a live app. A fresh one is built from the current plugin set and swapped in:
|
||||
//
|
||||
// honoServer = buildHonoApp(plugins) // install, uninstall, enable, disable — all the same call
|
||||
//
|
||||
// `server.tsx` serves it through a CLOSURE (`(req, server) => honoServer.fetch(req, server)`), not the
|
||||
// bound `honoServer.fetch`, so the reassignment above IS the swap. Verified end to end: a route 404s
|
||||
// before install, 200s after, and 404s again after uninstall, with core routes untouched throughout.
|
||||
//
|
||||
// Two things this buys over adding routes to a live app: the default SmartRouter is kept, so the fast
|
||||
// RegExpRouter path survives — and uninstall is expressible, which an add-only API cannot do.
|
||||
|
||||
// Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is
|
||||
// not a loosening: the check it replaced defaulted to off, so this is what every real install already
|
||||
@@ -88,93 +110,6 @@ const corsMiddleware = cors({
|
||||
const isDavPath = (path: string) =>
|
||||
path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav';
|
||||
|
||||
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||
|
||||
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
|
||||
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
|
||||
honoServer.use(capabilityGateMiddleware);
|
||||
|
||||
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||
honoServer.route('/api/auth', authRouter);
|
||||
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
||||
honoServer.route('/api/waitlist', waitlistRouter);
|
||||
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
|
||||
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
||||
// honoServer.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin
|
||||
|
||||
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
|
||||
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
|
||||
// than a mode of the router above: that one requires an Officer session and swaps the caller's
|
||||
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
|
||||
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
|
||||
// open is not a new exposure.
|
||||
// honoServer.route('/vaultwarden', publicVaultRouter); // switched off with the above
|
||||
|
||||
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
|
||||
//
|
||||
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
|
||||
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
|
||||
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
|
||||
// header. An ordinary Officer request never matches, so nothing that worked before changes.
|
||||
// for (const prefix of VAULT_ONLY_PREFIXES) honoServer.route(prefix, publicVaultRouter);
|
||||
//
|
||||
// honoServer.use('/api/*', async (ctx, next) => {
|
||||
// if (!isBitwardenClient(ctx.req.raw.headers)) return next();
|
||||
// return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
|
||||
// });
|
||||
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||
|
||||
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
|
||||
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
|
||||
// so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token
|
||||
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
|
||||
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
|
||||
honoServer.route('/api/agent-handoff', agentHandoffRouter);
|
||||
|
||||
// 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.
|
||||
honoServer.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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// honoServer.route('/dav', davSyncRouter); // plugin — switched off 2026-08-13
|
||||
|
||||
// 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.
|
||||
// honoServer.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
|
||||
// honoServer.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
|
||||
|
||||
const protectedRouter = createRouter();
|
||||
protectedRouter.use(bodyParser());
|
||||
protectedRouter.use(userMiddleware);
|
||||
|
||||
// The mount table, as DATA rather than forty statements.
|
||||
//
|
||||
// The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted
|
||||
@@ -205,6 +140,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
|
||||
['/app-store', appStoreRouter],
|
||||
['/plugins', pluginsRouter],
|
||||
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
|
||||
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
||||
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
||||
@@ -214,7 +150,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
|
||||
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
||||
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
|
||||
['/vpn', vpnRouter],
|
||||
['/system-monitor', systemMonitorRouter],
|
||||
['/activity', activityRouter],
|
||||
['/dock', dockRouter],
|
||||
@@ -230,8 +165,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13
|
||||
];
|
||||
|
||||
for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router);
|
||||
|
||||
/** Every prefix served behind the account gate. Read by the capability totality check at boot. */
|
||||
export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix);
|
||||
|
||||
@@ -248,21 +181,144 @@ export const UNPROTECTED_API_PREFIXES: string[] = [
|
||||
'/agent-handoff',
|
||||
];
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
/**
|
||||
* Build the whole application from the plugins currently installed.
|
||||
*
|
||||
* Pure: it reads nothing and mutates nothing. Everything it needs arrives as an argument, so a caller
|
||||
* can build an app for a hypothetical plugin set — which is what makes the swap testable without a
|
||||
* database, a filesystem or a running server.
|
||||
*/
|
||||
export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> {
|
||||
const app = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
honoServer.onError((error, ctx) => {
|
||||
if (error instanceof CustomError) {
|
||||
if (error.returnValue) {
|
||||
if (typeof error.returnValue === 'string') {
|
||||
return ctx.text(error.returnValue, error.statusCode);
|
||||
} else {
|
||||
return ctx.json(error.returnValue, error.statusCode);
|
||||
app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||
|
||||
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
|
||||
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
|
||||
app.use(capabilityGateMiddleware);
|
||||
|
||||
app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
||||
app.route('/api/auth', authRouter);
|
||||
app.route('/api/landing-page-data', landingPageDataRouter);
|
||||
app.route('/api/waitlist', waitlistRouter);
|
||||
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
|
||||
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
|
||||
// notifications WebSocket is upgraded at the serve level (server.tsx).
|
||||
// app.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin
|
||||
|
||||
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
|
||||
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
|
||||
// than a mode of the router above: that one requires an Officer session and swaps the caller's
|
||||
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
|
||||
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
|
||||
// open is not a new exposure.
|
||||
// app.route('/vaultwarden', publicVaultRouter); // switched off with the above
|
||||
|
||||
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
|
||||
//
|
||||
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
|
||||
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
|
||||
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
|
||||
// header. An ordinary Officer request never matches, so nothing that worked before changes.
|
||||
// for (const prefix of VAULT_ONLY_PREFIXES) app.route(prefix, publicVaultRouter);
|
||||
//
|
||||
// app.use('/api/*', async (ctx, next) => {
|
||||
// if (!isBitwardenClient(ctx.req.raw.headers)) return next();
|
||||
// return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
|
||||
// });
|
||||
app.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||
|
||||
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
|
||||
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
|
||||
// so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token
|
||||
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
|
||||
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
|
||||
app.route('/api/agent-handoff', agentHandoffRouter);
|
||||
|
||||
// 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); // plugin — switched off 2026-08-13
|
||||
|
||||
// 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());
|
||||
protectedRouter.use(userMiddleware);
|
||||
for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router);
|
||||
|
||||
// Plugin routes, mounted behind the same account gate as everything else — a plugin is part of the
|
||||
// application, not a guest in it, so it gets no separate door and no weaker middleware.
|
||||
//
|
||||
// `mountPrefix` decides where, from the manifest's `publisher` and nothing else. Nothing here may
|
||||
// branch on provenance: the moment first-party and third-party differ anywhere but that one function,
|
||||
// they become two systems and only one of them is exercised.
|
||||
for (const plugin of plugins) protectedRouter.route(plugin.prefix, plugin.router);
|
||||
|
||||
app.route('/api', protectedRouter);
|
||||
|
||||
app.onError((error, ctx) => {
|
||||
if (error instanceof CustomError) {
|
||||
if (error.returnValue) {
|
||||
if (typeof error.returnValue === 'string') {
|
||||
return ctx.text(error.returnValue, error.statusCode);
|
||||
} else {
|
||||
return ctx.json(error.returnValue, error.statusCode);
|
||||
}
|
||||
}
|
||||
return ctx.text(error.message, error.statusCode);
|
||||
}
|
||||
return ctx.text(error.message, error.statusCode);
|
||||
}
|
||||
|
||||
console.error('Unexpected error:', error.message);
|
||||
console.log(error.stack);
|
||||
return ctx.text('Internal Server Error', 500);
|
||||
});
|
||||
console.error('Unexpected error:', error.message);
|
||||
console.log(error.stack);
|
||||
return ctx.text('Internal Server Error', 500);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live app. `let`, and reassigned by `rebuildHonoApp` — see the note at the top of this file.
|
||||
*
|
||||
* Starts with no plugins because discovery reads the disk and the database, which is asynchronous and
|
||||
* must not happen at import. `server.tsx` rebuilds once both have answered.
|
||||
*/
|
||||
export let honoServer = buildHonoApp();
|
||||
|
||||
/** Swap the live app for one built from `plugins`. The whole of install, uninstall, enable and disable. */
|
||||
export function rebuildHonoApp(plugins: MountedPlugin[]): Hono<{ Variables: HonoVariables }> {
|
||||
honoServer = buildHonoApp(plugins);
|
||||
return honoServer;
|
||||
}
|
||||
|
||||
+37
-2
@@ -297,11 +297,46 @@ export async function confineUserTree(params: {
|
||||
try {
|
||||
if (!existsSync(home)) await mkdir(home, { recursive: true });
|
||||
|
||||
// Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the
|
||||
// directory and learn every other member's email address.
|
||||
// Traversable, not listable — for everyone EXCEPT the members themselves, who are named below.
|
||||
//
|
||||
// ── Why "not listable" could not be kept ──
|
||||
//
|
||||
// 711 says: pass through, do not read. That is enough to `cd` into a home and not enough for a program
|
||||
// that READS its ancestors, and at least one in daily use does. `bun run` primes its module-resolution
|
||||
// cache by walking DOWN from `/` and opening every component of the cwd with `O_RDONLY|O_DIRECTORY`:
|
||||
//
|
||||
// openat("/home/pastilhas/officerdev/") = 6
|
||||
// openat("/home/pastilhas/officerdev/data/") = -1 EACCES
|
||||
// openat("/home/pastilhas/officerdev/data/<email>/") = -1 EACCES
|
||||
//
|
||||
// and dies with `CouldntReadCurrentDirectory` before it ever looks for `package.json`. `getcwd` succeeds;
|
||||
// it is the read of the ancestors that fails. Traversal alone would do — `O_PATH` needs only `x` — so
|
||||
// this is arguably Bun's bug, but it is not one this repository can fix, and it presents as a project
|
||||
// being mysteriously unbuildable from a member's shell.
|
||||
//
|
||||
// The cost is stated plainly: a member can now `ls` DATA_PATH and learn the other accounts' email
|
||||
// addresses. Their CONTENTS stay shut — every `home` is 700 and owned by its member, and every sibling
|
||||
// is 700 and owned by the service user. What is given up is the account list, not any account's data.
|
||||
//
|
||||
// Named ACL entries rather than `chmod 755`, so this reaches members and not every account on the box.
|
||||
await chmod(DATA_PATH, 0o711);
|
||||
await chmod(accountDir, 0o711);
|
||||
|
||||
// After the chmods, never before — chmod recomputes the ACL mask from the group bits, which for 711 is
|
||||
// `--x`, and that would clamp every member entry (including ones added by earlier provisions) down to
|
||||
// traverse-only. Setting `m::rx` explicitly restores them all, so provisioning a second member does not
|
||||
// silently re-break the first.
|
||||
const uidEntry = `u:${params.uid}:rx`;
|
||||
const openUp = await run(['sudo', '-n', 'setfacl', '-m', `${uidEntry},m::rx`, DATA_PATH, accountDir]);
|
||||
if (!openUp.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`could not grant ${params.email} read access to ${DATA_PATH}: ${openUp.out}. ` +
|
||||
`Without it their own tooling cannot resolve paths inside their home.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
|
||||
const entries = await readdir(accountDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { discoverPlugins } from './discover';
|
||||
|
||||
// Discovery against a real tree, because every assertion here is about the FILESYSTEM being the
|
||||
// declaration. Mocking `existsSync` would test the mock.
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'officer-plugins-'));
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const MANIFEST = (over = '') => `export const manifest = {
|
||||
publisher: 'officerdev', version: '1.0.0', platform: '>=1.0.0',
|
||||
label: 'X', summary: 'x', icon: 'Network', color: '#fff',
|
||||
permissions: [], ${over}
|
||||
};`;
|
||||
|
||||
function plant(appName: string, files: Record<string, string>) {
|
||||
const dir = join(root, appName);
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const full = join(dir, rel);
|
||||
mkdirSync(join(full, '..'), { recursive: true });
|
||||
writeFileSync(full, body);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
plant('full', {
|
||||
'manifest.ts': MANIFEST(),
|
||||
'api/router.ts': 'export const router = {};',
|
||||
'db/schema.ts': 'export const t = {};',
|
||||
'sidecar/index.ts': 'export {};',
|
||||
'web/Router.tsx': 'export default () => null;',
|
||||
'web/panels.ts': 'export const appRegistryMetas = [];',
|
||||
});
|
||||
plant('bare', { 'manifest.ts': MANIFEST() });
|
||||
plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' });
|
||||
plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' });
|
||||
plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
|
||||
plant('_scratch', { 'manifest.ts': MANIFEST() });
|
||||
|
||||
describe('discoverPlugins', () => {
|
||||
it('reads what the tree declares, and nothing more', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
const full = plugins.find((p) => p.appName === 'full')!;
|
||||
expect(full.api).toContain('api/router.ts');
|
||||
expect(full.schema).toContain('db/schema.ts');
|
||||
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
|
||||
expect(full.web?.panels).toContain('web/panels.ts');
|
||||
});
|
||||
|
||||
it('a manifest alone is a valid plugin — every other part is optional', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
const bare = plugins.find((p) => p.appName === 'bare')!;
|
||||
expect([bare.api, bare.schema, bare.sidecar, bare.web]).toEqual([null, null, null, null]);
|
||||
});
|
||||
|
||||
// The runtime is the extension, not a field, so it cannot contradict the file it describes.
|
||||
it('reads the runtime off the extension', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
expect(plugins.find((p) => p.appName === 'nodeish')!.sidecar?.runtime).toBe('node');
|
||||
});
|
||||
|
||||
it('takes the app name from the directory, so it cannot disagree with where the code sits', async () => {
|
||||
const { plugins } = await discoverPlugins(root);
|
||||
expect(plugins.map((p) => p.appName)).toContain('full');
|
||||
});
|
||||
|
||||
// The property that matters most: one bad plugin must not take the platform down, or hide the good
|
||||
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
|
||||
it('collects broken plugins instead of throwing', async () => {
|
||||
const { plugins, broken } = await discoverPlugins(root);
|
||||
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'nomanifest']);
|
||||
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
|
||||
expect(plugins.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('skips underscore and dot directories, which are scratch space', async () => {
|
||||
const { plugins, broken } = await discoverPlugins(root);
|
||||
expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch');
|
||||
});
|
||||
|
||||
it('is empty, not an error, when there is no plugins directory at all', async () => {
|
||||
expect(await discoverPlugins(join(root, 'does-not-exist'))).toEqual({ plugins: [], broken: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './manifest';
|
||||
|
||||
// Finding plugins on disk.
|
||||
//
|
||||
// They live at `<platform>/plugins/<app-name>/` — INSIDE the repository, not beside it, and that is what
|
||||
// makes the whole developer story work. Bun links the workspace packages into the root `node_modules`, so
|
||||
// anything under the repo can `import { useClient } from 'hooks/useClient'` with no publishing, no package
|
||||
// registry and no version negotiation. A plugin author clones the platform, drops their plugin in, and
|
||||
// runs it in dev — the WordPress model — and the same tree is what the server builds from.
|
||||
//
|
||||
// Verified: `Bun.resolveSync('hooks/useClient', '<platform>/plugins/anything')` resolves.
|
||||
//
|
||||
// Discovery is by CONVENTION. Presence is the declaration:
|
||||
//
|
||||
// manifest.ts required — everything a directory listing cannot say
|
||||
// api/router.ts a backend router
|
||||
// db/schema.ts tables
|
||||
// sidecar/index.ts a process (`.mjs` instead means node — see below)
|
||||
// web/Router.tsx a frontend
|
||||
// web/panels.ts panel apps
|
||||
//
|
||||
// Nothing here reads the database. This answers "what is on disk", which is a different question from
|
||||
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store
|
||||
// has to render rather than a bug to prevent.
|
||||
|
||||
/** Where plugins live. Inside the repo, so the workspace packages resolve. */
|
||||
export const PLUGINS_DIR = join(PLATFORM_DIR, 'plugins');
|
||||
|
||||
/**
|
||||
* The runtime is the file extension, not a manifest field.
|
||||
*
|
||||
* `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun. Implicit, but it is the rule this
|
||||
* repository already follows — `officer-pty` is `pty/index.mjs` under node because node-pty is a native
|
||||
* module built against Node's ABI, and everything else is bun. Better than a field that can contradict
|
||||
* the file it describes.
|
||||
*/
|
||||
function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
|
||||
const ts = join(dir, 'sidecar', 'index.ts');
|
||||
if (existsSync(ts)) return { script: ts, runtime: 'bun' };
|
||||
const mjs = join(dir, 'sidecar', 'index.mjs');
|
||||
if (existsSync(mjs)) return { script: mjs, runtime: 'node' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function findWeb(dir: string): DiscoveredPlugin['web'] {
|
||||
const router = join(dir, 'web', 'Router.tsx');
|
||||
if (!existsSync(router)) return null;
|
||||
const panels = join(dir, 'web', 'panels.ts');
|
||||
return { router, panels: existsSync(panels) ? panels : null };
|
||||
}
|
||||
|
||||
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
||||
|
||||
/**
|
||||
* Read one plugin directory.
|
||||
*
|
||||
* Throws with every problem at once rather than the first, because a manifest fixed one field per attempt
|
||||
* is a manifest nobody finishes.
|
||||
*/
|
||||
export async function loadPlugin(dir: string, appName: string): Promise<DiscoveredPlugin> {
|
||||
const manifestPath = join(dir, 'manifest.ts');
|
||||
if (!existsSync(manifestPath)) throw new Error(`${appName}: no manifest.ts`);
|
||||
|
||||
let manifest: PluginManifest | null = null;
|
||||
try {
|
||||
const module = (await import(manifestPath)) as { manifest?: PluginManifest };
|
||||
manifest = module.manifest ?? null;
|
||||
} catch (err) {
|
||||
throw new Error(`${appName}: manifest.ts failed to load — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const problems = manifestProblems(appName, manifest);
|
||||
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
|
||||
|
||||
return {
|
||||
appName,
|
||||
dir,
|
||||
manifest: manifest as PluginManifest,
|
||||
api: fileOrNull(join(dir, 'api', 'router.ts')),
|
||||
schema: fileOrNull(join(dir, 'db', 'schema.ts')),
|
||||
sidecar: findSidecar(dir),
|
||||
web: findWeb(dir),
|
||||
};
|
||||
}
|
||||
|
||||
export type DiscoveryResult = {
|
||||
plugins: DiscoveredPlugin[];
|
||||
/** Directories that look like plugins but could not be read. Reported, never thrown — see below. */
|
||||
broken: { appName: string; error: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Every plugin directory under `PLUGINS_DIR`.
|
||||
*
|
||||
* A broken plugin is COLLECTED, not thrown. One unreadable manifest must not stop the platform from
|
||||
* booting or hide the nine plugins beside it that are fine — and "this one is broken, here is why" is
|
||||
* something the app store can render, where a failed boot is something only a log can.
|
||||
*/
|
||||
export async function discoverPlugins(root: string = PLUGINS_DIR): Promise<DiscoveryResult> {
|
||||
if (!existsSync(root)) return { plugins: [], broken: [] };
|
||||
|
||||
const plugins: DiscoveredPlugin[] = [];
|
||||
const broken: { appName: string; error: string }[] = [];
|
||||
|
||||
for (const entry of readdirSync(root)) {
|
||||
// `_`-prefixed directories are scratch space, and dotfiles are not plugins.
|
||||
if (entry.startsWith('.') || entry.startsWith('_')) continue;
|
||||
const dir = join(root, entry);
|
||||
try {
|
||||
if (!statSync(dir).isDirectory()) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
plugins.push(await loadPlugin(dir, entry));
|
||||
} catch (err) {
|
||||
broken.push({ appName: entry, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return { plugins: plugins.sort((a, b) => a.appName.localeCompare(b.appName)), broken };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { pluginProcessName } from './ecosystem';
|
||||
|
||||
// The read/write pair is exercised live in `install.test.ts` against a real generated file, because the
|
||||
// thing worth testing is that a round trip preserves the CORE entries officer-setup wrote — and that is a
|
||||
// property of the real file's shape, not of a fixture I would author to match my own parser.
|
||||
|
||||
describe('pluginProcessName', () => {
|
||||
it('is one rule, so nothing has to look it up', () => {
|
||||
expect(pluginProcessName('offscale')).toBe('officer-offscale');
|
||||
expect(pluginProcessName('example')).toBe('officer-example');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
|
||||
// Giving a plugin's sidecar an entry in the PM2 ecosystem file, and taking it away again.
|
||||
//
|
||||
// ── The hole this closes ──
|
||||
//
|
||||
// `app-store/pm2.ts` has carried this since 2026-08-13:
|
||||
//
|
||||
// "[open] As of 2026-08-13 it contains the SIX core processes and nothing else, so
|
||||
// `pm2 start ecosystem.config.cjs --only officer-jellyfin` finds no such app and does nothing.
|
||||
// Installing a plugin has to append its entry here before starting it — that is the plugin
|
||||
// system's job and it is not built."
|
||||
//
|
||||
// This is that job. It is why nothing in the app-store catalogue installs end to end today: the
|
||||
// container comes up, the connection is written, the icon publishes, and the sidecar never starts.
|
||||
//
|
||||
// ── Why the file is read back rather than regenerated ──
|
||||
//
|
||||
// The CORE entries are written by `officer-setup` from a shell array (`services.sh`), so the platform
|
||||
// cannot regenerate the whole file — it does not know what the core list is, and duplicating it here
|
||||
// would be a second copy to drift. Instead the file is `require`d (it is CommonJS, deliberately, because
|
||||
// package.json says `"type": "module"` and PM2 `require`s the config), its `apps` array is edited, and it
|
||||
// is written back. Whatever officer-setup put there survives untouched.
|
||||
//
|
||||
// The header above `module.exports` is preserved verbatim — officer-setup's is the better one, and losing
|
||||
// it to a plugin install would be a poor trade.
|
||||
|
||||
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
|
||||
|
||||
/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
|
||||
type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean };
|
||||
|
||||
/**
|
||||
* The header to write when there is no file to take one from.
|
||||
*
|
||||
* Normally the EXISTING header is preserved verbatim — officer-setup's explains why `cwd` is pinned on
|
||||
* every app and what happens when it is wrong, and that is worth more than anything restated here. This
|
||||
* is only the fallback for a file that does not exist yet.
|
||||
*/
|
||||
const FALLBACK_HEADER = `// Generated. Not in git, and not meant to be — it describes THIS install.
|
||||
// Core processes come from officer-setup (scripts/setup/officer-setup/lib/services.sh); plugin processes
|
||||
// are added and removed by the plugin installer (servers/plugins/ecosystem.ts).
|
||||
`;
|
||||
|
||||
/** The PM2 process name for a plugin's sidecar. One rule, so nothing has to look it up. */
|
||||
export const pluginProcessName = (appName: string): string => `officer-${appName}`;
|
||||
|
||||
/** Read the current apps array. An unreadable or missing file is an empty one — the caller decides. */
|
||||
export function readEcosystemApps(): Pm2App[] {
|
||||
if (!existsSync(ECOSYSTEM_PATH)) return [];
|
||||
try {
|
||||
// A plain parse rather than `require`: the file is generated and its shape is known, and requiring it
|
||||
// would cache the module so a second read in the same process returned a stale array.
|
||||
const source = readFileSync(ECOSYSTEM_PATH, 'utf-8');
|
||||
const start = source.indexOf('[');
|
||||
const end = source.lastIndexOf(']');
|
||||
if (start < 0 || end < 0) return [];
|
||||
// The generated file is JS object literals, not JSON — keys are bare and strings are single-quoted.
|
||||
// Evaluating it is safe in the way that matters: it is a file this process wrote, sitting inside the
|
||||
// install root, and anything able to edit it can already edit the server's own source.
|
||||
const apps = new Function(`return ${source.slice(start, end + 1)}`)() as Pm2App[];
|
||||
return Array.isArray(apps) ? apps : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the apps array, keeping everything above `module.exports` exactly as it was.
|
||||
*
|
||||
* The header is preserved rather than regenerated because officer-setup's is the better one — it explains
|
||||
* that Bun auto-loads .env from the working directory, and that data-path derives the install root from
|
||||
* its PARENT, so a wrong `cwd` silently relocates the whole install rather than failing. Losing that to a
|
||||
* plugin install would be a poor trade.
|
||||
*/
|
||||
function writeEcosystemApps(apps: Pm2App[]): void {
|
||||
const existing = existsSync(ECOSYSTEM_PATH) ? readFileSync(ECOSYSTEM_PATH, 'utf-8') : '';
|
||||
const marker = existing.indexOf('module.exports');
|
||||
const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER;
|
||||
|
||||
const lines = apps.map(
|
||||
(app) =>
|
||||
` { name: '${app.name}', script: '${app.script}'` +
|
||||
(app.args ? `, args: '${app.args}'` : '') +
|
||||
`, cwd: '${app.cwd}', watch: ${app.watch} },`,
|
||||
);
|
||||
writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the plugin's sidecar has an entry, replacing one that is already there.
|
||||
*
|
||||
* Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved.
|
||||
* Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
|
||||
*/
|
||||
export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean {
|
||||
if (!plugin.sidecar) return false;
|
||||
|
||||
const name = pluginProcessName(plugin.appName);
|
||||
// Relative to the platform, because `cwd` is pinned to it and an absolute path would break the moment
|
||||
// the install root moved — which is exactly what happened to this machine two days ago.
|
||||
const script = plugin.sidecar.script.startsWith(PLATFORM_DIR)
|
||||
? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1)
|
||||
: plugin.sidecar.script;
|
||||
|
||||
const entry: Pm2App =
|
||||
plugin.sidecar.runtime === 'node'
|
||||
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
|
||||
: { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false };
|
||||
|
||||
const apps = readEcosystemApps().filter((a) => a.name !== name);
|
||||
apps.push(entry);
|
||||
writeEcosystemApps(apps);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drop the plugin's entry. Safe to call when it was never there. */
|
||||
export function removePluginFromEcosystem(appName: string): void {
|
||||
const name = pluginProcessName(appName);
|
||||
const apps = readEcosystemApps();
|
||||
const remaining = apps.filter((a) => a.name !== name);
|
||||
if (remaining.length !== apps.length) writeEcosystemApps(remaining);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
|
||||
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
|
||||
// The install runner: the four verbs, each as a short ordered list of effects.
|
||||
//
|
||||
// ── Order is the whole design ──
|
||||
//
|
||||
// Every verb does its work in the order that leaves the system coherent if it stops halfway, because it
|
||||
// can. Bringing something UP goes outside-in (make it possible, then start it, then record it, then
|
||||
// expose it); taking something DOWN goes inside-out (stop exposing it, stop it, then forget it). The
|
||||
// worst intermediate state is then "recorded but not running", which the UI can show and a retry fixes —
|
||||
// never "running but forgotten", which nothing can see and nothing will clean up.
|
||||
//
|
||||
// ── What each verb touches ──
|
||||
//
|
||||
// ecosystem PM2 row mounts tables
|
||||
// install add start upsert rebuild (see below)
|
||||
// uninstall remove delete delete rebuild untouched
|
||||
// enable — start enabled=t rebuild untouched
|
||||
// disable — stop enabled=f rebuild untouched
|
||||
//
|
||||
// Nothing here drops a table, ever. Uninstall means "stop running this", and for a plugin holding a
|
||||
// user's data the two are unrecoverably different — see `plugin_installs` schema.
|
||||
//
|
||||
// `[open]` The schema push. A plugin with `db/schema.ts` still needs its tables created, which means
|
||||
// regenerating the drizzle barrel and running `db:push`. Deliberately not done in the same pass as this:
|
||||
// push DROPS tables absent from the schema it is given, so an uninstall that regenerated the barrel would
|
||||
// delete a plugin's data as a side effect of stopping it — exactly the thing this file refuses to do.
|
||||
// Offscale does not need it yet (`headscale_servers` already ships in the platform schema).
|
||||
|
||||
/**
|
||||
* Reported as each step completes, for the streaming endpoint.
|
||||
*
|
||||
* The runner does not know or care whether anyone is listening — it calls this and carries on, so the
|
||||
* non-streaming path is the same code with no callback rather than a second implementation.
|
||||
*/
|
||||
export type OnStep = (step: string) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* A beat between steps, so the log reads rather than blinks.
|
||||
*
|
||||
* Cosmetic, and worth being honest about: `pm2 start` genuinely takes a moment, but writing a row and
|
||||
* rebuilding the router do not, and four lines arriving in the same frame look like a stall followed by a
|
||||
* jump. This is small enough not to matter to a script and long enough for a person to follow.
|
||||
*/
|
||||
const STEP_BEAT_MS = 220;
|
||||
|
||||
export type PluginActionResult = {
|
||||
ok: boolean;
|
||||
appName: string;
|
||||
/** What actually happened, in order. Returned so the UI can show a real account rather than a spinner. */
|
||||
steps: string[];
|
||||
/** Present when a step failed. The plugin is left in the last coherent state above. */
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** PM2 is only involved when the plugin actually has a sidecar. Most won't. */
|
||||
const hasSidecar = (plugin: DiscoveredPlugin) => !!plugin.sidecar;
|
||||
|
||||
/** Record a step, tell whoever is listening, and pause so the next one does not land in the same frame. */
|
||||
async function step(steps: string[], onStep: OnStep | undefined, text: string): Promise<void> {
|
||||
steps.push(text);
|
||||
await onStep?.(text);
|
||||
if (onStep) await Bun.sleep(STEP_BEAT_MS);
|
||||
}
|
||||
|
||||
async function findPlugin(appName: string): Promise<DiscoveredPlugin | null> {
|
||||
const { states } = await snapshotPlugins();
|
||||
return states.find((s) => s.plugin.appName === appName)?.plugin ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install, or upgrade one already installed.
|
||||
*
|
||||
* Idempotent by construction: the ecosystem entry is replaced rather than appended, the row is an upsert,
|
||||
* and the mount is a rebuild. Re-running after a failure resumes rather than duplicating.
|
||||
*
|
||||
* `enabled` is deliberately untouched on the upgrade path — re-installing something the owner had
|
||||
* switched off must not switch it back on.
|
||||
*/
|
||||
export async function installPlugin(appName: string, onStep?: OnStep): Promise<PluginActionResult> {
|
||||
const plugin = await findPlugin(appName);
|
||||
if (!plugin) return { ok: false, appName, steps: [], error: `No plugin directory named "${appName}"` };
|
||||
|
||||
const steps: string[] = [];
|
||||
try {
|
||||
if (hasSidecar(plugin)) {
|
||||
addPluginToEcosystem(plugin);
|
||||
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
|
||||
|
||||
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
|
||||
if (!started.ok) {
|
||||
// The entry stays. A sidecar that will not start is a plugin to retry or debug, and removing the
|
||||
// entry would take away the thing that makes `pm2 logs officer-<name>` work.
|
||||
return { ok: false, appName, steps, error: `sidecar failed to start: ${started.error}` };
|
||||
}
|
||||
await step(steps, onStep, 'sidecar: started');
|
||||
}
|
||||
|
||||
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
|
||||
|
||||
await recordPluginInstall(appName, plugin.manifest.version);
|
||||
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
await step(steps, onStep, mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)');
|
||||
|
||||
return { ok: true, appName, steps };
|
||||
} catch (err) {
|
||||
return { ok: false, appName, steps, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall: stop answering, stop running, forget.
|
||||
*
|
||||
* Keeps every table and row the plugin owns, and keeps the directory. Reinstalling is therefore a restore
|
||||
* rather than a fresh start, which is the whole reason not to drop anything here.
|
||||
*/
|
||||
export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise<PluginActionResult> {
|
||||
const steps: string[] = [];
|
||||
const plugin = await findPlugin(appName);
|
||||
|
||||
// Row first: the mount rebuild below reads it, and a failure after this point leaves the plugin
|
||||
// unmounted and stopped rather than half-visible.
|
||||
const removed = await removePluginInstall(appName);
|
||||
if (!removed) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
|
||||
await step(steps, onStep, 'install record removed');
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
await step(steps, onStep, `unmounted (now: ${mounted.join(', ') || 'no plugin routes'})`);
|
||||
|
||||
if (plugin && hasSidecar(plugin)) {
|
||||
await stopProcess(pluginProcessName(appName), PLATFORM_DIR);
|
||||
await deleteProcess(pluginProcessName(appName), PLATFORM_DIR);
|
||||
removePluginFromEcosystem(appName);
|
||||
await step(steps, onStep, 'sidecar: stopped, deleted, ecosystem entry removed');
|
||||
}
|
||||
|
||||
await step(steps, onStep, 'tables and data: untouched');
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
/** Enable: mount and run again. Disable: the reversible middle — unmount and stop, keep everything. */
|
||||
export async function setPluginRunning(
|
||||
appName: string,
|
||||
enabled: boolean,
|
||||
onStep?: OnStep,
|
||||
): Promise<PluginActionResult> {
|
||||
const steps: string[] = [];
|
||||
const row = await setPluginEnabled(appName, enabled);
|
||||
if (!row) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
|
||||
await step(steps, onStep, enabled ? 'enabled' : 'disabled');
|
||||
|
||||
const plugin = await findPlugin(appName);
|
||||
if (plugin && hasSidecar(plugin)) {
|
||||
const name = pluginProcessName(appName);
|
||||
const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR);
|
||||
await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
|
||||
}
|
||||
|
||||
const { mounted } = await refreshPluginMounts();
|
||||
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
/** Whether a plugin's sidecar is actually up, for the UI. `null` when it has none or PM2 has not seen it. */
|
||||
export async function pluginProcessStatus(plugin: DiscoveredPlugin): Promise<string | null> {
|
||||
if (!hasSidecar(plugin)) return null;
|
||||
return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { manifestProblems, mountPrefix, type PluginManifest } from './manifest';
|
||||
|
||||
const valid = (over: Partial<PluginManifest> = {}): PluginManifest => ({
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0 <2.0.0',
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet', ownerOnly: true }],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('mountPrefix', () => {
|
||||
it('puts first-party plugins at the root', () => {
|
||||
expect(mountPrefix({ appName: 'offscale', manifest: { publisher: 'officerdev' } })).toBe('/offscale');
|
||||
});
|
||||
|
||||
it('puts everyone else under /p/<publisher>/', () => {
|
||||
expect(mountPrefix({ appName: 'notes', manifest: { publisher: 'alice' } })).toBe('/p/alice/notes');
|
||||
});
|
||||
|
||||
// The property the segment exists for: a third party cannot reach a core route's namespace, whatever
|
||||
// they call their plugin. Without it, publishing `notes` at /api/notes would mean the platform could
|
||||
// never add /api/notes itself.
|
||||
it('cannot shadow a core route, whatever the plugin is called', () => {
|
||||
for (const core of ['chat', 'users', 'terminal', 'auth', 'files']) {
|
||||
expect(mountPrefix({ appName: core, manifest: { publisher: 'alice' } })).toBe(`/p/alice/${core}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifestProblems', () => {
|
||||
it('accepts a good manifest', () => {
|
||||
expect(manifestProblems('offscale', valid())).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a missing manifest with a reason rather than a crash', () => {
|
||||
expect(manifestProblems('offscale', null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports every problem at once, not the first', () => {
|
||||
// An install fixed one field per attempt is an install nobody finishes.
|
||||
const problems = manifestProblems('offscale', { publisher: 'officerdev' } as Partial<PluginManifest>);
|
||||
expect(problems.length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
// The app name is a URL segment, a SQL identifier prefix and a directory name simultaneously. Anything
|
||||
// that is not safe in all three has to be refused at the door.
|
||||
it.each([
|
||||
['Offscale', 'uppercase'],
|
||||
['1offscale', 'leading digit'],
|
||||
['off scale', 'space'],
|
||||
['off_scale', 'underscore'],
|
||||
['off/scale', 'slash'],
|
||||
['../escape', 'traversal'],
|
||||
['', 'empty'],
|
||||
])('refuses the directory name %p (%s)', (appName) => {
|
||||
expect(manifestProblems(appName, valid()).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('refuses a publisher that is not a safe path segment', () => {
|
||||
expect(manifestProblems('notes', valid({ publisher: '../evil' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('requires permissions to be an array, so [] is how a plugin says it gates nothing', () => {
|
||||
expect(manifestProblems('notes', valid({ permissions: [] }))).toEqual([]);
|
||||
expect(manifestProblems('notes', valid({ permissions: undefined })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('names the permission that is malformed, by index', () => {
|
||||
const problems = manifestProblems('notes', valid({ permissions: [{ label: 'x' }] as never }));
|
||||
expect(problems.some((p) => p.includes('permissions[0].key'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// What a plugin declares about itself, and what the tree declares for it.
|
||||
//
|
||||
// ── The manifest holds only what a directory listing cannot say ──
|
||||
//
|
||||
// Everything structural is convention, and presence is the declaration: `sidecar/index.ts` means there is
|
||||
// a sidecar, `api/router.ts` means there are routes, `db/schema.ts` means there are tables, `web/` means
|
||||
// there is a frontend. The manifest carries the residue — an identity fact, or something a human chose.
|
||||
//
|
||||
// That is why there is no `sidecar`, `schema` or `frontend` field here, and no dock or title field either:
|
||||
// the tile is `{ label, icon, color, to: mountPrefix() }` and the title is `label`, all of which are
|
||||
// already below. Writing them twice could only ever drift.
|
||||
//
|
||||
// See docs/offscale-plugin.md for the reasoning behind each decision recorded here.
|
||||
|
||||
/**
|
||||
* A permission the plugin adds to the platform's permission system.
|
||||
*
|
||||
* Called `permissions` and NOT `capabilities`: that word already means three different things in this
|
||||
* codebase — the permission registry, the file-based item store under `$OFFICER_ROOT/capabilities`, and
|
||||
* the routing keys a sidecar registers with. A fourth would be one too many.
|
||||
*/
|
||||
export type PluginPermission = {
|
||||
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/**
|
||||
* Owner-only, or grantable to members. The whole distinction a plugin needs.
|
||||
*
|
||||
* The platform's own `CapabilityKind` has five values because the PLATFORM has five sorts of surface.
|
||||
* A plugin has two states, so this is a boolean — which also removes the escalation question rather
|
||||
* than answering it: a plugin cannot claim `core` if `core` is not a word it can say.
|
||||
*/
|
||||
ownerOnly?: boolean;
|
||||
/**
|
||||
* Requests that look like writes and are not — `POST /ssh-test` probes, `POST /policy/assist` proposes
|
||||
* a document and never saves one. Without declaring them, a read-level account meets what reads as a
|
||||
* broken feature where a withheld permission should be.
|
||||
*/
|
||||
readOnlyWrites?: string[];
|
||||
};
|
||||
|
||||
export type PluginManifest = {
|
||||
/**
|
||||
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
|
||||
* code paths. Constant today; the seam third parties hang off later.
|
||||
*/
|
||||
publisher: string;
|
||||
/** The plugin's own semver. Updates compare against this. */
|
||||
version: string;
|
||||
/** Which platform versions this build is good for. Refused at install when it does not match. */
|
||||
platform: string;
|
||||
|
||||
label: string;
|
||||
summary: string;
|
||||
/** A lucide icon name, resolved at render. */
|
||||
icon: string;
|
||||
/** Tile colour. */
|
||||
color: string;
|
||||
|
||||
permissions: PluginPermission[];
|
||||
};
|
||||
|
||||
/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */
|
||||
export type DiscoveredPlugin = {
|
||||
/**
|
||||
* THE id — route segment, table prefix, sidecar suffix, install key.
|
||||
*
|
||||
* Taken from the DIRECTORY NAME rather than declared, so the id cannot disagree with where the code
|
||||
* sits. The cost is that renaming a directory re-identifies the plugin; the benefit is that the two can
|
||||
* never drift, and a wrong table prefix is a much quieter failure than a missing directory.
|
||||
*/
|
||||
appName: string;
|
||||
/** Absolute path to the plugin's directory. */
|
||||
dir: string;
|
||||
manifest: PluginManifest;
|
||||
|
||||
/** `api/router.ts` — a backend router, mounted at `mountPrefix`. */
|
||||
api: string | null;
|
||||
/** `db/schema.ts` — tables, pushed on install. Every name must be prefixed `<appName>_`. */
|
||||
schema: string | null;
|
||||
/** `sidecar/index.{ts,mjs}` — a process for PM2. */
|
||||
sidecar: { script: string; runtime: 'bun' | 'node' } | null;
|
||||
/** `web/Router.tsx` — a frontend, mounted at `<mountPrefix>/*` by the generated Plugins.tsx. */
|
||||
web: { router: string; panels: string | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a plugin's routes live, on both the API and the frontend.
|
||||
*
|
||||
* `publisher` is the only input, deliberately. First-party plugins sit at the root because Officer Dev
|
||||
* owns that namespace anyway and provenance is then legible at a glance in a log; third-party plugins sit
|
||||
* under `/p/<publisher>/`, which is what makes it impossible for any plugin to shadow a core route — and
|
||||
* therefore what lets the platform keep adding core routes forever without breaking an install.
|
||||
*
|
||||
* NOTHING else in the codebase may branch on provenance. If that difference leaks past this one function
|
||||
* — a special case in the router, a bypassed check, a different install branch — first-party and
|
||||
* third-party become two systems, and only one of them gets tested.
|
||||
*/
|
||||
export const FIRST_PARTY_PUBLISHER = 'officerdev';
|
||||
|
||||
export function mountPrefix(plugin: { appName: string; manifest: { publisher: string } }): string {
|
||||
const { appName } = plugin;
|
||||
return plugin.manifest.publisher === FIRST_PARTY_PUBLISHER
|
||||
? `/${appName}`
|
||||
: `/p/${plugin.manifest.publisher}/${appName}`;
|
||||
}
|
||||
|
||||
/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */
|
||||
const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
||||
|
||||
/** A publisher shares the app name's constraints — it is a path segment too. */
|
||||
const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/;
|
||||
|
||||
/**
|
||||
* Validate a manifest read off disk. Returns the reasons it is unusable, empty when it is fine.
|
||||
*
|
||||
* Returns every problem rather than the first, because an install that fails one field at a time is an
|
||||
* install someone retries four times.
|
||||
*/
|
||||
export function manifestProblems(appName: string, manifest: Partial<PluginManifest> | null): string[] {
|
||||
const problems: string[] = [];
|
||||
if (!manifest) return ['no manifest, or it did not export `manifest`'];
|
||||
|
||||
if (!APP_NAME_RE.test(appName)) {
|
||||
problems.push(`directory name "${appName}" must be lowercase letters, digits and dashes, starting with a letter`);
|
||||
}
|
||||
if (typeof manifest.publisher !== 'string' || !PUBLISHER_RE.test(manifest.publisher)) {
|
||||
problems.push('publisher must be lowercase letters, digits and dashes');
|
||||
}
|
||||
for (const field of ['version', 'platform', 'label', 'summary', 'icon', 'color'] as const) {
|
||||
if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`);
|
||||
}
|
||||
if (!Array.isArray(manifest.permissions)) {
|
||||
problems.push('permissions must be an array (use [] when the plugin gates nothing)');
|
||||
} else {
|
||||
for (const [i, permission] of manifest.permissions.entries()) {
|
||||
if (!permission || typeof permission.key !== 'string' || !permission.key) {
|
||||
problems.push(`permissions[${i}].key is required`);
|
||||
continue;
|
||||
}
|
||||
// The permission key shares the capability registry's namespace, so a plugin colliding with a core
|
||||
// capability would silently widen or narrow it. Prefixing is not enforced here — the installer
|
||||
// checks against the live registry, which is the only thing that knows what is taken.
|
||||
if (typeof permission.label !== 'string' || !permission.label) {
|
||||
problems.push(`permissions[${i}].label is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { listPluginInstalls, type PluginInstall } from 'officerdb';
|
||||
import type { MountedPlugin } from '../hono';
|
||||
import { rebuildHonoApp } from '../hono';
|
||||
import { discoverPlugins } from './discover';
|
||||
import { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
|
||||
// Turning what is on disk plus what is in the database into a mounted application.
|
||||
//
|
||||
// Three states, and they are genuinely different questions:
|
||||
//
|
||||
// on disk the directory exists — `discoverPlugins`
|
||||
// installed a `plugin_installs` row — the owner asked for it
|
||||
// enabled that row says so — and has not since turned it off
|
||||
//
|
||||
// Only the third mounts. A plugin a developer is writing sits in the tree unmounted; a disabled plugin
|
||||
// keeps every table and row it owns and simply stops answering.
|
||||
|
||||
/** A plugin, with whatever the database knows about it. `install` is null when nobody has installed it. */
|
||||
export type PluginState = {
|
||||
plugin: DiscoveredPlugin;
|
||||
install: PluginInstall | null;
|
||||
/** The manifest on disk moved after it was installed — normal while developing, worth being able to see. */
|
||||
outdated: boolean;
|
||||
};
|
||||
|
||||
export type PluginsSnapshot = {
|
||||
states: PluginState[];
|
||||
/** Directories that look like plugins and could not be read. Rendered, never thrown — see `discover.ts`. */
|
||||
broken: { appName: string; error: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* What is on disk, joined to what is installed.
|
||||
*
|
||||
* An install row with no directory is DROPPED rather than reported: it means the code was removed from
|
||||
* the tree while the row stayed, and there is nothing to mount, describe or offer. The row is left in the
|
||||
* database on purpose — deleting it here would turn "somebody moved the checkout" into silent data loss.
|
||||
*/
|
||||
export async function snapshotPlugins(): Promise<PluginsSnapshot> {
|
||||
const [{ plugins, broken }, installs] = await Promise.all([discoverPlugins(), listPluginInstalls()]);
|
||||
const byName = new Map(installs.map((row) => [row.appName, row]));
|
||||
|
||||
const states = plugins.map((plugin) => {
|
||||
const install = byName.get(plugin.appName) ?? null;
|
||||
return { plugin, install, outdated: !!install && install.version !== plugin.manifest.version };
|
||||
});
|
||||
|
||||
return { states, broken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a plugin's backend router.
|
||||
*
|
||||
* `api/router.ts` must export `router`. Anything else — a default export, a factory, a bare Hono — is
|
||||
* refused by name rather than mounted wrong: a plugin whose routes silently do not exist is far harder to
|
||||
* diagnose than one that refuses to install.
|
||||
*/
|
||||
export async function loadPluginRouter(plugin: DiscoveredPlugin): Promise<MountedPlugin | null> {
|
||||
if (!plugin.api) return null;
|
||||
|
||||
const module = (await import(plugin.api)) as { router?: unknown };
|
||||
const router = module.router;
|
||||
if (!router || typeof (router as { fetch?: unknown }).fetch !== 'function') {
|
||||
throw new Error(`${plugin.appName}: api/router.ts must export \`router\` (a Hono router)`);
|
||||
}
|
||||
|
||||
return { prefix: mountPrefix(plugin), router: router as MountedPlugin['router'] };
|
||||
}
|
||||
|
||||
/** The plugins that should be mounted right now: installed, enabled, and carrying an `api/router.ts`. */
|
||||
export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<MountedPlugin[]> {
|
||||
const mounted: MountedPlugin[] = [];
|
||||
for (const { plugin, install } of snapshot.states) {
|
||||
if (!install?.enabled || !plugin.api) continue;
|
||||
try {
|
||||
const entry = await loadPluginRouter(plugin);
|
||||
if (entry) mounted.push(entry);
|
||||
} catch (err) {
|
||||
// One plugin that will not load must not take the other nine down with it, and must not stop the
|
||||
// platform booting. It stays unmounted and says why.
|
||||
console.error(`[plugins] ${plugin.appName} not mounted:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
return mounted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the application from the current state of disk and database.
|
||||
*
|
||||
* This is the whole of install, uninstall, enable and disable as far as ROUTING is concerned — each of
|
||||
* those writes a row and then calls this. Hono cannot add a route to a live app and cannot remove one at
|
||||
* all, so nothing is mutated: a fresh app is built and `honoServer` is reassigned. `server.tsx` serves it
|
||||
* through a closure, which is what makes the reassignment take effect.
|
||||
*/
|
||||
export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken: string[] }> {
|
||||
const snapshot = await snapshotPlugins();
|
||||
const mounted = await mountablePlugins(snapshot);
|
||||
rebuildHonoApp(mounted);
|
||||
return { mounted: mounted.map((m) => m.prefix), broken: snapshot.broken.map((b) => b.appName) };
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { runAsArgv } from './os-user';
|
||||
|
||||
// Reading a file that belongs to a member.
|
||||
//
|
||||
// ── Why the ACL grant is not enough ──
|
||||
//
|
||||
// `confineUserTree` gives the service user a named ACL entry on every member home (`u:<serviceUid>:rwx`,
|
||||
// plus `d:` defaults so anything created later inherits it). That is what made the file browser work on
|
||||
// 2026-08-11, and it is genuinely in force — `getfacl` on a member's home shows the entry.
|
||||
//
|
||||
// It does not survive contact with a file created at mode 600, because POSIX derives the ACL **mask** from
|
||||
// the group bits of the creation mode, and the mask clamps every named entry:
|
||||
//
|
||||
// user:officer:rwx #effective:---
|
||||
// mask::---
|
||||
//
|
||||
// `claude` writes every transcript at exactly that mode (verified: `.claude` and `projects/` are 775, every
|
||||
// `*.jsonl` is 600). So the platform could list a member's transcripts and read not one byte of them — and
|
||||
// `summarizeTranscript` catches EACCES and returns null, so the sessions did not fail, they *vanished*. A
|
||||
// member chatted normally and their conversation list was empty on every refresh.
|
||||
//
|
||||
// No ACL fixes this. The creation mode ANDs the mask down, so `d:` defaults cannot raise it, and widening
|
||||
// the mode would have to go through `other` — which is every account on the box. The only readers a 600 file
|
||||
// has are its owner and root.
|
||||
//
|
||||
// ── So read as the owner of the file ──
|
||||
//
|
||||
// Which is what the terminal and the agent already do, through the same `runAsArgv` helper. The platform is
|
||||
// the owner's process and could equally read via `sudo cat`, but acting AS the member keeps one rule instead
|
||||
// of two: a member's bytes are reached through the member's identity, and the kernel stays the arbiter.
|
||||
//
|
||||
// Deliberately synchronous. `Bun.spawnSync` is what lets this drop into `claude-sessions.ts` — 914 lines and
|
||||
// 28 functions of synchronous parsing, reached from five modules — without turning the whole read path async
|
||||
// for a subprocess that takes a millisecond. The alternative was an `await` ripple through every caller for
|
||||
// no behavioural gain.
|
||||
//
|
||||
// ── That last paragraph used to say only CONTENT needed this. It was wrong ──
|
||||
//
|
||||
// It claimed `statSync` and `readdirSync` were satisfied by "the 775 directories". They are not, because
|
||||
// there are no 775 directories on this path: `claude` creates `~/.claude/projects/` and each project group
|
||||
// at mode **700**, and the same rule that clamps a 600 file clamps a 700 directory —
|
||||
//
|
||||
// $ getfacl .../.claude/projects
|
||||
// user:officer:rwx #effective:---
|
||||
// mask::---
|
||||
//
|
||||
// so the service user has neither `r` nor `x` on it. Measured, not reasoned:
|
||||
//
|
||||
// existsSync(projects) -> true (stat needs traverse on `.claude`, which IS permissive)
|
||||
// readdirSync(projects) -> EACCES
|
||||
// existsSync(projects/<slug>) -> false (no `x` on projects, so it cannot even be reached)
|
||||
// statSync(<transcript>) -> EACCES
|
||||
//
|
||||
// `existsSync` returning **false** rather than throwing is what made this invisible: every caller read it as
|
||||
// "no such session" and returned an empty list or a 404. One bug, three symptoms — an empty conversation
|
||||
// list, no title on a new chat, and a /chat/<id> deep link that never restored. The content fix landed
|
||||
// without it because content reads were already funnelled through this file; enumeration never was.
|
||||
//
|
||||
// So enumeration is here too, and as ONE call rather than a spawn per entry: `readdir` + `stat` per file
|
||||
// would be dozens of `sudo setpriv` forks per request, each writing a line to `/var/log/auth.log`. A single
|
||||
// `find` answers the whole tree — which paths exist AND their mtimes — in one fork.
|
||||
|
||||
/** One transcript on disk. `slug` is the project-group directory; `id` the session uuid. */
|
||||
export type TranscriptFile = { slug: string; id: string; mtimeMs: number };
|
||||
|
||||
/**
|
||||
* Every `*.jsonl` under `projectsDir`, with mtimes, as the owner of the files.
|
||||
*
|
||||
* Replaces `readdirSync` + `statSync`, both of which fail for a member. Returns `[]` for a tree that does
|
||||
* not exist or cannot be read — the callers all treat "no transcripts" and "cannot look" the same way, and
|
||||
* there is no useful third answer to give a list endpoint.
|
||||
*
|
||||
* `onlySlug` narrows to one project group; it bounds the owner's syscalls and the member's `find`, and the
|
||||
* result is identical either way.
|
||||
*/
|
||||
export function listTranscriptsAs(osUser: AsUser, projectsDir: string, onlySlug?: string): TranscriptFile[] {
|
||||
if (!osUser) return listTranscriptsAsSelf(projectsDir, onlySlug);
|
||||
|
||||
// GNU `-printf` is safe here: the member path exists only where `sudo setpriv` does, which is Linux. An
|
||||
// owner on macOS takes the branch above.
|
||||
// Depth follows the root: transcripts are `projects/<slug>/<id>.jsonl`, so scanning the whole tree is two
|
||||
// levels down and scanning one group is one. Pinning both bounds keeps `find` off the rest of the home.
|
||||
const root = onlySlug ? join(projectsDir, onlySlug) : projectsDir;
|
||||
const depth = onlySlug ? '1' : '2';
|
||||
let out: string;
|
||||
try {
|
||||
out = runSync(osUser, [
|
||||
'find',
|
||||
root,
|
||||
'-mindepth',
|
||||
depth,
|
||||
'-maxdepth',
|
||||
depth,
|
||||
'-name',
|
||||
'*.jsonl',
|
||||
'-printf',
|
||||
'%h\t%f\t%T@\n',
|
||||
]);
|
||||
} catch {
|
||||
// A missing tree exits non-zero, which is the same nothing as an empty one.
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: TranscriptFile[] = [];
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line) continue;
|
||||
const [dir, name, mtime] = line.split('\t');
|
||||
if (!dir || !name || !mtime) continue;
|
||||
files.push({ slug: basename(dir), id: name.replace(/\.jsonl$/, ''), mtimeMs: Math.round(Number(mtime) * 1000) });
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** The owner's own files — no fork, because this process already IS them. */
|
||||
function listTranscriptsAsSelf(projectsDir: string, onlySlug?: string): TranscriptFile[] {
|
||||
const slugs = onlySlug ? [onlySlug] : safeReaddir(projectsDir);
|
||||
const files: TranscriptFile[] = [];
|
||||
for (const slug of slugs) {
|
||||
for (const name of safeReaddir(join(projectsDir, slug))) {
|
||||
if (!name.endsWith('.jsonl')) continue;
|
||||
try {
|
||||
files.push({
|
||||
slug,
|
||||
id: name.replace(/\.jsonl$/, ''),
|
||||
mtimeMs: statSync(join(projectsDir, slug, name)).mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// Raced with a delete, or not a regular file. Either way it is not a transcript we can offer.
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const safeReaddir = (dir: string): string[] => {
|
||||
try {
|
||||
return readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/** Whose identity to read as. `null` is this process's own uid — the owner, and the common case. */
|
||||
export type AsUser = string | null;
|
||||
|
||||
/** `Bun.spawnSync` through `setpriv`, or a throw carrying enough to tell EACCES from ENOENT. */
|
||||
function runSync(osUser: string, command: string[]): string {
|
||||
const result = Bun.spawnSync(runAsArgv(osUser, command), { stdout: 'pipe', stderr: 'pipe' });
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = new TextDecoder().decode(result.stderr).trim() || `exit ${result.exitCode}`;
|
||||
throw new Error(`reading as ${osUser} failed: ${detail}`);
|
||||
}
|
||||
return new TextDecoder().decode(result.stdout);
|
||||
}
|
||||
|
||||
/** The whole file, as text. Throws on any failure, so existing `try`/`catch` around reads keeps working. */
|
||||
export function readTextAs(osUser: AsUser, path: string): string {
|
||||
if (!osUser) return readFileSync(path, 'utf-8');
|
||||
return runSync(osUser, ['cat', '--', path]);
|
||||
}
|
||||
|
||||
/** The first `bytes` bytes. Used where a header is all that is wanted and transcripts run to megabytes. */
|
||||
export function readHeadAs(osUser: AsUser, path: string, bytes: number): string {
|
||||
if (!osUser) return readRange(path, 0, bytes);
|
||||
return runSync(osUser, ['head', '-c', String(bytes), '--', path]);
|
||||
}
|
||||
|
||||
/** The last `bytes` bytes. `truncated` reports whether anything was left off the front. */
|
||||
export function readTailAs(osUser: AsUser, path: string, bytes: number): { text: string; truncated: boolean } {
|
||||
if (!osUser) {
|
||||
const size = statSync(path).size;
|
||||
return { text: readRange(path, Math.max(0, size - bytes), bytes), truncated: size > bytes };
|
||||
}
|
||||
|
||||
// `statSync` is EACCES on a member's transcript — the header explains why — so the size has to come back
|
||||
// from the same identity as the bytes. One fork for both: `wc -c` writes the size on the first line, then
|
||||
// `tail` writes the window. Splitting them would double the forks and could straddle an append.
|
||||
const out = runSync(osUser, ['sh', '-c', 'wc -c < "$1"; tail -c "$2" -- "$1"', '_', path, String(bytes)]);
|
||||
const firstBreak = out.indexOf('\n');
|
||||
const size = Number(out.slice(0, firstBreak).trim());
|
||||
return { text: out.slice(firstBreak + 1), truncated: Number.isFinite(size) && size > bytes };
|
||||
}
|
||||
|
||||
/** Append one line, as its owner. The rename path writes a `summary` entry into the member's transcript. */
|
||||
export function appendTextAs(osUser: AsUser, path: string, text: string): void {
|
||||
if (!osUser) {
|
||||
appendFileSync(path, text);
|
||||
return;
|
||||
}
|
||||
// `tee -a` rather than a shell redirect: no shell means no quoting question about the path.
|
||||
const result = Bun.spawnSync(runAsArgv(osUser, ['tee', '-a', '--', path]), {
|
||||
stdin: new TextEncoder().encode(text),
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
const detail = new TextDecoder().decode(result.stderr).trim() || `exit ${result.exitCode}`;
|
||||
throw new Error(`appending as ${osUser} failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file as its owner. Returns false if it was not there.
|
||||
*
|
||||
* Needed for the same reason the listing is: removing an entry needs `w`+`x` on the DIRECTORY, and the
|
||||
* service user has neither on a member's `projects/<slug>`. Without this, deleting a member's conversation
|
||||
* silently removed nothing and still answered `{ ok: true }`.
|
||||
*/
|
||||
export function removeAs(osUser: AsUser, path: string): boolean {
|
||||
if (!osUser) {
|
||||
if (!existsSync(path)) return false;
|
||||
rmSync(path);
|
||||
return true;
|
||||
}
|
||||
// `rm -f` exits 0 on a missing file, so absence is reported by testing first — in the same fork.
|
||||
const out = runSync(osUser, [
|
||||
'sh',
|
||||
'-c',
|
||||
'if test -e "$1"; then rm -f -- "$1" && printf 1; else printf 0; fi',
|
||||
'_',
|
||||
path,
|
||||
]);
|
||||
return out.trim() === '1';
|
||||
}
|
||||
|
||||
/** Owner fast path for a byte window — the same positional read the callers used before this file existed. */
|
||||
function readRange(path: string, start: number, bytes: number): string {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(path, 'r');
|
||||
const buf = Buffer.alloc(bytes);
|
||||
const n = readSync(fd, buf, 0, bytes, start);
|
||||
return buf.toString('utf-8', 0, n);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,30 @@ bindkey '^[[A' up-line-or-beginning-search # Up: history matching what is alr
|
||||
bindkey '^[[B' down-line-or-beginning-search
|
||||
bindkey '^[[1;5C' forward-word # ctrl-arrow by word
|
||||
bindkey '^[[1;5D' backward-word
|
||||
bindkey '^[[1;3C' forward-word # alt-arrow by word — see below
|
||||
bindkey '^[[1;3D' backward-word
|
||||
bindkey '^[[3~' delete-char
|
||||
bindkey '^[[H' beginning-of-line
|
||||
bindkey '^[[F' end-of-line
|
||||
bindkey '^[[1~' beginning-of-line # the other Home/End encoding; terminals disagree
|
||||
bindkey '^[[4~' end-of-line
|
||||
bindkey '^H' backward-kill-word # ctrl-backspace (and alt-backspace, which sends ^[^?)
|
||||
bindkey '^[^?' backward-kill-word
|
||||
bindkey '^[[3;5~' kill-word # ctrl-delete
|
||||
|
||||
# Why alt-arrow needs binding at all, given ^[b/^[f already work.
|
||||
#
|
||||
# xterm.js 5 rewrote Alt+Left/Right into the ctrl-arrow sequence, so `^[[1;5D` covered both. **xterm.js 6
|
||||
# removed that rewrite** (verified: the string `1;3D` does not appear anywhere in the 6.0 bundle), and now
|
||||
# emits the honest `^[[1;3D`. Nothing bound it, so alt-arrow became a no-op the moment the dependency moved
|
||||
# — on a machine where nobody had changed a line of shell config.
|
||||
#
|
||||
# Bound here rather than translated in the browser on purpose: `tmux.conf` claims M-Left/M-Right for pane
|
||||
# switching, and a client-side rewrite would send `^[b` to tmux and break that. Letting the real sequence
|
||||
# through means tmux gets it inside a session and zsh gets it outside, which is what both expect.
|
||||
#
|
||||
# Cmd+Left/Right is deliberately absent: xterm emits NOTHING for it (`case 37: if (e.metaKey) break`), so
|
||||
# there is no sequence to bind. On macOS that chord reaches the browser as back/forward.
|
||||
|
||||
# ── Editor ──
|
||||
if command -v nvim >/dev/null 2>&1; then
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { ChatEvent, PromptImage } from '../../api/chat/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
@@ -33,6 +34,39 @@ function resolveClaudeBin(): string {
|
||||
const CLAUDE_BIN = resolveClaudeBin();
|
||||
console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`);
|
||||
|
||||
/**
|
||||
* Variables the `claude` CLI injects to describe ITS OWN session, which must never reach a `claude` we
|
||||
* spawn ourselves.
|
||||
*
|
||||
* They arrive here by an ordinary accident: PM2 inherits the environment of whoever ran `pm2 start`, so
|
||||
* restarting this sidecar from inside a Claude Code terminal — which is how it is restarted most of the
|
||||
* time — bakes that terminal's session into the daemon. On 2026-08-14 this process was carrying
|
||||
* `CLAUDE_CODE_MESSAGING_SOCKET` for an unrelated PID that had been alive for an hour and a half.
|
||||
*
|
||||
* Only three of these were stripped before (`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SSE_PORT`);
|
||||
* the rest are newer and arrived with 2.x. Stripping them is hygiene rather than a fix — a spawn was
|
||||
* verified to succeed with the whole set present — but the failure it prevents is a child attaching to a
|
||||
* stranger's IPC socket, which would be extremely hard to recognise from the symptom.
|
||||
*/
|
||||
const NESTED_SESSION_ENV = [
|
||||
'CLAUDECODE',
|
||||
'CLAUDE_CODE_ENTRYPOINT',
|
||||
'CLAUDE_CODE_SSE_PORT',
|
||||
'CLAUDE_CODE_CHILD_SESSION',
|
||||
'CLAUDE_CODE_MESSAGING_SOCKET',
|
||||
'CLAUDE_CODE_MESSAGING_TOKEN',
|
||||
'CLAUDE_CODE_SESSION_ID',
|
||||
'CLAUDE_CODE_EXECPATH',
|
||||
'CLAUDE_PID',
|
||||
] as const;
|
||||
|
||||
/** `process.env` minus the parent session's fingerprint. */
|
||||
function envWithoutParentSession(): Record<string, string> {
|
||||
const out: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||
for (const name of NESTED_SESSION_ENV) delete out[name];
|
||||
return out;
|
||||
}
|
||||
|
||||
// Capture original HOME before user-instance overrides it
|
||||
const HOST_HOME = process.env.HOME!;
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
@@ -216,6 +250,79 @@ const COMPACT_STALL_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
const sessions = new Map<string, PersistentSession>();
|
||||
|
||||
/**
|
||||
* The owner's spawn — what the SDK would do by default, written out so it can be wrapped by `watchChild`.
|
||||
*
|
||||
* A member's turn already supplies its own (`spawnClaudeAsMember`) because it has to go through `setpriv`.
|
||||
* The owner had no such function and therefore no place to observe the child, which is precisely why its
|
||||
* death was invisible. The existence check mirrors the SDK's default: without it a bad `CLAUDE_BIN` fails
|
||||
* as a write to a closed pipe several seconds later, naming nothing useful.
|
||||
*/
|
||||
function spawnClaudeAsOwner({ command, args, cwd, env, signal }: SpawnOptions): SpawnedProcess {
|
||||
if (!existsSync(command)) throw new Error(`claude CLI not found at ${command}`);
|
||||
const child = spawn(command, args, { cwd, env, signal, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
// Non-null by construction: 'pipe' on all three. Mirrors the cast in `spawn-as-member.ts`.
|
||||
return child as unknown as SpawnedProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a session whose `claude` process is gone, and tell whoever was waiting.
|
||||
*
|
||||
* This closes the hole that made a dead agent look like a slow one **forever**. The SDK runs two
|
||||
* independent tasks per session: the consumer loop (`for await (const msg of q)`) and an input pump that
|
||||
* writes the queue to the child's stdin. The consumer loop's `finally` is what removes a session from the
|
||||
* map — but when the CHILD dies, it is the input pump that fails, with `ProcessTransport is not ready for
|
||||
* writing`, and that rejection neither ends the consumer loop nor is caught anywhere. So the loop stayed
|
||||
* parked on a stream with no writer, `finally` never ran, the session stayed in the map, and
|
||||
* `spawnClaudeStreaming` handed every later turn to the same corpse. Each one pushed a message onto a
|
||||
* queue nobody drained: no error, no result, no timeout. The client spun forever, and the only trace was
|
||||
* an `unhandledRejection` line in the sidecar log.
|
||||
*
|
||||
* Observed on 2026-08-14; the session had to be cleared with `pm2 restart officer-claude-code`.
|
||||
*
|
||||
* Emitting only while a turn is in flight is deliberate. A child that exits between turns is invisible to
|
||||
* the user, and an error bubble arriving in a chat nobody is looking at would be noise — dropping the map
|
||||
* entry is the whole repair there, because the next turn then builds a fresh session and resumes the
|
||||
* transcript by id.
|
||||
*/
|
||||
function dropDeadSession(session: PersistentSession, detail: string): void {
|
||||
// Identity, not key: a later turn may have already replaced this entry, and killing its session because
|
||||
// its predecessor's process exited would break the live conversation instead of a dead one.
|
||||
if (sessions.get(session.sessionKey) !== session) return;
|
||||
sessions.delete(session.sessionKey);
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
if (session.stallTimer) clearTimeout(session.stallTimer);
|
||||
session.idleTimer = undefined;
|
||||
session.stallTimer = undefined;
|
||||
|
||||
const wasGenerating = session.isGenerating;
|
||||
session.isGenerating = false;
|
||||
// A deliberate teardown (kill / idle GC) aborts first, and its exit is not news.
|
||||
if (session.abort.signal.aborted) return;
|
||||
|
||||
console.error(`[claude:exit:${session.sessionKey}] ${detail}`);
|
||||
if (!wasGenerating) return;
|
||||
session.emit({
|
||||
type: 'error',
|
||||
message: 'The agent process exited unexpectedly. Your conversation is safe — send again to continue.',
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap a spawn so the session self-heals when its child goes away. */
|
||||
function watchChild(
|
||||
inner: (options: SpawnOptions) => SpawnedProcess,
|
||||
session: PersistentSession,
|
||||
): (options: SpawnOptions) => SpawnedProcess {
|
||||
return (options: SpawnOptions): SpawnedProcess => {
|
||||
const child = inner(options);
|
||||
child.on('exit', (code, signal) =>
|
||||
dropDeadSession(session, `claude exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`),
|
||||
);
|
||||
child.on('error', (err: Error) => dropDeadSession(session, `claude failed to start: ${err.message}`));
|
||||
return child;
|
||||
};
|
||||
}
|
||||
|
||||
/** A hand-rolled async iterable we can push turns onto and close on teardown. */
|
||||
function makeInputQueue() {
|
||||
const buf: SdkUserMessage[] = [];
|
||||
@@ -273,13 +380,25 @@ function armStall(session: PersistentSession): void {
|
||||
session.isGenerating = false;
|
||||
session.compactStartedAt = undefined;
|
||||
session.interrupted = false;
|
||||
if (session.pendingTasks.size === 0) armIdle(session);
|
||||
session.emit({
|
||||
type: 'error',
|
||||
message: compacting
|
||||
? `Compaction has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.`
|
||||
: `The agent has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.`,
|
||||
});
|
||||
// A background job can be silent for far longer than this and still land its `task_notification`, so
|
||||
// a stall with tasks outstanding keeps the old behaviour and leaves the session alone.
|
||||
if (session.pendingTasks.size > 0) return;
|
||||
|
||||
// Otherwise tear it down rather than leaving it armed for the next turn.
|
||||
//
|
||||
// This used to keep the session — "it may still be working, and the next turn resumes it" — which is
|
||||
// the right instinct for a SLOW agent and exactly wrong for a wedged one: a session that has said
|
||||
// nothing for ten minutes because its transport is broken stays broken, so every later turn hangs
|
||||
// the same way and the message above ("send again to continue") is a lie. Killing costs a resume,
|
||||
// which is what the message already promises; the transcript id survives in `claudeSessions`, so the
|
||||
// next turn continues the same conversation.
|
||||
killClaudeSession(session.sessionKey, session.userId);
|
||||
},
|
||||
compacting ? COMPACT_STALL_TIMEOUT_MS : STALL_TIMEOUT_MS,
|
||||
);
|
||||
@@ -318,7 +437,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
};
|
||||
|
||||
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
|
||||
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
|
||||
const cleanEnv = envWithoutParentSession();
|
||||
|
||||
const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
|
||||
const subModel = params.model?.split('/')[1];
|
||||
@@ -361,12 +480,18 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
// authoritative for settings, and `~` is decided by the HOME the process gets. Pointing the SDK at a
|
||||
// member's binary while spawning as the service user would read the OWNER'S settings and credential
|
||||
// while executing the member's code — the worst of both, and it would look like it worked.
|
||||
//
|
||||
// Both branches go through `watchChild`: the owner's spawn exists only so there is something to
|
||||
// wrap (see `spawnClaudeAsOwner`), because a child nobody watches is a session that can die silently.
|
||||
...(params.member
|
||||
? {
|
||||
pathToClaudeCodeExecutable: claudeBinIn(params.member.home),
|
||||
spawnClaudeCodeProcess: spawnClaudeAsMember(params.member),
|
||||
spawnClaudeCodeProcess: watchChild(spawnClaudeAsMember(params.member), session),
|
||||
}
|
||||
: { pathToClaudeCodeExecutable: CLAUDE_BIN }),
|
||||
: {
|
||||
pathToClaudeCodeExecutable: CLAUDE_BIN,
|
||||
spawnClaudeCodeProcess: watchChild(spawnClaudeAsOwner, session),
|
||||
}),
|
||||
settingSources: ['user', 'project', 'local'],
|
||||
env: cleanEnv as Record<string, string>,
|
||||
stderr: (d: string) => {
|
||||
|
||||
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
|
||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||
//
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||
// HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||
|
||||
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// DELETE /_officer/keys/:id delete outright
|
||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||
// for a joining device. userId is only required when the server
|
||||
// has more than one user; reached via /api/vpn/enroll.
|
||||
// has more than one user.
|
||||
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||
// which is deleted. Kept because it is the handler a route under
|
||||
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||
// IS live — dispatches through the same function.
|
||||
// anything else 404
|
||||
//
|
||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
|
||||
@@ -46,8 +46,40 @@ const resolveCwd = (cwd, base) => {
|
||||
return home;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sequences that make a terminal ANSWER, stripped before anything is stored.
|
||||
*
|
||||
* The scrollback is replayed verbatim to a re-attaching client. Anything in it that asks the terminal a
|
||||
* question gets asked AGAIN on every reconnect — and xterm answers, correctly, by writing the reply to its
|
||||
* input. That input is a keystroke as far as the pty is concerned, so a reconnect injects text into the
|
||||
* shell that nobody typed: `^[[?62;c` and friends landing on the command line, or being eaten by whatever
|
||||
* TUI is running. It is the "terminal goes weird after reconnecting" symptom, and it is not the shell's
|
||||
* fault.
|
||||
*
|
||||
* Stripping on the way IN rather than on the way out: the buffer is the thing that gets replayed, and a
|
||||
* live client has already answered these once, at the moment they were legitimately asked.
|
||||
*
|
||||
* What is removed is only ever a QUESTION. Colour, cursor movement, screen clears — everything that draws —
|
||||
* is untouched, so a replay still reproduces the screen exactly.
|
||||
*/
|
||||
// Each pattern is the QUERY form only. Where a control shares its final byte with a command that DRAWS,
|
||||
// the numeric parameter is enumerated rather than wildcarded — `CSI 18 t` asks the window size, but
|
||||
// `CSI 22 t` pushes the title, and stripping the second would silently change what a replay renders.
|
||||
const QUERY_SEQUENCES = [
|
||||
/\x1b\[\??[56]n/g, // DSR — cursor position (6n), status (5n), and the DEC `?` variants
|
||||
/\x1b\[[0-9;?>=]*c/g, // DA1/DA2/DA3 — device attributes. `c` is only ever a query.
|
||||
/\x1b\[\?[0-9;]*\$p/g, // DECRQM — mode query
|
||||
/\x1b\[(?:1[1345689]|2[01])(?:;[0-9]+)*t/g, // XTWINOPS reports only — NOT 22/23 (title push/pop)
|
||||
/\x1b\[>[0-9;]*q/g, // XTVERSION
|
||||
/\x1bP\+q[0-9a-fA-F;]*(?:\x1b\\|\x07)/g, // DCS XTGETTCAP — terminfo capability query
|
||||
/\x1b\](?:10|11|12|4;[0-9]+);\?(?:\x07|\x1b\\)/g, // OSC colour queries (fg/bg/cursor/palette)
|
||||
];
|
||||
|
||||
/** Exported for the test: this is the one function here whose mistakes are invisible until a replay. */
|
||||
export const stripQueries = (data) => QUERY_SEQUENCES.reduce((out, re) => out.replace(re, ''), data);
|
||||
|
||||
const appendBuffer = (session, data) => {
|
||||
session.buffer += data;
|
||||
session.buffer += stripQueries(data);
|
||||
if (session.buffer.length > BUFFER_MAX) {
|
||||
// Cut on a line boundary, not a byte offset. A blind slice can land inside an escape sequence, and the
|
||||
// replay then opens with the tail of a colour or cursor-move code — which xterm renders as garbage, or
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
// The sidecar is .mjs — node-pty binds a native addon against node's ABI, so this half of the tree is plain
|
||||
// JavaScript. Only the pure function is imported; nothing here spawns a pty.
|
||||
import { stripQueries } from './sessions.mjs';
|
||||
|
||||
// The scrollback is replayed verbatim when a client re-attaches. Anything in it that ASKS the terminal a
|
||||
// question is asked again on every reconnect, and xterm answers by writing the reply to its input — which
|
||||
// the pty receives as a keystroke nobody typed. That is the whole reason this function exists.
|
||||
//
|
||||
// It is tested rather than eyeballed because both directions fail silently: strip too little and a reconnect
|
||||
// injects junk into the shell, strip too much and a replay renders differently from the live screen, and
|
||||
// neither shows up until someone reconnects at the wrong moment.
|
||||
|
||||
describe('stripQueries — removes what would be answered', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['cursor position report (DSR 6n)', '\x1b[6n'],
|
||||
['status report (DSR 5n)', '\x1b[5n'],
|
||||
['DEC variant (DSR ?6n)', '\x1b[?6n'],
|
||||
['primary device attributes', '\x1b[c'],
|
||||
['secondary device attributes', '\x1b[>c'],
|
||||
['tertiary device attributes', '\x1b[=c'],
|
||||
['DA with parameters', '\x1b[0c'],
|
||||
['mode query (DECRQM)', '\x1b[?2026$p'],
|
||||
['window size report (XTWINOPS 18)', '\x1b[18t'],
|
||||
['text area size (XTWINOPS 14)', '\x1b[14t'],
|
||||
['version query (XTVERSION)', '\x1b[>0q'],
|
||||
['terminfo capability query (XTGETTCAP)', '\x1bP+q544e\x1b\\'],
|
||||
['foreground colour query', '\x1b]10;?\x07'],
|
||||
['background colour query', '\x1b]11;?\x1b\\'],
|
||||
['cursor colour query', '\x1b]12;?\x07'],
|
||||
['palette entry query', '\x1b]4;1;?\x07'],
|
||||
];
|
||||
|
||||
for (const [name, sequence] of cases) {
|
||||
test(`strips ${name}`, () => {
|
||||
expect(stripQueries(`before${sequence}after`)).toBe('beforeafter');
|
||||
});
|
||||
}
|
||||
|
||||
test('strips several in one chunk, and repeats of the same one', () => {
|
||||
expect(stripQueries('a\x1b[6nb\x1b[cc\x1b[6nd')).toBe('abcd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripQueries — keeps everything that draws', () => {
|
||||
const kept: Array<[string, string]> = [
|
||||
['SGR colour', '\x1b[31mred\x1b[0m'],
|
||||
['256-colour SGR', '\x1b[38;5;208m'],
|
||||
['truecolour SGR', '\x1b[38;2;255;128;0m'],
|
||||
['cursor movement', '\x1b[10;20H'],
|
||||
['cursor up', '\x1b[3A'],
|
||||
['erase in display', '\x1b[2J'],
|
||||
['erase in line', '\x1b[K'],
|
||||
['alt screen on', '\x1b[?1049h'],
|
||||
['alt screen off', '\x1b[?1049l'],
|
||||
['bracketed paste on', '\x1b[?2004h'],
|
||||
['mouse tracking on', '\x1b[?1000h'],
|
||||
['scroll region', '\x1b[1;24r'],
|
||||
['window title (OSC 0)', '\x1b]0;my title\x07'],
|
||||
['window title (OSC 2)', '\x1b]2;my title\x07'],
|
||||
['OSC 52 clipboard write', '\x1b]52;c;aGVsbG8=\x07'],
|
||||
['save cursor', '\x1b7'],
|
||||
['plain text with newlines', 'line one\r\nline two\r\n'],
|
||||
];
|
||||
|
||||
for (const [name, sequence] of kept) {
|
||||
test(`keeps ${name}`, () => {
|
||||
expect(stripQueries(sequence)).toBe(sequence);
|
||||
});
|
||||
}
|
||||
|
||||
// The pair that made the XTWINOPS pattern enumerate its parameters instead of wildcarding them: both are
|
||||
// `CSI … t`, one is a question and the other changes what a replay renders.
|
||||
test('keeps title push/pop (XTWINOPS 22/23) while stripping the reports', () => {
|
||||
expect(stripQueries('\x1b[22;0t\x1b[18t\x1b[23;0t')).toBe('\x1b[22;0t\x1b[23;0t');
|
||||
});
|
||||
|
||||
test('leaves a realistic prompt untouched', () => {
|
||||
const prompt = '\x1b]0;green@edge: ~\x07\x1b[1;32mgreen@edge\x1b[0m:\x1b[1;34m~\x1b[0m$ ';
|
||||
expect(stripQueries(prompt)).toBe(prompt);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,21 @@ import { getHomeDir, getOwnerHomeDir } from './data-path';
|
||||
// No Linux account means no confinement means no access, and the refusal names the fix.
|
||||
|
||||
export type HomeResolution =
|
||||
| { ok: true; home: string; isOwner: boolean }
|
||||
| {
|
||||
ok: true;
|
||||
home: string;
|
||||
isOwner: boolean;
|
||||
/**
|
||||
* The Linux account whose identity reaches this home, or `null` for the owner — who IS this process's
|
||||
* uid, so there is nobody to become.
|
||||
*
|
||||
* Carried because resolving the home is not enough to READ inside it: a member's files are theirs and
|
||||
* `claude` writes transcripts at mode 600, which clamps the platform's ACL entry to nothing. See
|
||||
* `read-as-user.ts`. Reported here rather than looked up again at each call site so that "whose home"
|
||||
* and "whose identity" cannot drift apart — they are one answer from one row.
|
||||
*/
|
||||
osUser: string | null;
|
||||
}
|
||||
| { ok: false; reason: string; needsOsAccount: boolean };
|
||||
|
||||
/**
|
||||
@@ -45,7 +59,7 @@ export async function resolveHomeDir(userId: number): Promise<HomeResolution> {
|
||||
// The owner runs in their real login home — the whole point of HOME_DIR, and what makes platform
|
||||
// terminals share config and credentials with the shell they use outside Officer.
|
||||
if (user.role === 'Super Admin') {
|
||||
return { ok: true, home: getOwnerHomeDir(user.email), isOwner: true };
|
||||
return { ok: true, home: getOwnerHomeDir(user.email), isOwner: true, osUser: null };
|
||||
}
|
||||
|
||||
if (!user.osUser) {
|
||||
@@ -59,5 +73,5 @@ export async function resolveHomeDir(userId: number): Promise<HomeResolution> {
|
||||
// `getHomeDir` and `osUserHome` are deliberately the same path: DATA_PATH/<email>/home is both the
|
||||
// managed home the platform provisions and the real passwd home of the Linux account. If those ever
|
||||
// diverge, a member's shell and their file browser would show different directories.
|
||||
return { ok: true, home: getHomeDir(user.email), isOwner: false };
|
||||
return { ok: true, home: getHomeDir(user.email), isOwner: false, osUser: user.osUser };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
|
||||
import { appRegistryMetas as pluginsMetas } from '../apps/Plugins';
|
||||
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
|
||||
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
|
||||
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
||||
@@ -45,6 +46,7 @@ export const apps = [
|
||||
...qrTransferMetas,
|
||||
...davMetas,
|
||||
...appStoreMetas,
|
||||
...pluginsMetas,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+3
-5
@@ -23,7 +23,6 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
||||
setViewMode,
|
||||
setShowVideoDownload,
|
||||
setShowDictate,
|
||||
hiddenForced,
|
||||
} = fileBrowserManager;
|
||||
|
||||
return (
|
||||
@@ -124,11 +123,10 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
disabled={hiddenForced}
|
||||
className={`hidden md:block p-1.5 rounded-md transition-colors ${hiddenForced ? 'opacity-30 cursor-not-allowed' : `cursor-pointer ${showHidden && !hiddenForced ? 'bg-duck-teal text-duck-yellow' : 'text-duck-teal hover:bg-duck-dark/5'}`}`}
|
||||
title={hiddenForced ? 'Hidden files not shown in home directory' : showHidden ? 'Hide hidden files' : 'Show hidden files'}
|
||||
className={`hidden md:block p-1.5 rounded-md cursor-pointer transition-colors ${showHidden ? 'bg-duck-teal text-duck-yellow' : 'text-duck-teal hover:bg-duck-dark/5'}`}
|
||||
title={showHidden ? 'Hide hidden files' : 'Show hidden files'}
|
||||
>
|
||||
{showHidden && !hiddenForced ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
{showHidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
|
||||
<button
|
||||
|
||||
@@ -97,8 +97,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
filesRef.current = files;
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
const hiddenForced = currentPath === '/';
|
||||
const visibleEntries = showHidden && !hiddenForced ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
const selectedPaths = () => Array.from(selected).map(entryPath);
|
||||
@@ -676,7 +675,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
setViewMode,
|
||||
showHidden,
|
||||
setShowHidden,
|
||||
hiddenForced,
|
||||
// Selection
|
||||
selected,
|
||||
setSelected,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { usePlugins, type PluginItem } from './usePlugins';
|
||||
|
||||
// The right panel: one plugin, and the four verbs.
|
||||
//
|
||||
// Reads `?selected=` itself rather than being handed a plugin by the list — neither panel tells the other
|
||||
// anything, so they cannot disagree.
|
||||
|
||||
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex gap-3 py-1.5 text-sm">
|
||||
<span className="w-28 shrink-0 text-duck-dark/50">{label}</span>
|
||||
<span className="min-w-0 text-duck-dark">{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Button = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tone = 'ghost',
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'primary' | 'ghost' | 'danger';
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const tones = {
|
||||
primary: 'bg-duck-teal text-duck-yellow hover:opacity-90',
|
||||
ghost: 'border border-duck-dark/20 text-duck-dark hover:bg-duck-dark/5',
|
||||
danger: 'border border-red-300 text-red-600 hover:bg-red-50',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-md px-3 py-1.5 text-sm transition-colors disabled:opacity-40 ${tones[tone]}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/** What the tree declared. Shown because "installed but nothing happened" is otherwise a mystery. */
|
||||
const Parts = ({ has }: { has: PluginItem['has'] }) => {
|
||||
const parts = [
|
||||
['api', has.api],
|
||||
['schema', has.schema],
|
||||
['sidecar', has.sidecar],
|
||||
['web', has.web],
|
||||
] as const;
|
||||
const present = parts.filter(([, yes]) => yes).map(([name]) => name);
|
||||
return <>{present.length ? present.join(' · ') : 'manifest only'}</>;
|
||||
};
|
||||
|
||||
export const PluginDetail = () => {
|
||||
const { plugins, steps, result, running, run } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const plugin = plugins.find((p) => p.appName === params.get('selected'));
|
||||
|
||||
if (!plugin) {
|
||||
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
|
||||
}
|
||||
|
||||
const busy = running !== null;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">{plugin.summary}</p>
|
||||
|
||||
<div className="mt-5 border-t border-duck-dark/10 pt-4">
|
||||
<Row label="Mounts at">
|
||||
<code>/api{plugin.prefix}</code>
|
||||
</Row>
|
||||
<Row label="Publisher">{plugin.publisher}</Row>
|
||||
<Row label="Version">
|
||||
{plugin.version}
|
||||
{plugin.outdated ? (
|
||||
<span className="ml-2 text-amber-600">on disk — installed {plugin.installedVersion}</span>
|
||||
) : null}
|
||||
</Row>
|
||||
<Row label="Needs platform">{plugin.platform}</Row>
|
||||
<Row label="Ships">
|
||||
<Parts has={plugin.has} />
|
||||
</Row>
|
||||
{plugin.has.sidecar ? (
|
||||
<Row label="Sidecar">
|
||||
{/* Enabled but not online is the state worth naming: the plugin is switched on and its
|
||||
process is not running, which is broken rather than off. */}
|
||||
<span
|
||||
className={
|
||||
plugin.enabled && plugin.processStatus !== 'online' && plugin.installed
|
||||
? 'text-amber-600'
|
||||
: 'text-duck-dark'
|
||||
}
|
||||
>
|
||||
{plugin.processStatus ?? 'not started'}
|
||||
</span>
|
||||
</Row>
|
||||
) : null}
|
||||
<Row label="Permissions">
|
||||
{plugin.permissions.length
|
||||
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
|
||||
: 'none — reachable by anyone who can reach the platform'}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{!plugin.installed ? (
|
||||
<Button tone="primary" disabled={busy} onClick={() => run('install', plugin.appName)}>
|
||||
Install
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{plugin.enabled ? (
|
||||
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button tone="primary" disabled={busy} onClick={() => run('enable', plugin.appName)}>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{plugin.outdated ? (
|
||||
<Button disabled={busy} onClick={() => run('install', plugin.appName)}>
|
||||
Update to {plugin.version}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button tone="danger" disabled={busy} onClick={() => run('uninstall', plugin.appName)}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The log, as it arrives. `steps` fills while the stream is open and `result` lands when it
|
||||
closes, so the same block is both the progress and the verdict — an install that mounted routes
|
||||
but could not start a sidecar reads differently from one that worked, and a spinner cannot. */}
|
||||
{steps.length || result ? (
|
||||
<div className="mt-5 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3">
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs font-medium text-duck-dark/60">
|
||||
{running ? (
|
||||
<>
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-duck-teal" />
|
||||
{running}…
|
||||
</>
|
||||
) : (
|
||||
<span className={result?.ok ? 'text-emerald-600' : 'text-red-600'}>{result?.ok ? 'Done' : 'Failed'}</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-xs text-duck-dark/70">
|
||||
{steps.map((s, i) => (
|
||||
<li key={i}>· {s}</li>
|
||||
))}
|
||||
</ul>
|
||||
{result?.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
|
||||
leaving someone to guess whether the button destroys their data. */}
|
||||
{plugin.installed ? (
|
||||
<p className="mt-4 text-xs text-duck-dark/40">
|
||||
Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install — neither deletes
|
||||
anything the plugin stored.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Puzzle, AlertTriangle } from 'lucide-react';
|
||||
import { usePlugins, type PluginItem } from './usePlugins';
|
||||
|
||||
// The left panel: every plugin in the tree, installed or not.
|
||||
//
|
||||
// Rows are real `<Link>`s carrying `?selected=`, not buttons with the name in a closure — so cmd-click,
|
||||
// middle-click and "copy link" all work, and the detail panel reads the URL rather than being told.
|
||||
// See docs/navigation-audit.md on the opaque-click anti-pattern.
|
||||
|
||||
const Status = ({ plugin }: { plugin: PluginItem }) => {
|
||||
if (!plugin.installed) return <span className="text-xs text-duck-dark/40">not installed</span>;
|
||||
if (!plugin.enabled) return <span className="text-xs text-amber-600">disabled</span>;
|
||||
if (plugin.outdated) return <span className="text-xs text-amber-600">update available</span>;
|
||||
return <span className="text-xs text-emerald-600">enabled</span>;
|
||||
};
|
||||
|
||||
export const PluginsList = () => {
|
||||
const { plugins, broken, isLoading } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const selected = params.get('selected');
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{plugins.length === 0 && broken.length === 0 ? (
|
||||
<div className="p-4 text-sm text-duck-dark/50">
|
||||
No plugins in <code>plugins/</code> yet.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{plugins.map((plugin) => (
|
||||
<Link
|
||||
key={plugin.appName}
|
||||
to={`/plugins?selected=${encodeURIComponent(plugin.appName)}`}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 border-b border-duck-dark/5 transition-colors ${
|
||||
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<Puzzle className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
|
||||
<div className="truncate text-xs text-duck-dark/50">{plugin.prefix}</div>
|
||||
</div>
|
||||
<Status plugin={plugin} />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* A directory that could not be read is shown rather than swallowed — otherwise a malformed
|
||||
manifest looks exactly like a plugin nobody wrote. */}
|
||||
{broken.map((b) => (
|
||||
<div key={b.appName} className="flex items-start gap-3 px-3 py-2.5 border-b border-duck-dark/5">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-duck-dark">{b.appName}</div>
|
||||
<div className="text-xs text-red-600">{b.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Puzzle } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PluginsList } from './PluginsList';
|
||||
import { PluginDetail } from './PluginDetail';
|
||||
|
||||
export { PluginsList } from './PluginsList';
|
||||
export { PluginDetail } from './PluginDetail';
|
||||
export { usePlugins } from './usePlugins';
|
||||
export type { PluginItem, PluginPermission } from './usePlugins';
|
||||
|
||||
// Two panels, read side by side, neither telling the other anything — the selection is `?selected=` and
|
||||
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
|
||||
// /plugins, together.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
|
||||
{ key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient, getHeaders } from 'hooks/useClient';
|
||||
|
||||
// Reading and driving the plugin system. One query, four verbs.
|
||||
//
|
||||
// Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and
|
||||
// asking questions; this installs plugins from the tree and asks nothing.
|
||||
|
||||
export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
|
||||
|
||||
export type PluginItem = {
|
||||
appName: string;
|
||||
/** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
|
||||
prefix: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
publisher: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
permissions: PluginPermission[];
|
||||
/** What the directory declared. Shown so "installed but does nothing" is legible rather than puzzling. */
|
||||
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
installedVersion: string | null;
|
||||
/** The code on disk moved after it was installed — normal while developing, and worth seeing. */
|
||||
outdated: boolean;
|
||||
/**
|
||||
* PM2's word for the sidecar, or null when the plugin has none. `online` while enabled is healthy;
|
||||
* anything else while enabled is the state worth rendering differently — the difference between a
|
||||
* plugin that is off and one that is broken.
|
||||
*/
|
||||
processStatus: string | null;
|
||||
};
|
||||
|
||||
/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */
|
||||
export type PluginActionResult = { ok: boolean; appName: string; steps: string[]; error?: string };
|
||||
|
||||
export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable';
|
||||
|
||||
export type BrokenPlugin = { appName: string; error: string };
|
||||
|
||||
const PLUGINS_KEY = ['plugins'];
|
||||
|
||||
export function usePlugins() {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: PLUGINS_KEY,
|
||||
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
|
||||
});
|
||||
|
||||
// Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
|
||||
// and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
|
||||
const invalidate = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const [steps, setSteps] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<PluginActionResult | null>(null);
|
||||
const [running, setRunning] = useState<PluginVerb | null>(null);
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
|
||||
/**
|
||||
* Run a verb and stream its steps.
|
||||
*
|
||||
* Not `useMutation`, because react-query models one request with one answer and this is a request with
|
||||
* a running commentary. Hand-rolled for the same reason `useCompanionLogStream` is: `EventSource`
|
||||
* cannot send an `Authorization` header, and these routes are owner-only.
|
||||
*
|
||||
* The frame parser is deliberately small — split on blank lines, read `event:` and `data:`. It only has
|
||||
* to understand what our own endpoint emits.
|
||||
*/
|
||||
const run = useCallback(
|
||||
async (verb: PluginVerb, appName: string) => {
|
||||
abort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
|
||||
setSteps([]);
|
||||
setResult(null);
|
||||
setRunning(verb);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${appName}/${verb}/stream`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.body) throw new Error(`${verb} failed: no response body`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// The last element is whatever has arrived since the last blank line — an incomplete frame, so
|
||||
// it stays in the buffer rather than being parsed as a short one.
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
|
||||
for (const frame of frames) {
|
||||
let event = 'message';
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
const parsed = JSON.parse(data) as { step?: string } & PluginActionResult;
|
||||
if (event === 'step' && parsed.step) setSteps((prev) => [...prev, parsed.step!]);
|
||||
else if (event === 'done') setResult(parsed);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
setResult({ ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
} finally {
|
||||
setRunning(null);
|
||||
invalidate();
|
||||
}
|
||||
},
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
return {
|
||||
plugins: data?.plugins ?? [],
|
||||
broken: data?.broken ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
/** Steps as they arrive. Cleared when the next verb starts. */
|
||||
steps,
|
||||
/** The final answer, once the stream closes. Null while running. */
|
||||
result,
|
||||
/** Which verb is in flight, or null. */
|
||||
running,
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -243,6 +243,26 @@ export const TerminalView = ({
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
|
||||
// ── One socket per session, enforced here rather than at each caller ──
|
||||
//
|
||||
// There are two independent reconnect triggers — `handleClose` arms a timer, and
|
||||
// `handleVisibilityChange` fires when the tab comes back — and they overlap exactly: a pending timer
|
||||
// leaves `readyState === CLOSED`, which is the visibility handler's own condition to reconnect. Both
|
||||
// then ran, and the session ended up with TWO live sockets: every keystroke delivered twice, two
|
||||
// `replay` frames fighting over the screen, two `resize` frames racing. That is the "goes weird after
|
||||
// reconnecting" report, and it is also why only one socket was ever cleaned up —
|
||||
// `__terminalCleanup` is overwritten by whichever connect ran last.
|
||||
//
|
||||
// Guarding the entry point covers both callers and any future third one.
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
const existing = wsRef.current;
|
||||
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fitAddon.fit();
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
@@ -335,6 +355,10 @@ export const TerminalView = ({
|
||||
|
||||
const handleClose = () => {
|
||||
cleanupWs();
|
||||
// A socket that is no longer the session's must not arm a reconnect: its close arrives AFTER the
|
||||
// replacement is already open, and the timer it sets would then tear down a healthy connection to
|
||||
// build a third. Only the current socket speaks for the session.
|
||||
if (wsRef.current !== ws) return;
|
||||
if (disposed || processExited) {
|
||||
onConnectionChangeRef.current?.('disconnected');
|
||||
term.write('\r\n[Disconnected]\r\n');
|
||||
@@ -381,13 +405,13 @@ export const TerminalView = ({
|
||||
});
|
||||
|
||||
// Reconnect when tab becomes visible again
|
||||
// Coming back to the tab should not WAIT out a backoff that may be seconds long — but it must not open a
|
||||
// second socket either. `connect` now decides that (it returns early on a live one), so this only has to
|
||||
// say "try now", and the CLOSED/CLOSING distinction that used to be here stops mattering.
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !disposed && !processExited) {
|
||||
if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) {
|
||||
reconnectAttempts = 0;
|
||||
connect();
|
||||
}
|
||||
}
|
||||
if (document.visibilityState !== 'visible' || disposed || processExited) return;
|
||||
reconnectAttempts = 0;
|
||||
connect();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user