Compare commits
221
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73ffed806 | ||
|
|
ec1997fd0e | ||
|
|
6c84c74c91 | ||
|
|
661b3d761f | ||
|
|
2a8f0049a3 | ||
|
|
532ad15ac1 | ||
|
|
c59df4f866 | ||
|
|
ba64a412f2 | ||
|
|
53f36f07e4 | ||
|
|
0947d31330 | ||
|
|
c5522700ff | ||
|
|
85249a2756 | ||
|
|
9bab67aca5 | ||
|
|
bae3ebf7ba | ||
|
|
e7346c8790 | ||
|
|
584074c845 | ||
|
|
1575df3f78 | ||
|
|
2bb8128619 | ||
|
|
95951fbe5a | ||
|
|
519109a342 | ||
|
|
a3431abeac | ||
|
|
4d96083c20 | ||
|
|
6aeb304f56 | ||
|
|
92e014c19f | ||
|
|
311b2ea55c | ||
|
|
b646140dbf | ||
|
|
8c4f150c15 | ||
|
|
0bc7858302 | ||
|
|
d59adbf1f2 | ||
|
|
9833822625 | ||
|
|
4ef99bf3e5 | ||
|
|
893130940e | ||
|
|
e99949b1ac | ||
|
|
ef000aaf51 | ||
|
|
52b021bbe2 | ||
|
|
7cb402b25a | ||
|
|
4da82e7f91 | ||
|
|
73c359fd5f | ||
|
|
80e1a746c0 | ||
|
|
d051aff4e0 | ||
|
|
07ab3f9e7a | ||
|
|
3e0daee611 | ||
|
|
fe30164452 | ||
|
|
df4503180f | ||
|
|
fe7bd49bc7 | ||
|
|
fbabc22ed7 | ||
|
|
c15bd082b5 | ||
|
|
baa2d29fa4 | ||
|
|
45df9aaa20 | ||
|
|
288679af09 | ||
|
|
f4dc46d67a | ||
|
|
6b7aad91db | ||
|
|
06bfcf9556 | ||
|
|
2bd96a9a98 | ||
|
|
fb2c5c28cb | ||
|
|
6a85ca5d1f | ||
|
|
6cd462caf6 | ||
|
|
f09385c789 | ||
|
|
ed52faae02 | ||
|
|
701933d30d | ||
|
|
62e98dff2e | ||
|
|
48ed171b38 | ||
|
|
10fe3ffe65 | ||
|
|
401dcb710c | ||
|
|
f0af7237db | ||
|
|
aaeb3424ab | ||
|
|
3bea46f2d7 | ||
|
|
71589aee99 | ||
|
|
e4acf19a35 | ||
|
|
4d4a253f72 | ||
|
|
eda004a46d | ||
|
|
b4f88ec161 | ||
|
|
4b058a6703 | ||
|
|
d3bed0add9 | ||
|
|
6b4fed68fd | ||
|
|
e393d0f5c2 | ||
|
|
2c9d4e55aa | ||
|
|
ea0d2396f7 | ||
|
|
0281ca62d2 | ||
|
|
4d513c0e13 | ||
|
|
0fb9a29e64 | ||
|
|
5c7ceb2283 | ||
|
|
69a31051ac | ||
|
|
f67bb44b7e | ||
|
|
d86d3ed1c0 | ||
|
|
801eba9284 | ||
|
|
adaaba658c | ||
|
|
9d2da49572 | ||
|
|
d9857eef7c | ||
|
|
b89a562614 | ||
|
|
66a41d0813 | ||
|
|
bc13450fad | ||
|
|
31ffe084f5 | ||
|
|
52d567874f | ||
|
|
0a4ff548b9 | ||
|
|
7726c9fc71 | ||
|
|
cb7ab55cca | ||
|
|
bcb3d6d621 | ||
|
|
907ac46eec | ||
|
|
243bd04d97 | ||
|
|
4b3668f03c | ||
|
|
80d66538c2 | ||
|
|
2d1fc05518 | ||
|
|
8bab607366 | ||
|
|
a19895c216 | ||
|
|
ec4f06a313 | ||
|
|
b7184283e0 | ||
|
|
dd2be8c8b3 | ||
|
|
2ef4efd39b | ||
|
|
dc6b623ee1 | ||
|
|
e83585dd42 | ||
|
|
45e3e86d72 | ||
|
|
a3dbda7d3b | ||
|
|
82e9fdacc2 | ||
|
|
b79eca45fb | ||
|
|
209e916343 | ||
|
|
a058cbb3fd | ||
|
|
9084fabbf6 | ||
|
|
27c1331b3c | ||
|
|
a06422bd4c | ||
|
|
feb9010097 | ||
|
|
38ebe168f0 | ||
|
|
86fd03b35b | ||
|
|
35970146bb | ||
|
|
ecb9025f8f | ||
|
|
6e9a8ee42f | ||
|
|
f2e38ed9a7 | ||
|
|
b07cae142f | ||
|
|
ecc10ae6af | ||
|
|
90e0ca8ab4 | ||
|
|
f0aa6dbf4b | ||
|
|
73b8111216 | ||
|
|
ba71dc1957 | ||
|
|
a41abb4b0f | ||
|
|
4b9b98efda | ||
|
|
4a03b9f84e | ||
|
|
b197c2aeba | ||
|
|
936b96a36e | ||
|
|
ee38257856 | ||
|
|
890f57a7a6 | ||
|
|
7359867f7f | ||
|
|
654cb10711 | ||
|
|
fc48a572d2 | ||
|
|
977d14922f | ||
|
|
539aeca7ec | ||
|
|
0c90216c7a | ||
|
|
41663bc207 | ||
|
|
9118a9f76c | ||
|
|
1892c23aef | ||
|
|
cdee320fed | ||
|
|
73ccf15a89 | ||
|
|
d9a3513cb5 | ||
|
|
be259f813a | ||
|
|
72f4dcbdb3 | ||
|
|
42190f007f | ||
|
|
c252f24f1d | ||
|
|
3216040d2f | ||
|
|
96fd9e93a2 | ||
|
|
698784ae03 | ||
|
|
140288031a | ||
|
|
e8bd946272 | ||
|
|
8b409e8af8 | ||
|
|
d7b223127a | ||
|
|
cfbf58cdba | ||
|
|
0210e85762 | ||
|
|
7774a25ad9 | ||
|
|
8ac36ed8b8 | ||
|
|
013e6296f1 | ||
|
|
29d99127bd | ||
|
|
492509ae52 | ||
|
|
22bcd7d4b9 | ||
|
|
52bac070ce | ||
|
|
a6ffe3f5a2 | ||
|
|
2ebf6dccbb | ||
|
|
f9dedee997 | ||
|
|
da5cd8e918 | ||
|
|
77cd703d72 | ||
|
|
8d94d08d19 | ||
|
|
928eaebe2c | ||
|
|
5a7591124a | ||
|
|
27bbbaee47 | ||
|
|
8f2d80573a | ||
|
|
c4241c23da | ||
|
|
975673a9a6 | ||
|
|
5e141afa18 | ||
|
|
a75bf7a283 | ||
|
|
be266da9e2 | ||
|
|
5286e9f9ec | ||
|
|
46db80e962 | ||
|
|
87927a4bb0 | ||
|
|
856a63b840 | ||
|
|
d9d7abf8d7 | ||
|
|
908ab1ca6a | ||
|
|
eb6f73ff40 | ||
|
|
6a74b3d198 | ||
|
|
c79b5a287b | ||
|
|
417860b892 | ||
|
|
9974736587 | ||
|
|
b953a6ba8c | ||
|
|
63f7a14b6c | ||
|
|
ea59b5f1a7 | ||
|
|
8987898dd7 | ||
|
|
555da9a170 | ||
|
|
ae275ee607 | ||
|
|
ae54df7c30 | ||
|
|
00997f5ff1 | ||
|
|
b077671f19 | ||
|
|
33d96f19ee | ||
|
|
6089eb18fa | ||
|
|
2490392e57 | ||
|
|
d56be0301d | ||
|
|
ac64a7362b | ||
|
|
7ce9289921 | ||
|
|
8773da5953 | ||
|
|
302116d624 | ||
|
|
5624ed8e66 | ||
|
|
a70e4e7296 | ||
|
|
66b9cf634c | ||
|
|
7b7147001b | ||
|
|
bffae5ef61 | ||
|
|
d5a3bae367 |
@@ -48,3 +48,6 @@ src/apps/officer-web/index.gen.html
|
|||||||
|
|
||||||
# scratch scripts — never commit these
|
# scratch scripts — never commit these
|
||||||
*.tmp.ts
|
*.tmp.ts
|
||||||
|
|
||||||
|
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
|
||||||
|
public/plugins/
|
||||||
|
|||||||
@@ -5,16 +5,27 @@ Guidance for any coding agent working in the Officer platform repo.
|
|||||||
**The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.**
|
**The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.**
|
||||||
|
|
||||||
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
|
||||||
look for `CLAUDE.md`, without the two drifting apart. The previous contents of this file had drifted
|
look for `CLAUDE.md`, without the two drifting apart.
|
||||||
badly: they described Officer as a multi-user intranet for small businesses, with a user-invitation
|
|
||||||
API, a `bun dev` serving a separate dashboard on port 5000, and a closing instruction to be
|
|
||||||
"multi-user aware — always consider user isolation and role-based access". None of that is true.
|
|
||||||
|
|
||||||
## Orientation
|
## Orientation
|
||||||
|
|
||||||
Officer is a self-hosted personal platform that **serves exactly one person — the owner of the
|
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
|
||||||
server**. There is no tenancy, no roles, no user management. If a design question turns on "which
|
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
|
||||||
user", the answer is the owner.
|
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
|
||||||
|
per-role capability grants, resolved on every request.
|
||||||
|
|
||||||
|
If a design question turns on "which user", the answer depends on the surface: real for the **app**
|
||||||
|
capabilities (gitea, music, photos, email, calendar…), and still always **the owner** for anything
|
||||||
|
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
|
||||||
|
`kind: 'execution'` and can never be granted. `src/servers/capabilities/registry.ts` is the authority.
|
||||||
|
|
||||||
|
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Capabilities"
|
||||||
|
section of `CLAUDE.md` before adding one.
|
||||||
|
|
||||||
|
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
|
||||||
|
management". That was written to correct an *older* drift in the opposite direction — a fictional
|
||||||
|
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
|
||||||
|
paragraph above; treat the capability registry as the source of truth over either.
|
||||||
|
|
||||||
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as
|
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as
|
||||||
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
||||||
|
|||||||
@@ -2,14 +2,36 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Officer is a self-hosted personal platform for one person: the server owner. It bundles an AI agent,
|
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
|
||||||
a terminal, a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable
|
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
|
||||||
dashboards behind a single web app.
|
a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable dashboards
|
||||||
|
behind a single web app.
|
||||||
|
|
||||||
**Single-user is a hard invariant, not a stage.** There is exactly one account, created once by
|
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
|
||||||
`POST /auth/bootstrap` while the user table is empty. There are no roles, no invitations, no
|
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
|
||||||
sandboxing of one user from another, and no per-user isolation anywhere in the codebase. If a change
|
written: `users` holds six rows. The accurate statement is narrower and more useful —
|
||||||
seems to need "which user is this", the answer is always the owner.
|
|
||||||
|
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
||||||
|
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
||||||
|
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||||
|
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
|
||||||
|
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||||
|
- **Some things can never be shared, structurally.** Terminal, chat, tasks, files, desktop and browser
|
||||||
|
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's
|
||||||
|
home, so there is no level of "read" that makes them safe. They have no level at all and the grants
|
||||||
|
API refuses to store one.
|
||||||
|
|
||||||
|
So "which user is this" now has a real answer for the **app** surface (gitea, music, photos, email,
|
||||||
|
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
|
||||||
|
|
||||||
|
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
|
||||||
|
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
|
||||||
|
below before adding one.
|
||||||
|
|
||||||
|
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||||
|
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
||||||
|
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
||||||
|
deliberate boundary.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -104,24 +126,57 @@ history was deleted because it had drifted from the real schema. Treat the schem
|
|||||||
files, as the source of truth.
|
files, as the source of truth.
|
||||||
|
|
||||||
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
|
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** —
|
||||||
drizzle-kit mis-diffs named composite unique *constraints* and re-creates them on every push, which used
|
drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
|
||||||
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
|
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
|
||||||
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
||||||
"Composite keys" before adding either.
|
"Composite keys" before adding either.
|
||||||
|
|
||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
The perimeter is one credential, so the guards matter:
|
|
||||||
|
|
||||||
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
||||||
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
|
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
|
||||||
limiting and password rules all key off it — they fail closed.
|
limiting and password rules all key off it — they fail closed.
|
||||||
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
|
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
|
||||||
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
|
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
|
||||||
|
- **`ALLOW_ANY_ORIGIN` defaults to ON** — origin checking is off unless the var is explicitly `false`.
|
||||||
|
A deliberate inversion of the usual rule, safe only because the perimeter is the tailnet and a valid
|
||||||
|
token is still required on every protected route. It is defence in depth that is currently switched
|
||||||
|
off, not the lock.
|
||||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||||
|
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||||
|
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||||
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||||
authenticated request until the server restarts.
|
authenticated request until the server restarts.
|
||||||
|
|
||||||
|
### Capabilities — read this before mounting a router
|
||||||
|
|
||||||
|
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||||
|
valid"). It is `originScopeMiddleware` → `capabilities/authorize.ts`, mounted globally in `hono.ts`
|
||||||
|
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||||
|
`userMiddleware`.
|
||||||
|
|
||||||
|
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds:
|
||||||
|
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
|
||||||
|
(owner only, and `execution` is never grantable at any level).
|
||||||
|
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||||
|
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them.
|
||||||
|
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||||
|
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
|
||||||
|
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
||||||
|
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
||||||
|
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
||||||
|
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
||||||
|
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
|
||||||
|
survive the next door; refusing to boot does.
|
||||||
|
|
||||||
|
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
|
||||||
|
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
||||||
|
is how the hole happened the first time.
|
||||||
|
|
||||||
|
The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||||
|
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||||
|
into a refusal.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -164,12 +219,11 @@ so it rewrites every uncommitted file — including work in progress that isn't
|
|||||||
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
||||||
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
||||||
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- **Paradigm**: functional — pure functions, immutability, composition
|
- **Paradigm**: functional — pure functions, immutability, composition
|
||||||
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
||||||
- **Comments**: minimal, and about *why*. Don't narrate what the code already says.
|
- **Comments**: minimal, and about _why_. Don't narrate what the code already says.
|
||||||
- **Async**: always async/await
|
- **Async**: always async/await
|
||||||
- **Exports**: named only, no defaults
|
- **Exports**: named only, no defaults
|
||||||
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
||||||
@@ -226,6 +280,13 @@ type parameter and let inference flow from it.
|
|||||||
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
||||||
- **Report honestly** — say what you verified and what you didn't
|
- **Report honestly** — say what you verified and what you didn't
|
||||||
|
|
||||||
|
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
|
||||||
|
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
|
||||||
|
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
|
||||||
|
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
|
||||||
|
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
|
||||||
|
withholding the commit until it is good.
|
||||||
|
|
||||||
Commit messages: simple lowercase, no prefixes.
|
Commit messages: simple lowercase, no prefixes.
|
||||||
|
|
||||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||||
@@ -261,7 +322,7 @@ link-focusable). Half the app still does this; none of the new code should.
|
|||||||
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
||||||
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
||||||
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
||||||
*mutates* rather than navigates stays a `<button>`.
|
_mutates_ rather than navigates stays a `<button>`.
|
||||||
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
||||||
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
|
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
|
||||||
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
||||||
@@ -277,13 +338,42 @@ link-focusable). Half the app still does this; none of the new code should.
|
|||||||
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
||||||
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
||||||
|
|
||||||
|
## COMMS — a channel between agents, when one is open
|
||||||
|
|
||||||
|
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
|
||||||
|
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
|
||||||
|
sessions.
|
||||||
|
|
||||||
|
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
|
||||||
|
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
|
||||||
|
convention rather than an oversight: a spent channel left in place gets read as current.
|
||||||
|
|
||||||
|
If you open one:
|
||||||
|
|
||||||
|
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
|
||||||
|
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
|
||||||
|
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
|
||||||
|
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
|
||||||
|
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
|
||||||
|
agent read identically.
|
||||||
|
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
|
||||||
|
participant. "I think we're done" can close a thread with work still in it.
|
||||||
|
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
|
||||||
|
lands, delete the channel and move anything still open to `TODO.md`.
|
||||||
|
|
||||||
|
Two things that made it work, and neither is about either agent being more careful. One writes, the other
|
||||||
|
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
|
||||||
|
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
|
||||||
|
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
|
||||||
|
explaining why it was safe.
|
||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
||||||
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
||||||
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
- `docs/agent-coordination.md` — **the north star** for the workspace/panel work: agents on one
|
||||||
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
dashboard coordinating with each other instead of through the human, the handoff protocol, and what
|
||||||
is deliberately *not* being built. Read it before ranking, deferring or starting any panel item —
|
is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
|
||||||
it is what `docs/workspace-panel-todo.md` is ranked against.
|
it is what `docs/workspace-panel-todo.md` is ranked against.
|
||||||
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
|
||||||
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ Everything the `/system-monitor` web screen renders, for building the same in th
|
|||||||
|
|
||||||
- Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string
|
- Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string
|
||||||
(required for the SSE endpoints — `EventSource` can't set headers).
|
(required for the SSE endpoints — `EventSource` can't set headers).
|
||||||
- **Owner-only.** These routes are gated to the platform owner. Non-owner accounts (e.g. music-app
|
- **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and
|
||||||
users) are confined to `/api/auth` + `/api/music` and will get `403` here. The full **officer-mobile**
|
therefore never grantable — a non-owner account gets `403` here whatever its role. The full
|
||||||
client (which authenticates as the owner from an owner-allowed origin) has access; the music app does not.
|
**officer-mobile** client (which authenticates as the owner) has access; the music app does not.
|
||||||
|
- Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were
|
||||||
|
confined to a hardcoded `/api/auth` + `/api/music`. That list is gone, replaced by per-role
|
||||||
|
capability grants. The *outcome* for these routes is unchanged — still owner-only — but the reason is
|
||||||
|
now the capability's kind, not a two-element array.
|
||||||
- All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`.
|
- All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,26 +1,110 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Deferred work. Context: Officer is collapsing from multi-tenant / open-source-ready to a
|
Deferred work.
|
||||||
**single-user platform**. Treat multi-tenant indirection as accidental complexity, not a requirement.
|
|
||||||
|
|
||||||
## Single-user cleanup
|
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
|
||||||
|
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||||
|
indirection as accidental complexity. **That direction was reversed.** The capability permission model
|
||||||
|
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
|
||||||
|
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
|
||||||
|
below that proposed deleting it have been removed rather than left to mislead the next reader.
|
||||||
|
|
||||||
- [ ] **Remove dead `username` plumbing.** Mostly resolved by deleting the chat channels — the
|
What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as
|
||||||
handlers and `send-and-await.ts` that threaded `toShellUsername(...)` through to nothing are gone.
|
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||||
What remains: `send-claude-code.ts` still declares `username` without using it, and
|
|
||||||
`toShellUsername` has one real caller left (`provision.ts` → `generateClaudeSettings`), so it may
|
|
||||||
be inlinable.
|
|
||||||
|
|
||||||
- [x] **Delete or gut `scripts/provision-existing-users.sh`.** Done — deleted the script (it ran
|
## Multi-user
|
||||||
`sudo useradd …`, the source of the vestigial `andrepadez`/`john-wick`/`fedra`/`miguelbenoliel`
|
|
||||||
Unix accounts). Also dropped the dead per-user VNC desktop provisioning (`provisionVncEnv`, the
|
|
||||||
`startxfce4` xstartup) from `provision.ts` — the mirror self-provisions its passwd in
|
|
||||||
`vnc-manager.ts` — and the Pi `.pi/agent/sessions` seed.
|
|
||||||
|
|
||||||
- [ ] **Collapse the rest of the multi-tenant machinery.** Candidates, in rough order of payoff:
|
- [ ] **`deprovisionOsAccount` does not exist, and deleting a member leaves their whole Linux side.**
|
||||||
roles (`Super Admin`/`Member`), the sandboxed-vs-unsandboxed path split, per-email home dirs
|
`deleteUserHandler` removes the row and cascades the database; `userdel` never runs. Observed on the
|
||||||
under `dev-data/{email}/home`, per-user server state (dock, user apps, AppRegistry keyed by
|
production host on 2026-08-12: a member deleted through the UI kept a working login shell, a running
|
||||||
email), and auth (passkeys-per-origin, JWT signin, unmounted `PasskeyGate`).
|
Postgres container and 454M of data, and their uid was free for the next `useradd` to reissue. Spec in
|
||||||
|
`docs/deprovision-os-account.md`. The ordering that matters: reap processes explicitly (`terminate-user`
|
||||||
|
does **not** reap a stale shell, and `userdel` fails while one lives), then `chown -R` to the service
|
||||||
|
user, then `userdel` — sever before release, and abort if the `chown` fails.
|
||||||
|
|
||||||
|
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
|
||||||
|
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
|
||||||
|
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
|
||||||
|
`appendBuffer`, so a replay reproduces output and never re-issues requests.
|
||||||
|
|
||||||
|
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
|
||||||
|
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
|
||||||
|
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
|
||||||
|
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
|
||||||
|
missing property.
|
||||||
|
|
||||||
|
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
|
||||||
|
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
|
||||||
|
Settings → User management. Created accounts are `status: 'Active'` — the column defaults to
|
||||||
|
`'Unverified'` and `signin.ts` refuses anything else with a bare UNAUTHORIZED, which is the trap
|
||||||
|
the hand-INSERT route fell into. The owner sets the password and reads it out; `passwordChangedAt`
|
||||||
|
stays null. Directories come from the shared `provisionUserDirs`/`USER_DIRS` in `data-path.ts`,
|
||||||
|
which `scripts/provision-user-dirs.ts` now imports rather than restating.
|
||||||
|
|
||||||
|
- [ ] **Still no invite flow, and no password reset for a member.** The owner types the password and
|
||||||
|
tells the person, which means the owner knows it and the member cannot change it back if they
|
||||||
|
forget theirs — recovery today is delete-and-recreate. An invite (token, expiry, member sets
|
||||||
|
their own) needs a mail path. This is the next piece, not a nice-to-have.
|
||||||
|
|
||||||
|
- [x] **A second Super Admin was storable, and made the owner nondeterministic.** Fixed 2026-08-11.
|
||||||
|
`ck_users_owner_is_super_admin` pins user 1's role but a row-level CHECK cannot see other rows, and
|
||||||
|
`updateUserRoleHandler` happily promoted anyone — while `getOwnerUser()` was
|
||||||
|
`WHERE role='Super Admin' LIMIT 1` with no ORDER BY. Two holders would have made "who owns this
|
||||||
|
server" a question the query plan answered, and that answer feeds the agent sidecar's identity,
|
||||||
|
vault access and origin scoping. Both write paths now refuse the role, the list endpoint offers
|
||||||
|
`assignableRoles` without it, and `getOwnerUser()` orders by id.
|
||||||
|
|
||||||
|
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot
|
||||||
|
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on
|
||||||
|
the table is a composite PK `(user_id, id)` — it matches the `uq_dashboards_user_id` index
|
||||||
|
already there and keeps every stored `ws-layout-<id>` address valid, which uuid ids would not.
|
||||||
|
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
|
||||||
|
`docs/workspace-panel-todo.md` §3.
|
||||||
|
|
||||||
|
- [ ] **`capabilities/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
|
||||||
|
registry functions and the totality check; the resolver that does the owner bypass, the grant
|
||||||
|
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||||
|
standing between a Member and a shell.
|
||||||
|
|
||||||
|
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||||
|
broken panel or an endless spinner rather than a clean refusal.
|
||||||
|
|
||||||
|
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
|
||||||
|
every caller resolves to the owner's real login home. Safe only because all seven callers sit
|
||||||
|
behind `execution` capabilities. If per-user home confinement is ever attempted, this is the
|
||||||
|
function to start from.
|
||||||
|
|
||||||
|
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
|
||||||
|
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
|
||||||
|
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
|
||||||
|
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
|
||||||
|
owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the
|
||||||
|
thing to fix first if any of them is ever granted.
|
||||||
|
|
||||||
|
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
|
||||||
|
sidecar itself is fully per-user and confines every JSON read to `/dav/<userId>/`. The platform
|
||||||
|
side is ready for members; the CalDAV server underneath is not.
|
||||||
|
|
||||||
|
- [ ] **The music library is one global index.** `sidecar/music/indexer.ts` reads `HOME_DIR` and serves
|
||||||
|
every account from it. Favourites, playlists and now-playing _are_ per-user. Deliberate for now
|
||||||
|
(one household, one library) but worth stating rather than discovering.
|
||||||
|
|
||||||
|
- [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a
|
||||||
|
single global lane; ownership is enforced one layer up, in `pipeline-jobs-routes.ts`, by an
|
||||||
|
explicit `job.userId !== user.id → 404` on every by-id route. Correct today, but the queue itself
|
||||||
|
has no notion of whose work it is running.
|
||||||
|
|
||||||
|
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
|
||||||
|
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
|
||||||
|
`notify` capability could deregister another's device; and `POST /_officer/notify` let a request
|
||||||
|
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
|
||||||
|
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
|
||||||
|
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
|
||||||
|
|
||||||
|
- [ ] **Remove dead `username` plumbing.** `send-claude-code.ts` declares `username` in two types
|
||||||
|
without using it. (`toShellUsername` is NOT dead — `server.tsx:190` and
|
||||||
|
`pipeline-job-manager.ts:267` both call it. The `provision.ts` caller this item used to name no
|
||||||
|
longer exists.)
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Per-agent git identity
|
||||||
|
|
||||||
|
**Status: idea, not implemented. Nothing in this document has been built.** Written 2026-08-10 from a
|
||||||
|
read of the live spawn path; the file:line references were verified against `dc6b623`.
|
||||||
|
|
||||||
|
## The goal
|
||||||
|
|
||||||
|
A team of agents works on this project, sometimes several of them in the same repository at once. Each
|
||||||
|
one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody
|
||||||
|
having to remember to say so.
|
||||||
|
|
||||||
|
Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS
|
||||||
|
is concerned.
|
||||||
|
|
||||||
|
## How git identity can be overridden at all
|
||||||
|
|
||||||
|
Identity is unverified metadata, not authentication — a default, never a constraint. Anyone who can
|
||||||
|
commit can claim any name and email, by any of:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -c user.name=X -c user.email=x@y.z commit # per-invocation config
|
||||||
|
git commit --author="X <x@y.z>" # author only; committer stays whoever ran it
|
||||||
|
GIT_AUTHOR_NAME=X GIT_AUTHOR_EMAIL=x@y.z \
|
||||||
|
GIT_COMMITTER_NAME=X GIT_COMMITTER_EMAIL=x@y.z git commit # env; both identities
|
||||||
|
```
|
||||||
|
|
||||||
|
Precedence: `--author` > `GIT_AUTHOR_*` env > `-c user.email` > local config > global config.
|
||||||
|
|
||||||
|
Two consequences that shape the design below:
|
||||||
|
|
||||||
|
- **Author and committer are different fields.** `--author` alone leaves the committer as the owner, and
|
||||||
|
`git log` shows only the author by default. Set both, or the attribution is half-fiction. To read
|
||||||
|
both: `git log --format='%an <%ae> | %cn <%ce>'`.
|
||||||
|
- **Environment beats instruction.** Telling an agent "commit as X" in its prompt is a rule it can
|
||||||
|
forget. `GIT_AUTHOR_*` in the process environment applies to every git invocation in that process
|
||||||
|
whether or not anyone remembered. The whole point is to make the identity unforgettable rather than
|
||||||
|
well-intentioned, so this belongs in the environment.
|
||||||
|
|
||||||
|
## Why the obvious approach does not work here
|
||||||
|
|
||||||
|
The first instinct is a PM2 `env` block per agent, or setting `process.env.GIT_AUTHOR_NAME` in the
|
||||||
|
sidecar at boot. **Both are wrong on this platform**, and for the same reason:
|
||||||
|
|
||||||
|
`officer-agent` is **one process running many concurrent sessions.** `sessions` is a
|
||||||
|
`Map<string, PersistentSession>` (`src/servers/sidecar/claude/claude-manager.ts:213`); each session owns
|
||||||
|
its own long-lived `query()` and its own warm `claude` child, reused across turns and collected after 30
|
||||||
|
minutes idle. A singleton lockfile enforces one sidecar per owner (`user-instance.ts:95-98`).
|
||||||
|
|
||||||
|
So a process-level variable — whether from PM2, from `.env`, or assigned in `user-instance.ts` — is
|
||||||
|
shared by every agent and every session on the box. It can say "an agent did this". It cannot say which.
|
||||||
|
|
||||||
|
The `ecosystem.config.cjs` entry is bare anyway, which is worth recording since it looks like a place
|
||||||
|
where env might already be happening:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{ name: 'officer-agent', script: 'bun', args: 'run src/servers/sidecar/claude/user-instance.ts', watch: false },
|
||||||
|
```
|
||||||
|
|
||||||
|
No `env`, no `cwd`, no `interpreter` — for any app in the file.
|
||||||
|
|
||||||
|
## What the spawn path actually looks like
|
||||||
|
|
||||||
|
The live path is the Agent SDK, not `Bun.spawn`. `claude-manager.ts:320-360`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const q = query({
|
||||||
|
prompt: input.gen as AsyncIterable<SdkUserMessage>,
|
||||||
|
options: {
|
||||||
|
cwd: params.cwd ?? HOST_HOME,
|
||||||
|
permissionMode: 'bypassPermissions',
|
||||||
|
pathToClaudeCodeExecutable: CLAUDE_BIN,
|
||||||
|
env: cleanEnv as Record<string, string>,
|
||||||
|
...
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`cleanEnv` is built once, at `claude-manager.ts:315`, by destructuring three keys back out of the
|
||||||
|
sidecar's own environment:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session
|
||||||
|
guards, and nothing is added per turn.
|
||||||
|
|
||||||
|
**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there
|
||||||
|
is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point
|
||||||
|
for everything below.
|
||||||
|
|
||||||
|
### What identity exists today
|
||||||
|
|
||||||
|
Almost none, and none of it at the OS level.
|
||||||
|
|
||||||
|
- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport
|
||||||
|
field on the pushed message.
|
||||||
|
- **Agent name and persona are prompt-only.** `buildAgentPrompt`
|
||||||
|
(`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user
|
||||||
|
message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file.
|
||||||
|
- The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)`
|
||||||
|
(`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their
|
||||||
|
transcripts.
|
||||||
|
|
||||||
|
`grep -rn "GIT_AUTHOR\|GIT_COMMITTER" src/` returns nothing. Verified. Any commit an agent makes today
|
||||||
|
is attributed to whatever `~/.gitconfig` says — the human owner, identically for every agent.
|
||||||
|
|
||||||
|
## The proposed change
|
||||||
|
|
||||||
|
Three edits, all on the claude path:
|
||||||
|
|
||||||
|
1. **`src/servers/sidecar/protocol.ts`** — add an optional `gitIdentity: { name: string; email: string }`
|
||||||
|
to `ClaudeSpawnStreamingParams` (the type begins at line 170). Optional so every existing caller is
|
||||||
|
untouched.
|
||||||
|
2. **`claude-manager.ts:315`** — build `cleanEnv` per session rather than once, merging the identity in
|
||||||
|
as all four variables when present:
|
||||||
|
```ts
|
||||||
|
...(params.gitIdentity && {
|
||||||
|
GIT_AUTHOR_NAME: params.gitIdentity.name,
|
||||||
|
GIT_AUTHOR_EMAIL: params.gitIdentity.email,
|
||||||
|
GIT_COMMITTER_NAME: params.gitIdentity.name,
|
||||||
|
GIT_COMMITTER_EMAIL: params.gitIdentity.email,
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
3. **`agent-runner.ts:144`** — populate it from `agent.dirName`, next to where the cwd pin is already
|
||||||
|
derived from the same field.
|
||||||
|
|
||||||
|
Chat sessions (`api/chat/websocket.ts`) would pass nothing and keep committing as the owner, which is
|
||||||
|
almost certainly right: a chat turn is the human driving directly.
|
||||||
|
|
||||||
|
Use a domain that is actually controlled — `<dirName>@officer.dev` — so Gitea can be made to map or
|
||||||
|
deliberately not-map these authors later.
|
||||||
|
|
||||||
|
## Known limitations of the proposal
|
||||||
|
|
||||||
|
### opencode cannot do this at all
|
||||||
|
|
||||||
|
Since the `serve` migration (`a3dbda7`, phase D) there is **no process spawned per turn**. One shared
|
||||||
|
`opencode serve` starts at sidecar boot (`src/servers/sidecar/opencode/index.ts:112-116`) with no `env`
|
||||||
|
key — full inheritance — and turns are driven over HTTP against it, with per-turn cwd carried as a
|
||||||
|
request header (`serve-runner.ts:155`). Every session shares that one process environment.
|
||||||
|
|
||||||
|
Per-agent git identity on the opencode path therefore requires either a serve per agent, or an upstream
|
||||||
|
API field. Neither is a small change, and this document does not propose one.
|
||||||
|
|
||||||
|
### Attribution is not isolation, and isolation is the real problem
|
||||||
|
|
||||||
|
There is **no filesystem isolation** between agents. They share one real `HOME`
|
||||||
|
(`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this
|
||||||
|
outright, and it is the stated reason `chat` is an `execution` capability that can never be granted.
|
||||||
|
`grep -ril worktree src/` returns nothing — worktrees are used nowhere.
|
||||||
|
|
||||||
|
cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.
|
||||||
|
|
||||||
|
So two agents told to work on the same repository will share one working tree: fighting over
|
||||||
|
`index.lock`, staging each other's half-finished edits, interleaving commits. Per-agent identity makes
|
||||||
|
that **legible after the fact**. It does nothing to prevent it.
|
||||||
|
|
||||||
|
If agents are genuinely to work the same repo concurrently, the isolation question is the larger and
|
||||||
|
more urgent one — a worktree or a clone per agent — and per-agent identity composes naturally with it
|
||||||
|
(a worktree per agent is also the cleanest place to put a per-worktree git identity).
|
||||||
|
|
||||||
|
### If worktrees do arrive, note this trap
|
||||||
|
|
||||||
|
`git worktree` shares one `.git/config`, so `git config --local user.email` in one worktree changes it
|
||||||
|
for **all** of them. Per-worktree config needs `extensions.worktreeConfig true` and then
|
||||||
|
`git config --worktree user.email …`. Environment variables sidestep the whole issue, which is another
|
||||||
|
argument for the env approach above.
|
||||||
|
|
||||||
|
## Deliberately not proposed
|
||||||
|
|
||||||
|
- **Signing.** Per-agent SSH signing keys would make attribution unforgeable rather than conventional.
|
||||||
|
For a trusted local fleet where every agent already runs as the owner with
|
||||||
|
`permissionMode: 'bypassPermissions'`, an agent that wanted to forge another's identity has far easier
|
||||||
|
routes. Revisit only if agents stop being equally trusted.
|
||||||
|
- **`Co-Authored-By:` trailers.** Useful when a human and an agent genuinely share a commit, and both
|
||||||
|
Gitea and GitHub attribute them. Orthogonal to this, and a prompt-level convention rather than an
|
||||||
|
environment one.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Should pipeline steps (`pipeline-executor.ts`) get an identity too, or only named agents?
|
||||||
|
- Is `dirName` the right identity, or should an agent's `AGENT.md` declare its own name and email — so
|
||||||
|
the identity is authored where the persona is, rather than derived from a directory?
|
||||||
|
- Does anything downstream — Gitea webhooks, activity feeds, the dashboards — assume commits belong to
|
||||||
|
the owner and break when they do not?
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Chat session lifetime — findings and plan
|
||||||
|
|
||||||
|
Written 2026-08-09, before implementation. The investigation is done; the code is not.
|
||||||
|
|
||||||
|
**The trigger.** A laptop ran out of battery overnight while an agent on the home lab was running a
|
||||||
|
15-minute background loop (review new commits, restart the server). The session should have survived
|
||||||
|
until morning. Officer would have killed it about an hour after the browser socket dropped.
|
||||||
|
|
||||||
|
## What is actually there — verified, not remembered
|
||||||
|
|
||||||
|
**Two independent idle policies exist, in two processes, answering different questions.**
|
||||||
|
|
||||||
|
### The sidecar's — the real one
|
||||||
|
|
||||||
|
`src/servers/sidecar/claude/claude-manager.ts`
|
||||||
|
|
||||||
|
- `IDLE_TIMEOUT_MS = 30 * 60 * 1000` (`:39`) — thirty minutes with **no new turn**.
|
||||||
|
- `armIdle` (`:284-295`) is a **heartbeat, not a one-shot**: on expiry it re-checks and re-arms if
|
||||||
|
`isGenerating || pendingTasks.size > 0`, so it can never collect a session that is still working.
|
||||||
|
- `pendingTasks: Set<string>` (`:174`) holds background tasks started but not yet notified.
|
||||||
|
`task:started` adds the id and **clears the idle timer outright** (`:406-411`); `task:notification`
|
||||||
|
removes it and re-arms only if nothing else is outstanding (`:413-414`).
|
||||||
|
- Its own comment states the intent: _"never GC a session that's mid-turn or still has background tasks
|
||||||
|
running — a long silent `run_in_background` job would otherwise be killed along with its pending
|
||||||
|
`task_notification`."_
|
||||||
|
|
||||||
|
So **the sidecar already detects long-running background work with no declaration from the user.** The
|
||||||
|
"mark a session permanent" feature is not needed for this case.
|
||||||
|
|
||||||
|
There is also `stallTimer` for a turn that claims to be generating but has emitted nothing, and
|
||||||
|
`SEND_TIMEOUT_MS = 30 * 60 * 1000` (`:10`).
|
||||||
|
|
||||||
|
### Officer's — the blunt one
|
||||||
|
|
||||||
|
`src/servers/api/chat/websocket.ts`, `src/servers/api/chat/session-manager.ts`
|
||||||
|
|
||||||
|
- `IDLE_TIMEOUT_MS = 60 * 60 * 1000` (`websocket.ts:48`).
|
||||||
|
- Armed by the socket **closing**, not by it being unresponsive: `close(ws)` → `detachWs` +
|
||||||
|
`setIdleTimeout` (`websocket.ts:151-163`). Unresponsiveness matters only upstream — Bun closes a WS
|
||||||
|
idle for 60s and there is a per-connection heartbeat, so a dead laptop loses the socket about a minute
|
||||||
|
in, and _that_ starts the hour.
|
||||||
|
- Expiry runs `deleteSession` (`session-manager.ts:74-102`), which calls `_sidecarUnsub()` then
|
||||||
|
`_claudeKill()` → `sidecar.killClaude(sessionId)`.
|
||||||
|
- `attachWs` clears the timer (`session-manager.ts:104-116`), so reconnecting cancels it cleanly.
|
||||||
|
|
||||||
|
**Three defects follow:**
|
||||||
|
|
||||||
|
1. It kills sessions the sidecar has deliberately protected. Officer has no view of `pendingTasks`.
|
||||||
|
2. It does not survive `pm2 restart officer` — a `setTimeout` on an in-memory record, in the process
|
||||||
|
designed to bounce. If the browser never returns, nothing re-arms it.
|
||||||
|
3. On a flat battery the sidecar's 30 minutes normally fires first anyway, so officer's hour is mostly
|
||||||
|
redundant _except_ in the one case where it does damage — a session with background work, which the
|
||||||
|
sidecar keeps and officer kills.
|
||||||
|
|
||||||
|
### What is already fine
|
||||||
|
|
||||||
|
`pm2 restart officer` **does not kill running sessions.** Sidecars are PM2 peers, not children; the
|
||||||
|
agent keeps generating and keeps committing to `chat_session_events`. Only officer's in-memory binding
|
||||||
|
dies, and `adoptOrphanedSession` (`websocket.ts:559`) rebuilds it — including the session-scoped
|
||||||
|
subscription, without which a reconnected client replays and then goes silent for the rest of the turn.
|
||||||
|
|
||||||
|
## Step 1 — officer's timer releases instead of kills — **DONE**
|
||||||
|
|
||||||
|
Officer's idle timer is doing two unrelated jobs: garbage-collecting its own binding (its business) and
|
||||||
|
terminating the agent (the sidecar's). Split them and leave the agent's lifetime to the process that
|
||||||
|
already reasons about it correctly.
|
||||||
|
|
||||||
|
**Rejected alternative:** teaching officer about `pendingTasks` over the protocol. That re-implements
|
||||||
|
the sidecar's heartbeat in a second place, which is how these two drifted apart to begin with.
|
||||||
|
|
||||||
|
**The blocker.** `src/servers/channels/send-claude-code.ts:65-69` welds them together:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return {
|
||||||
|
kill: () => {
|
||||||
|
sidecar.killClaude(params.sessionKey);
|
||||||
|
unsub();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`unsub` is a closure reachable only _through_ `kill`, so officer cannot let go without killing.
|
||||||
|
|
||||||
|
**The seam already exists.** `_sidecarUnsub` is declared (`types.ts:310`) and called separately in
|
||||||
|
`deleteSession` (`session-manager.ts:83-84`) — and **nothing ever assigns it**. Populating it is the fix.
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
1. `send-claude-code.ts` — return `{ kill, detach }`; `kill` stays as-is, `detach` is `unsub` alone.
|
||||||
|
2. Same for the OpenCode path (`websocket.ts:452` assigns `handle.kill` there too).
|
||||||
|
3. `websocket.ts:361` / `:452` — also assign `session._sidecarUnsub = handle.detach`.
|
||||||
|
4. `session-manager.ts` — add `releaseSession(sessionId)`: clear the idle timer, call `_sidecarUnsub`,
|
||||||
|
drop from `sessions`/`userSessions`. **Do not** call `_claudeKill`.
|
||||||
|
5. `setIdleTimeout`'s callback → `releaseSession`, not `deleteSession`.
|
||||||
|
6. Leave `deleteSession` alone — explicit disconnect (`handleDisconnect`) must still kill.
|
||||||
|
|
||||||
|
Check while doing it: `adoptOrphanedSession` notes that `handleChat` treats an absent `_claudeKill` as
|
||||||
|
"first turn of this session" and would open a **second** subscription, delivering every message twice.
|
||||||
|
Make sure a released-then-readopted session cannot land in that state.
|
||||||
|
|
||||||
|
Consequence to accept: after an hour a returning browser goes through the adopt path rather than finding
|
||||||
|
a live record. That path already runs on every officer restart.
|
||||||
|
|
||||||
|
**As built.** `detach` added beside `kill` on both streaming handles; `_sidecarUnsub` populated at all
|
||||||
|
three sites (Claude first turn, OpenCode, and `adoptOrphanedSession` — that last one was not in the
|
||||||
|
original list and would have leaked a listener per adopt-then-idle); `forget` factored out of
|
||||||
|
`deleteSession`; `releaseSession` added; the idle timer points at it. `deleteSession` still kills, so an
|
||||||
|
explicit disconnect is unchanged.
|
||||||
|
|
||||||
|
The double-subscription trap does not arise: `releaseSession` unsubscribes and drops the record, so a
|
||||||
|
returning browser either adopts (fresh single subscription) or starts a first turn with no stale
|
||||||
|
listener behind it. `unsub` is a `Set.delete`, so `deleteSession` calling it twice is harmless.
|
||||||
|
|
||||||
|
**Verified end to end, 2026-08-10 01:03.** `IDLE_TIMEOUT_MS` was temporarily dropped to 30s (reverted
|
||||||
|
immediately after), a ticking `run_in_background` job started, and the tab closed. Officer logged
|
||||||
|
`Session idle timeout reached, releasing binding (agent left running)` thirty seconds later, and the job
|
||||||
|
ticked straight through it and kept going. That is the exact point at which `deleteSession` used to call
|
||||||
|
`_claudeKill`. Reopening the tab adopted the session and resumed its output.
|
||||||
|
|
||||||
|
Note the shape of the test: restarting officer does **not** exercise this — the process dies outright and
|
||||||
|
`releaseSession` never runs, so that only tests `adoptOrphanedSession`, which already worked. The socket
|
||||||
|
has to close while officer stays up.
|
||||||
|
|
||||||
|
## Step 2 — enumerate live sessions
|
||||||
|
|
||||||
|
After an officer restart a live session is invisible until a browser reconnects to it _by id_; adoption
|
||||||
|
is on-demand only. **There is no list verb in the sidecar protocol** (checked).
|
||||||
|
|
||||||
|
Add `claude:list` returning each live `sessionKey` with `isGenerating` and `pendingTasks.size`, so
|
||||||
|
officer can answer "what is running right now" and surface it.
|
||||||
|
|
||||||
|
**Do not try to persist a running session.** A live session is a running `query()` with an open stream;
|
||||||
|
it cannot be serialised. The durable part — the output — is already in `chat_session_events`, which is
|
||||||
|
what makes replay work.
|
||||||
|
|
||||||
|
**Hard limit:** if the _agent sidecar_ restarts, a mid-turn is lost regardless. Officer restarts are
|
||||||
|
survivable; `officer-agent` restarts are not.
|
||||||
|
|
||||||
|
## Deliberately not decided
|
||||||
|
|
||||||
|
Keeping an **idle** session alive — nothing generating, no background work — is a separate decision and
|
||||||
|
the only part that would need the user to mark anything. Steps 1 and 2 cover sessions with work in
|
||||||
|
flight, which is the case that actually bit. A 15-minute loop re-arms the sidecar's 30-minute timer on
|
||||||
|
every turn, so it never idles out on its own.
|
||||||
@@ -608,4 +608,4 @@ a separate job.
|
|||||||
`border-box`, percentage heights resolve against the content box, so there is no overflow. I did not
|
`border-box`, percentage heights resolve against the content box, so there is no overflow. I did not
|
||||||
change it.
|
change it.
|
||||||
- **Mobile edit-in-invisible-panel** (dashboards) is unrelated to this work and still open; it is
|
- **Mobile edit-in-invisible-panel** (dashboards) is unrelated to this work and still open; it is
|
||||||
recorded in `nav-test-checklist.md` in the workspace root.
|
recorded under "Known and deliberately unfixed" in the workspace-root `CLAUDE.md`.
|
||||||
|
|||||||
@@ -284,12 +284,17 @@ is a real design decision and I don't have a confident recommendation.
|
|||||||
|
|
||||||
## Open questions — the ones I'd rather you answered
|
## Open questions — the ones I'd rather you answered
|
||||||
|
|
||||||
1. **Is the per-email spawn model dead weight?** `CLAUDE.md` states single-user is a hard invariant
|
1. ~~**Is the per-email spawn model dead weight?**~~ — **answered 2026-08-07: yes, it is.** The
|
||||||
("If a change seems to need 'which user is this', the answer is always the owner"), yet the agent
|
question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
|
||||||
sidecar is keyed per email — `claude:${email}`, a `claudeProcs` Map, a `claudeSpawnWaiters` Map, a
|
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled
|
||||||
per-email PID lock. If there is only ever one owner, Stage 1a becomes trivial: one PM2 entry, no
|
it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is
|
||||||
fan-out, no registration polling. If you intend multi-tenant later, the fan-out has to stay and
|
`kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**,
|
||||||
Stage 1 gets harder. **This single answer changes the shape of the whole plan.**
|
because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional
|
||||||
|
accounts exist now, and not one of them can ever open a chat.
|
||||||
|
|
||||||
|
So the fan-out is keyed on a dimension that is structurally guaranteed to have one value. Stage 1a
|
||||||
|
is the trivial version: one PM2 entry, no fan-out, no registration polling. This only reopens if
|
||||||
|
per-user home confinement is ever built, which is a project rather than a checkbox.
|
||||||
|
|
||||||
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
|
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
|
||||||
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
|
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Deprovisioning a member's Linux account
|
||||||
|
|
||||||
|
**Status:** specification. Not implemented. Written from a manual teardown performed on the production host on
|
||||||
|
2026-08-11, so the ordering constraints below are measured rather than reasoned.
|
||||||
|
|
||||||
|
**Trigger for implementing:** before the first account that does not belong to the server owner. Not "after
|
||||||
|
per-user Claude" — the risk opens when a real person has an account that might later be deleted, which may or
|
||||||
|
may not be the same moment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What happens today
|
||||||
|
|
||||||
|
`deleteUserHandler` removes the `users` row and cascades the database. `userdel` never runs. Measured on a
|
||||||
|
real member (`green`, uid 1002) immediately after deleting them through the UI, before any cleanup:
|
||||||
|
|
||||||
|
| | after `deleteUserHandler` |
|
||||||
|
|---|---|
|
||||||
|
| `users` row | gone |
|
||||||
|
| Linux account | alive, uid 1002 |
|
||||||
|
| Login shell | `id -u` → 1002 — the deleted account still had a working login |
|
||||||
|
| Rootless Docker | daemon running, `postgres` container `Up 2 hours (healthy)` |
|
||||||
|
| Home + Docker storage | 454 MB intact |
|
||||||
|
| linger, `/run/user/1002`, `/etc/subuid`, `/etc/subgid` | all present |
|
||||||
|
|
||||||
|
Nothing breaks, which is what makes it dangerous. The account keeps working; only the platform forgets it
|
||||||
|
exists.
|
||||||
|
|
||||||
|
## Why it matters: uid reuse
|
||||||
|
|
||||||
|
`useradd` allocates the lowest free uid. Delete a member and the uid is free while their files still carry it,
|
||||||
|
so the next member created inherits the previous member's home, keys, Docker storage and anything else owned
|
||||||
|
by that number. By uid, not by any decision anyone made.
|
||||||
|
|
||||||
|
This is not hypothetical. `officer_jg` (uid 1001) and `green` (uid 1002) both had login shells pointing at one
|
||||||
|
home on this host, and the `users` table had no row for `officer_jg` at all — an earlier account for the same
|
||||||
|
email, deleted from the platform, whose Linux side survived. The adoption rule in `ensureOsUser` was never
|
||||||
|
bypassed; the account simply outlived the row.
|
||||||
|
|
||||||
|
**The invariant this function exists to guarantee:**
|
||||||
|
|
||||||
|
> After deprovisioning, no file anywhere is owned by the freed uid **or by any id in its freed subuid range**,
|
||||||
|
> and no passwd entry, linger marker, runtime directory or process refers to it.
|
||||||
|
|
||||||
|
## The subuid half, which is easy to miss
|
||||||
|
|
||||||
|
A member's rootless Docker storage is **not** owned by their uid. Container processes map through
|
||||||
|
`/etc/subuid`, so the files are owned by ids in that range — on this host, `green` had `231072:65536`, and
|
||||||
|
postgres's data directory was owned by `231141` (231072 + 70, postgres's inner uid in the Alpine image).
|
||||||
|
|
||||||
|
`userdel` releases the subuid range along with the uid, and a later account can be allocated the same range.
|
||||||
|
So a check for "nothing is owned by the freed uid" **passes while hundreds of megabytes are still owned by the
|
||||||
|
freed subuid range**, and a future member's containers would map onto another member's leftover files.
|
||||||
|
|
||||||
|
Any verification has to cover the range, not just the uid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The sequence
|
||||||
|
|
||||||
|
Ordering is load-bearing. Each step explains what breaks if it moves.
|
||||||
|
|
||||||
|
### 1. Disable linger, before stopping anything
|
||||||
|
|
||||||
|
```
|
||||||
|
loginctl disable-linger <user>
|
||||||
|
```
|
||||||
|
|
||||||
|
Lingering keeps a systemd user manager alive with no login session. Terminate first and linger can bring it
|
||||||
|
back; disable first and nothing can re-spawn between the two steps.
|
||||||
|
|
||||||
|
*(The manual teardown ran these in the opposite order and worked. This order is specified because it removes a
|
||||||
|
race rather than because the other one failed.)*
|
||||||
|
|
||||||
|
### 2. Terminate the session, then **verify it actually died**
|
||||||
|
|
||||||
|
```
|
||||||
|
loginctl terminate-user <user>
|
||||||
|
```
|
||||||
|
|
||||||
|
**`terminate-user` is not a barrier.** Measured: a `/bin/zsh -i` owned by the member survived it — three hours
|
||||||
|
old, still running after the session was terminated and `/run/user/<uid>` was removed. `userdel` refuses while
|
||||||
|
a process owned by the account is alive, so an implementation that trusts `terminate-user` works on a quiet
|
||||||
|
account and fails on a member who left a shell open, which is the normal case.
|
||||||
|
|
||||||
|
Required after terminating:
|
||||||
|
|
||||||
|
```
|
||||||
|
pkill -u <user> # wait, then re-check
|
||||||
|
pkill -9 -u <user> # only if the count is still non-zero
|
||||||
|
```
|
||||||
|
|
||||||
|
with a bounded wait between and a final assertion that the process count is zero. **Do not proceed while it is
|
||||||
|
not.**
|
||||||
|
|
||||||
|
### 3. Sever the data from the uid — *before* releasing it
|
||||||
|
|
||||||
|
Two policies. The platform's default is **preserve**:
|
||||||
|
|
||||||
|
```
|
||||||
|
chown -R <service-user>:<service-group> <member-tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
Destroying a member's data because their account was deleted is a separate decision from removing their
|
||||||
|
access, and the platform has no standing to make it silently. Reassigning ownership severs the uid link while
|
||||||
|
keeping every byte.
|
||||||
|
|
||||||
|
**Destroy** is opt-in, for a deliberate rebuild:
|
||||||
|
|
||||||
|
```
|
||||||
|
rm -rf <member-tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
**This step must complete before step 4.** That is the one ordering choice the manual teardown got wrong: it
|
||||||
|
released the uid first and removed the data afterwards, which leaves a window where the uid is free while
|
||||||
|
files still carry it. If the process dies in that window, the next `useradd` inherits them. Sever first, then
|
||||||
|
release — the irreversible step goes last, and only once nothing points at it.
|
||||||
|
|
||||||
|
### 4. Release the account
|
||||||
|
|
||||||
|
```
|
||||||
|
userdel <user> # NEVER -r
|
||||||
|
```
|
||||||
|
|
||||||
|
`-r` deletes the home, which contradicts the preserve policy and would make the destroy policy depend on a
|
||||||
|
flag rather than on an explicit decision. Measured: plain `userdel` removes the passwd, shadow and group
|
||||||
|
entries **and** the `/etc/subuid` and `/etc/subgid` ranges.
|
||||||
|
|
||||||
|
### 5. Verify, and refuse to call it done otherwise
|
||||||
|
|
||||||
|
See the checklist below. A deprovision that half-succeeded is worse than one that failed cleanly, because the
|
||||||
|
uid is free and something still owns files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification: what "clean" means
|
||||||
|
|
||||||
|
All of these must hold for the freed uid *and* its freed subuid range:
|
||||||
|
|
||||||
|
- `getent passwd <user>` → nothing
|
||||||
|
- no entry in `/etc/subuid` or `/etc/subgid`
|
||||||
|
- `find <DATA_PATH> /home -uid <uid>` → nothing
|
||||||
|
- `find <DATA_PATH> /home -uid <subuid-start> -o ... ` over the freed range → nothing
|
||||||
|
*(a range scan, not a single id — the mapped ids are spread across it)*
|
||||||
|
- `/var/lib/systemd/linger/<user>` absent
|
||||||
|
- `/run/user/<uid>` absent
|
||||||
|
- no processes owned by the uid
|
||||||
|
|
||||||
|
Worth extracting as `assertUidFree(uid, subuidRange)` and reusing it as the post-condition of the function and
|
||||||
|
as a test.
|
||||||
|
|
||||||
|
**Trap for the verifier:** do not use `sudo -u <user> …` to check anything after step 2. Creating a session
|
||||||
|
starts a user manager and recreates `/run/user/<uid>`, so the check would undo the step it is verifying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behaviour requirements
|
||||||
|
|
||||||
|
**Idempotent.** Every step tolerates already-done. Re-running on a clean box is a no-op, and re-running after a
|
||||||
|
partial failure completes it. The delete handler should be able to call it, fail, and have an operator press
|
||||||
|
retry.
|
||||||
|
|
||||||
|
**Never throws; returns a result.** Same posture as `provisionOsAccount`, `provisionSshAccess` and
|
||||||
|
`provisionRootlessDocker`.
|
||||||
|
|
||||||
|
**But a failed deprovision is not the same as a failed provision.** An account that fails to provision is
|
||||||
|
merely unusable. An account that fails to *de*provision may have a freed uid with files still owned by it,
|
||||||
|
which is the hazard itself. So:
|
||||||
|
|
||||||
|
- if step 3 (sever) fails, **do not proceed to step 4**. Leaving the account intact is strictly safer than
|
||||||
|
freeing a uid that still owns data.
|
||||||
|
- a partial failure must be surfaced loudly, not warned into a log the way a missing Docker install is.
|
||||||
|
- the `users` row should not be considered fully deleted while the OS side is in a partial state, or the
|
||||||
|
platform forgets about a mess it created.
|
||||||
|
|
||||||
|
**Must not:** run `userdel -r`; delete data under the preserve policy; touch any account other than the one
|
||||||
|
named; run anything as the member after step 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Call sites
|
||||||
|
|
||||||
|
- `deleteUserHandler` — the reason this exists.
|
||||||
|
- An admin-triggered retry, for an account left in a partial state.
|
||||||
|
- Worth considering: a startup reconciliation that reports Linux accounts with `os_user` set and no
|
||||||
|
corresponding `users` row. That is exactly how `officer_jg` would have been noticed months earlier, and it
|
||||||
|
is a report rather than an action — nothing should be deleted automatically at boot.
|
||||||
|
|
||||||
|
## Open questions for whoever implements it
|
||||||
|
|
||||||
|
1. **Where does severed data go?** Reassigned in place under the member's old path, or moved somewhere that
|
||||||
|
reads as archival? In place is simpler; a `deleted/` location makes it obvious the data is orphaned.
|
||||||
|
2. **Is destroy ever exposed in the UI**, or is it always a deliberate operator action outside the platform?
|
||||||
|
3. **Should uid allocation avoid reuse entirely** as defence in depth — a monotonic counter rather than
|
||||||
|
`useradd`'s lowest-free? The previous discussion concluded severing is better, and it is, because it also
|
||||||
|
fixes orphaned files. The two are not exclusive.
|
||||||
|
4. **What happens to a member's rootless Docker images and volumes** under preserve? They become unreadable to
|
||||||
|
any live account once chowned, which is correct but means the disk stays occupied by data nobody can open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
Every measured claim here comes from a real teardown on the production host on 2026-08-11: the surviving
|
||||||
|
account and container after a UI delete, the shell that outlived `terminate-user`, `userdel` releasing the
|
||||||
|
subuid ranges, and the final verified-clean state (no accounts ≥ 1000 but the owner, no files owned by 1001 or
|
||||||
|
1002 anywhere under `DATA_PATH` or `/home`, linger empty, the owner's eight containers untouched).
|
||||||
|
|
||||||
|
The one thing not measured is the preserve path. The teardown used `rm -rf`, because the data was a disposable
|
||||||
|
test database. `chown -R` as a severing mechanism is reasoned, not observed.
|
||||||
+2
-1
@@ -117,7 +117,8 @@ worth serving both from one place.
|
|||||||
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
|
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
|
||||||
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
|
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
|
||||||
strategies) should be enabled and surfaced in the UI precisely so this is not confused.
|
strategies) should be enabled and surfaced in the UI precisely so this is not confused.
|
||||||
- **It is not sharing.** Single-user remains a hard platform invariant.
|
- **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so
|
||||||
|
there is still nobody to share with, whatever the account list says since 2026-08-07.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ sidecar with its own scheduling, so it does not appear in the Jobs list.
|
|||||||
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone,
|
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone,
|
||||||
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
|
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
|
||||||
|
|
||||||
**Context:** single user, forever. No multi-tenant concerns — "is anything running?" is a global check.
|
**Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
|
||||||
Favor power-user affordances over guardrails. See memory `sole-user-assume-competence`.
|
on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script
|
||||||
|
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
|
||||||
|
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
|
||||||
|
Favor power-user affordances over guardrails.
|
||||||
|
|
||||||
## Current state (baseline)
|
## Current state (baseline)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# API keys — implementing the client side
|
||||||
|
|
||||||
|
Written 2026-08-08, for the mobile developer. Server side is live and verified; nothing below is
|
||||||
|
planned-but-missing unless it says so.
|
||||||
|
|
||||||
|
**The mobile apps are not built here.** This document is the boundary: what the server now accepts, what
|
||||||
|
changes in `monorepo-mobile`, and what does not.
|
||||||
|
|
||||||
|
> **Update, later on 2026-08-08 — the client side now exists, in `monorepo-mobile`.** `@officer/core`
|
||||||
|
> gained `services/api-keys.ts` and `useAuth` learned that a key is a session; **OffChat (`apps/chat`) is
|
||||||
|
> the proof of concept** and is the only app wired up. The other eighteen are untouched and behave
|
||||||
|
> exactly as before.
|
||||||
|
>
|
||||||
|
> **None of it has been compiled or run** — it was written on the Linux box, which has no `node_modules`
|
||||||
|
> for that repo and no Mac. Read §"What the client actually does now" for what shipped, what it changed
|
||||||
|
> about the advice below, and the two things this document got wrong about the client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The short version
|
||||||
|
|
||||||
|
**An API key goes exactly where the JWT goes.** Same `Authorization: Bearer …` header, same `?token=`
|
||||||
|
query fallback for media, same `?token=` on the WebSocket. Every door was taught the new format at once,
|
||||||
|
so there is no endpoint where a key works and another where it doesn't.
|
||||||
|
|
||||||
|
That is the whole point of the design, and it means the client change is small:
|
||||||
|
|
||||||
|
- `packages/core/src/services/api.ts` needs **no change at all** — it already sends whatever
|
||||||
|
`getToken()` returns as a bearer token.
|
||||||
|
- `packages/core/src/state/useAuth.ts` gains a second way to obtain that value.
|
||||||
|
- Multi-server storage already exists (`tokenKeyFor(activeServerId())` in `services/servers.ts`), so a
|
||||||
|
key per server slots into the same SecureStore entry the JWT uses today.
|
||||||
|
|
||||||
|
The problem being solved is **multiple logins across the apps**. Today every app signs in with the
|
||||||
|
password and gets its own 30-day JWT, and there is no way to cut one device off without a password
|
||||||
|
change that kills all of them. A key is revocable on its own, from the web UI, in one click.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a key looks like
|
||||||
|
|
||||||
|
```
|
||||||
|
ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0
|
||||||
|
```
|
||||||
|
|
||||||
|
`ofk_` prefix, then 32 CSPRNG bytes base64url-encoded. **Treat it as opaque.** Do not parse it, do not
|
||||||
|
validate its length, do not assume 47 characters — the only guarantee is the `ofk_` prefix and that it is
|
||||||
|
URL-safe and header-safe.
|
||||||
|
|
||||||
|
- **It does not expire** unless one was asked for at creation. Assume no expiry.
|
||||||
|
- **It is shown exactly once**, by the response that creates it. The server stores a SHA-256; there is no
|
||||||
|
endpoint that reads a key back and there never will be. If it is lost, revoke and mint another.
|
||||||
|
- **It carries the account's full authority** — the same as the password, no more. A member's key is
|
||||||
|
still a member.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two ways for the app to get one
|
||||||
|
|
||||||
|
### (a) Mint it in-app — recommended
|
||||||
|
|
||||||
|
Sign in with the password once, immediately trade the JWT for a key, store the key, throw the JWT away.
|
||||||
|
No copy-paste, no typing 47 characters on a phone keyboard.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/signin {email, password} → {token, user}
|
||||||
|
POST /api/api-keys {name: "iPhone 15"} → {entry, key} (Authorization: Bearer <token>)
|
||||||
|
store `key`, discard `token`
|
||||||
|
```
|
||||||
|
|
||||||
|
Name it after the device, not the app — the owner reads that string in the web UI when deciding what to
|
||||||
|
revoke, and "iPhone 15" is a decision they can make while "music" is not.
|
||||||
|
|
||||||
|
If you want one key per app rather than per device, name it `"<device> — <app>"`. Either is fine; be
|
||||||
|
consistent so the list stays readable.
|
||||||
|
|
||||||
|
### (b) Paste a key the owner made in the web UI
|
||||||
|
|
||||||
|
**Settings → Integrations → Personal → API keys.** Useful for a device that cannot show a sign-in form,
|
||||||
|
and as the recovery path when (a) fails. A paste field that accepts the key and skips signin entirely.
|
||||||
|
|
||||||
|
Validate only that it is non-empty and starts with `ofk_`, then make a real request and let the server
|
||||||
|
answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
Identical to the JWT in all three places:
|
||||||
|
|
||||||
|
| Where | How |
|
||||||
|
| ---------------------------------------------- | --------------------------------- |
|
||||||
|
| Normal requests | `Authorization: Bearer ofk_…` |
|
||||||
|
| Media URLs (`<Image>`, `<Video>`, file `/raw`) | `?token=ofk_…` — URL-encode it |
|
||||||
|
| WebSockets | `?token=ofk_…` on the upgrade URL |
|
||||||
|
|
||||||
|
The `?token=` fallback is accepted on **every** protected `/api` route, not just media. That is
|
||||||
|
pre-existing behaviour and not something to rely on: it puts the credential in URLs, which reach proxy
|
||||||
|
logs. Use the header wherever a header is possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changes in the client
|
||||||
|
|
||||||
|
**1. `useAuth`: a key is a session.** The current `signin` throws when the response has no token
|
||||||
|
(`'Sign-in did not return a token'`). A key-based login never calls `/api/auth/signin` at all — it calls
|
||||||
|
`GET /api/auth/me` with the key to confirm it works and to learn who it belongs to, then persists it and
|
||||||
|
sets the signed-in state.
|
||||||
|
|
||||||
|
**2. Do not call `POST /api/auth/signout` when signed in with a key.** It is harmless — the server skips
|
||||||
|
the blacklist step for a key and returns `{ok: true}` — but it does nothing useful either. "Sign out"
|
||||||
|
with a key means: clear it locally. If the user wants it dead server-side, that is **revoke**, and it
|
||||||
|
belongs in the app's settings screen, not on the sign-out button. Wording matters here: signing out of
|
||||||
|
one phone should not silently disable a key another app is using.
|
||||||
|
|
||||||
|
**3. Store it exactly where the token goes.** `tokenKeyFor(activeServerId())` in SecureStore. Nothing
|
||||||
|
else needs to know which kind of credential it holds — that is what makes this change small.
|
||||||
|
|
||||||
|
**4. Optionally: let the app revoke its own key.** `DELETE /api/api-keys/:id` works when authenticated
|
||||||
|
with that same key. Keep `entry.id` from the create response if you want a "disconnect this device"
|
||||||
|
button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error semantics — the part worth getting right
|
||||||
|
|
||||||
|
**Error bodies are plain text, not JSON.** `Unauthorized`, `Forbidden`, `Not Found`, `Invalid request
|
||||||
|
body`. There is no `{error}` or `{message}` envelope anywhere. `packages/core/src/services/api.ts`
|
||||||
|
already handles this correctly (it tries JSON and falls back to raw text) — do not "fix" it.
|
||||||
|
|
||||||
|
The one exception: **429** returns `{"retryAfter": <seconds>}` as JSON, with **no `Retry-After`
|
||||||
|
header**. Rate limiting applies to `/api/auth/*` only, so a key-based client that never calls signin
|
||||||
|
will not meet it.
|
||||||
|
|
||||||
|
**401 and 403 mean different things and must be handled differently.**
|
||||||
|
|
||||||
|
| Status | Meaning | What the app should do |
|
||||||
|
| ------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. |
|
||||||
|
| **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. |
|
||||||
|
|
||||||
|
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a
|
||||||
|
logout loop they cannot escape, because signing in again produces a credential with the same 403.
|
||||||
|
|
||||||
|
A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a key can and cannot reach
|
||||||
|
|
||||||
|
Authorization is unchanged by how you authenticated. A key resolves to a user, and that user's role
|
||||||
|
decides everything after.
|
||||||
|
|
||||||
|
- **The owner** (user 1) reaches everything.
|
||||||
|
- **Any other account** reaches only what its role has been granted, and **can never** reach the
|
||||||
|
`execution` capabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
|
||||||
|
user in the owner's home; they are refused structurally, not by policy.
|
||||||
|
|
||||||
|
Verified: a member's key returns the same status as that member's JWT on every route tried, 403s
|
||||||
|
included. If you see a key behave differently from a password login for the same account, that is a bug —
|
||||||
|
report it, don't work around it.
|
||||||
|
|
||||||
|
For sockets specifically: `cliamp` and `cliamp-audio` (music playback) are grantable. `chat`,
|
||||||
|
`terminal`, `task-runner`, `pipeline` and `desktop` are owner-only. `useChatSocket` therefore works for
|
||||||
|
the owner and will always 403 for a member — that is not new, and not caused by keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint reference
|
||||||
|
|
||||||
|
All three require an authenticated caller and act only on that caller's own keys. Nothing accepts a user
|
||||||
|
id; there is no request shape that reaches another account's keys.
|
||||||
|
|
||||||
|
### `POST /api/api-keys`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// request
|
||||||
|
{ "name": "iPhone 15", "expiresInDays": 90 } // expiresInDays optional; omit for no expiry
|
||||||
|
|
||||||
|
// 200
|
||||||
|
{
|
||||||
|
"entry": {
|
||||||
|
"id": 1, "userId": 1, "name": "iPhone 15",
|
||||||
|
"prefix": "ofk_eRgp_F", // display only — first 10 chars, never enough to use
|
||||||
|
"lastUsedAt": null, "expiresAt": null, "revokedAt": null,
|
||||||
|
"createdAt": "2026-08-08T10:08:58.687Z"
|
||||||
|
},
|
||||||
|
"key": "ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0" // the only time this appears
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`400` on a missing/blank name, a name over 100 characters, or a non-positive `expiresInDays`.
|
||||||
|
|
||||||
|
### `GET /api/api-keys`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
/* entry objects as above, newest first — never the key itself */
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`lastUsedAt` is debounced to at most one write a minute, so it can lag by up to 60 seconds. It is a
|
||||||
|
"which of these is still in use" signal, not an audit trail.
|
||||||
|
|
||||||
|
### `DELETE /api/api-keys/:id`
|
||||||
|
|
||||||
|
`{"ok": true}`, or `404` if the id is not yours or is already revoked — deliberately the same answer for
|
||||||
|
both, so the endpoint cannot be used to discover whether an id exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Things that will surprise you
|
||||||
|
|
||||||
|
- **No `Origin` header is needed today.** `ALLOW_ANY_ORIGIN` defaults to on, so origin checking is off
|
||||||
|
and the apps work sending none — which is what they do. Nothing here changes that. If it is ever
|
||||||
|
switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that
|
||||||
|
is a separate conversation, not part of this work.
|
||||||
|
- **`/api/auth/signin` can return 200 with no token.** Pre-existing: it happens when the account has a
|
||||||
|
passkey registered against the caller's origin, and it means "now do WebAuthn". Mobile sends no
|
||||||
|
`Origin`, so it never triggers today. Minting a key at first sign-in and never signing in again makes
|
||||||
|
the app immune to it permanently — a real reason to prefer path (a).
|
||||||
|
- **`user` in the signin response has no `role`.** It is `{id, email, name, username, passkeys}` where
|
||||||
|
`passkeys` is a count. Role comes from `GET /api/auth/me`. The mobile `AuthUser` type currently
|
||||||
|
declares `role` as required, which is wrong for signin — worth fixing while you are in there.
|
||||||
|
- **A key can mint another key.** There is no parent/child link, so revoking a key does not revoke ones
|
||||||
|
created with it. This is a consequence of a key carrying full account authority and is accepted for
|
||||||
|
now; it is the strongest single argument for scoped keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not built
|
||||||
|
|
||||||
|
- **Scopes.** A key cannot be narrowed to a subset of its holder's capabilities. The column and the check
|
||||||
|
are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is
|
||||||
|
there today. Design as if every key is full-authority, because it is.
|
||||||
|
- **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave —
|
||||||
|
revocation from a phone that has been lost is not a thing you can do from the phone.
|
||||||
|
- **Server-side "sign out everywhere".** Password change invalidates JWTs but deliberately **not** API
|
||||||
|
keys, since rotating them independently is the reason they exist. If the owner wants everything dead,
|
||||||
|
they revoke each key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifying against a real server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE=https://officer.pastilhas.dev
|
||||||
|
|
||||||
|
TOKEN=$(curl -s -X POST $BASE/api/auth/signin -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"you@example.com","password":"…"}' | jq -r .token)
|
||||||
|
|
||||||
|
KEY=$(curl -s -X POST $BASE/api/api-keys -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"name":"curl test"}' | jq -r .key)
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY" # 200
|
||||||
|
ID=$(curl -s $BASE/api/api-keys -H "Authorization: Bearer $KEY" | jq -r '.keys[0].id')
|
||||||
|
curl -s -X DELETE $BASE/api/api-keys/$ID -H "Authorization: Bearer $KEY" # {"ok":true}
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY" # 401
|
||||||
|
```
|
||||||
|
|
||||||
|
That sequence — create, use, revoke, 401 — is the one that was run against the live server before this
|
||||||
|
document was written, along with a WebSocket upgrade on `/api/cliamp/ws?token=<key>` returning 101.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the client actually does now
|
||||||
|
|
||||||
|
Built in `monorepo-mobile` on 2026-08-08, against the server described above. **Written, never compiled**
|
||||||
|
— no `node_modules` in that tree on this machine and no Mac, so not even `tsc` has seen it.
|
||||||
|
|
||||||
|
### New in `@officer/core`
|
||||||
|
|
||||||
|
- **`src/services/api-keys.ts`** — `listApiKeys` / `createApiKey` / `revokeApiKey`, `isApiKey`,
|
||||||
|
`defaultApiKeyName`, and `revokeApiKeyByCredential` (below). Shaped to mirror `dav.ts`'s app
|
||||||
|
passwords, which is the same mint-once-hash-forever contract.
|
||||||
|
- **`src/services/device-name.ts`** — `deviceName()`, lifted out of `dav.ts`'s `deviceLabel()` so both
|
||||||
|
credential features name a device the same way. `deviceLabel()` now composes it; no caller changed.
|
||||||
|
- **`useAuth`** — `signin(email, password, { deviceKeyName })` opts into path (a); `signInWithApiKey(key)`
|
||||||
|
is path (b).
|
||||||
|
|
||||||
|
### Where this document was wrong about the client
|
||||||
|
|
||||||
|
**1. There is no such thing as "one key per app" on mobile.** §(a) offers per-device or per-app as a free
|
||||||
|
choice. It is not: `packages/core/src/services/shared-session.ts` holds **one token per server, shared
|
||||||
|
across the whole suite** through an iOS keychain access group and an Android signature-permission
|
||||||
|
ContentProvider. Whichever app writes last wins, for all of them. So a key named after an app actively
|
||||||
|
misleads the owner about what revoking it disconnects — the answer is always "the device". The default
|
||||||
|
name is `"<device> — <app>"`, where the app half records only who did the minting.
|
||||||
|
|
||||||
|
**2. "Do not call signout with a key" was not the whole story — `distressSignout` was the real problem.**
|
||||||
|
Under duress the app clears the credential locally _first_, then best-effort revokes server-side via
|
||||||
|
`POST /api/auth/revoke`. That endpoint blacklists a JWT and does nothing whatever for a key, so the
|
||||||
|
moment a phone starts holding keys, distress sign-out silently stops killing the credential. **And it
|
||||||
|
matters more here than it ever did for a JWT: an unrevoked session token still expires in thirty days, an
|
||||||
|
unrevoked key never does.**
|
||||||
|
|
||||||
|
`revokeApiKeyByCredential(baseUrl, key)` is the fix — raw `fetch` (the credential is already out of
|
||||||
|
storage by then, so it cannot go through `request()`), `GET /api/api-keys`, match on the `prefix` the
|
||||||
|
server kept in the clear, `DELETE /api/api-keys/:id`.
|
||||||
|
|
||||||
|
### Two client-side decisions worth a second opinion
|
||||||
|
|
||||||
|
- **A 401 is now the only thing that clears the credential.** `useAuth`'s `/api/auth/me` query used to
|
||||||
|
clear on _any_ throw. A network error is `ApiError(0)` and a timeout is `ApiError(408)`, so launching
|
||||||
|
with no connectivity destroyed a working session and dropped the user at a login screen that needs the
|
||||||
|
network to be useful — and a 403 produced the logout loop this document warns about. Pre-existing, not
|
||||||
|
caused by keys, fixed while in there.
|
||||||
|
- **The traded-in JWT is left to expire, not blacklisted.** Minting a key and keeping it drops a live
|
||||||
|
thirty-day token on the floor, and `POST /api/auth/signout` is the obvious tidy-up — but that handler
|
||||||
|
also calls `clearVaultTokens(user.id)`, keyed on the **user**, not the session. Blacklisting the
|
||||||
|
discarded token would therefore drop a vault session brokered on another of the owner's devices, as a
|
||||||
|
side effect of signing in. Not worth it for a token nothing holds and nothing will send again.
|
||||||
|
|
||||||
|
### Still not built on the client
|
||||||
|
|
||||||
|
No key-management screen in any app — listing and revoking remain web-only, per "Not built" above. The
|
||||||
|
service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where this lives on the server
|
||||||
|
|
||||||
|
- `src/servers/auth-token.ts` — the key format and `resolveAuthToken`, the single function that turns a
|
||||||
|
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
|
||||||
|
WebSocket upgrade in `server.tsx`, and the vault socket.
|
||||||
|
- `src/servers/api/api-keys/router.ts` — the three endpoints.
|
||||||
|
- `src/databases/officer_db/src/schema/api-keys.ts` — the table, and why it stores what it stores.
|
||||||
@@ -58,7 +58,9 @@ speaks DAV.
|
|||||||
|
|
||||||
### 1.2 The credential model
|
### 1.2 The credential model
|
||||||
|
|
||||||
- **Username** = the account's email address. (Officer is single-user; there is exactly one.)
|
- **Username** = the account's email address — the signed-in account's own, not a constant. (This said
|
||||||
|
"Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable
|
||||||
|
capability, so a member can hold their own app passwords and their own collections.)
|
||||||
- **Password** = a **DAV app password**, not the login password.
|
- **Password** = a **DAV app password**, not the login password.
|
||||||
|
|
||||||
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
|
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
|
||||||
@@ -100,9 +102,12 @@ A collection https://<host>/dav/<userId>/<collection>/
|
|||||||
`<userId>` **is the platform user id, by construction** — the same integer `/auth/me` returns. They
|
`<userId>` **is the platform user id, by construction** — the same integer `/auth/me` returns. They
|
||||||
cannot diverge: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the app-password
|
cannot diverge: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the app-password
|
||||||
row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's storage tree is literally
|
row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's storage tree is literally
|
||||||
`/<that value>/`. There is no mapping table to get out of step. On a single-user instance — which every
|
`/<that value>/`. There is no mapping table to get out of step.
|
||||||
Officer instance is — that is `1`. Deriving it from `/auth/me` is safe; so is deriving it from the
|
|
||||||
collection paths, which is why both work today.
|
**Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer
|
||||||
|
instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the
|
||||||
|
`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection
|
||||||
|
paths; both work, and both stay correct when the caller is not the owner.
|
||||||
|
|
||||||
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
|
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
|
||||||
`collection` outside that prefix, and Radicale runs `rights type = owner_only`.
|
`collection` outside that prefix, and Radicale runs `rights type = owner_only`.
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ Rationale for not reusing the account password: it ends up typed into a phone, s
|
|||||||
account manager in recoverable form, and synced to whatever backs that phone up. One password per
|
account manager in recoverable form, and synced to whatever backs that phone up. One password per
|
||||||
device, revocable per device, is the whole point.
|
device, revocable per device, is the whole point.
|
||||||
|
|
||||||
The single-user invariant holds — every app password belongs to the owner. `user_id` is there for
|
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
|
||||||
referential integrity, not multi-tenancy.
|
since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member
|
||||||
|
and the column decides whose collection tree Radicale serves. It is load-bearing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,8 +183,9 @@ Naming these now so they do not creep in later:
|
|||||||
- **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and
|
- **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and
|
||||||
a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only
|
a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only
|
||||||
on a concrete need.
|
on a concrete need.
|
||||||
- **Sharing, ACLs, federation** — single-user is a hard invariant of this platform. There is nobody
|
- **Sharing, ACLs, federation** — still out of scope, but the reason weakened on 2026-08-07. Members
|
||||||
to share with.
|
can now hold `calendar`, so there is somebody to share with; what is missing is any notion of one
|
||||||
|
account granting another access to its own collection. Revisit on a concrete need.
|
||||||
- **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens
|
- **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens
|
||||||
where it is displayed, using a library.
|
where it is displayed, using a library.
|
||||||
- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the
|
- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
# OpenCode's newer API — what it is, what it would cost, what it buys
|
||||||
|
|
||||||
|
Written 2026-08-11 against **opencode 1.18.16**, from three sources: the running server's own OpenAPI
|
||||||
|
document (`GET /doc` on `opencode serve`), live probes against a real serve, and upstream docs/npm.
|
||||||
|
Every claim below is marked by where it came from. Measurements were taken on the local serve
|
||||||
|
(port 49698, the `officer-opencode` sidecar's own) and cleaned up afterwards — the session store is
|
||||||
|
back to the 50 rows it started with.
|
||||||
|
|
||||||
|
Read this before starting any opencode work. Two live defects fell out of writing it (§1), and the
|
||||||
|
naming is actively misleading (§2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Two live defects, found while measuring
|
||||||
|
|
||||||
|
Neither is a migration concern. Both are broken right now, in production, and both are consequences of
|
||||||
|
being half-migrated.
|
||||||
|
|
||||||
|
### 1a. Every OpenCode conversation created since 2026-08-10 opens EMPTY
|
||||||
|
|
||||||
|
Since Phase D, turns run through `POST /api/session/{id}/prompt`, so the session belongs to the newer
|
||||||
|
engine. But `loadOpenCodeSession` reads the transcript through the legacy route
|
||||||
|
(`client.ts:51` → `GET /session/{id}/message`).
|
||||||
|
|
||||||
|
**The two surfaces are mutually blind.** Measured, both directions, on a session created via `/api` and
|
||||||
|
run to completion with a real model reply:
|
||||||
|
|
||||||
|
| read | api-created session | legacy-created session |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript |
|
||||||
|
| `GET /api/session/{id}/message` | 200, 3 messages | **500** |
|
||||||
|
| `GET /session/{id}` (the record) | 200, title + directory | 200 |
|
||||||
|
|
||||||
|
So the row appears in the list with its title and directory, and opens with nothing in it. And the
|
||||||
|
inverse is equally true: switching the reader to `/api` without keeping the old one would empty every
|
||||||
|
conversation from before 2026-08-10.
|
||||||
|
|
||||||
|
The fix is not "swap the endpoint" — it is "route by which engine owns the session", and there is no
|
||||||
|
field that says so. The one usable discriminator found tonight is that the legacy read returns `[]`
|
||||||
|
rather than erroring.
|
||||||
|
|
||||||
|
### 1b. The session list silently truncates at 50
|
||||||
|
|
||||||
|
`GET /api/session` defaults to **50 rows** and returns a `cursor.next`. Measured: with 50 sessions in
|
||||||
|
the store the list returns 50 *and still offers a next cursor*; adding a 51st and asking `?limit=200`
|
||||||
|
returns 51 (and `limit` is capped at 100 — 200 is accepted for the list but `/history` rejects >100
|
||||||
|
with `Expected a value less than or equal to 100`).
|
||||||
|
|
||||||
|
`client.ts:36` sends neither `limit` nor `cursor`, so **once the store passes 50 sessions the oldest
|
||||||
|
stop appearing in `/chat`**. The local store is at exactly 50 today. This is in code shipped this
|
||||||
|
morning (`adaaba6`).
|
||||||
|
|
||||||
|
The same endpoint takes `directory=` — verified filtering correctly (`?directory=/tmp/oc-cap` → 11
|
||||||
|
rows, all in that directory). We fetch everything and filter client-side in `opencode-sessions.ts:49`.
|
||||||
|
Pushing the filter down fixes the normal case and brings `search=`, `order=`, `project=` with it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The naming, because "API 2.0" means two different things
|
||||||
|
|
||||||
|
There is no version string "2.0" in the running server. `GET /doc` self-reports
|
||||||
|
`{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists:
|
||||||
|
|
||||||
|
| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** |
|
||||||
|
|---|---|---|---|
|
||||||
|
| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` |
|
||||||
|
| routes | 111 paths | 51 paths | ~100 paths, still moving |
|
||||||
|
| operationIds | `session.list` | **`v2.session.list`** | — |
|
||||||
|
| we use it | reads: transcript, delete, rename | writes: every turn since 2026-08-10 | not at all |
|
||||||
|
| docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs |
|
||||||
|
|
||||||
|
So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its
|
||||||
|
operation ids are literally `v2.*`. It is not something to adopt; it is something to *finish*.
|
||||||
|
|
||||||
|
Two qualifications, both from the source at tag `v1.18.16`:
|
||||||
|
|
||||||
|
- **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`,
|
||||||
|
version `"0.0.1"`, described as *"Experimental HttpApi surface for selected instance routes"*, with
|
||||||
|
every group annotated the same way. Meanwhile `/session/*` is the surface the public docs actually
|
||||||
|
document, and it is not deprecated. The internal direction is unambiguous; the external commitment is
|
||||||
|
nil.
|
||||||
|
- **`session.next` is the event family of that rewritten engine, and the name is already dead
|
||||||
|
upstream.** It arrived in **1.15.0** (PR #27415, "Add Effect-native core event system", merged
|
||||||
|
2026-05-15) as an interim prefix. On the `v2` branch all 36 session events have dropped `.next.` —
|
||||||
|
`session.step.started`, `session.text.delta` — along with renames: `agent.switched` →
|
||||||
|
`agent.selected`, `model.switched` → `model.selected`, `prompted` → `prompt.promoted`. Those renames
|
||||||
|
are **v2-branch only**; the 1.x line we run still emits `session.next.*`. Code against
|
||||||
|
`session.next.*` today, but put the names behind one mapping table, because they are scheduled to
|
||||||
|
change wholesale.
|
||||||
|
|
||||||
|
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: *"V1 coexistence is temporary… delete the
|
||||||
|
V1 subtree when the legacy runtime is retired"* and *"Do not preserve `V2` as the permanent name for the
|
||||||
|
replacement architecture."* Both halves of today's naming are transitional.
|
||||||
|
|
||||||
|
**OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs
|
||||||
|
carry the banner *"we may wipe your data, things may break, and APIs, configuration, and plugin APIs
|
||||||
|
may change"*, releases ship ~6/day, and the migration guide states three intentional breaking changes
|
||||||
|
(plugin API, server API contracts, TUI config), with *"Integrations that call the V1 server API must
|
||||||
|
migrate to the V2 API"*. No deprecation date for the legacy surface is published anywhere.
|
||||||
|
|
||||||
|
Two facts worth knowing regardless:
|
||||||
|
|
||||||
|
- **The repo moved.** `github.com/sst/opencode` 301s to **`github.com/anomalyco/opencode`**. Every npm
|
||||||
|
package now points there. No announcement was found explaining it.
|
||||||
|
- **There is already a typed client for the surface we run.** `@opencode-ai/sdk@1.18.16` ships two
|
||||||
|
generated clients: the default export covers legacy only, and **`@opencode-ai/sdk/v2` covers all 51
|
||||||
|
`/api/*` routes**. We have no opencode dependency at all today — every call is hand-rolled `fetch`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What we call today
|
||||||
|
|
||||||
|
Two of our processes talk to one serve, with no shared client.
|
||||||
|
|
||||||
|
**Sidecar (`src/servers/sidecar/opencode/`) — already on `/api/*`:** `POST /api/session`
|
||||||
|
(`serve-runner.ts:222`), `POST …/model` (`:236`), `POST …/prompt` (`:266`, `:199`), `POST …/interrupt`
|
||||||
|
(`:328`), `GET /api/event` (`:86`), `GET /api/health` (`index.ts:79`),
|
||||||
|
`POST /api/integration/{provider}/connect/key` (`connect-credential.ts:66`).
|
||||||
|
|
||||||
|
**API server (`src/servers/api/chat/opencode/client.ts`) — still legacy:** `GET /session/{id}` (`:45`),
|
||||||
|
`GET /session/{id}/message` (`:51`), `DELETE /session/{id}` (`:57`), `PATCH /session/{id}` (`:62`),
|
||||||
|
plus `GET /config/providers` for the model list (`list-models.ts:58`). The one exception is
|
||||||
|
`GET /api/session` for the list (`:36`), moved this morning.
|
||||||
|
|
||||||
|
51 routes exist. We call 7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What the newer surface has that we don't use
|
||||||
|
|
||||||
|
### 4a. Adding context to a turn that is already running
|
||||||
|
|
||||||
|
The capability the subprocess path could never have, and the reason the migration happened.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/session/{id}/prompt
|
||||||
|
{ "id": "msg_…", "prompt": { "text", "files": [{uri,name,description,source}],
|
||||||
|
"agents": [{name,source}] },
|
||||||
|
"delivery": "steer" | "queue", "resume": true|false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is
|
||||||
|
false."*
|
||||||
|
|
||||||
|
- **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the
|
||||||
|
work in flight. No kill, no restart, no lost context. We already send it (`serve-runner.ts:199`) but
|
||||||
|
only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI *asks* for
|
||||||
|
it, and nothing distinguishes "add this to what you're doing" from "here's my next message".
|
||||||
|
- **`delivery: "queue"`** runs after the current turn. It must be stated explicitly — **the field
|
||||||
|
defaults to `steer`** — or two quick messages merge into one turn (`serve-runner.ts:268`).
|
||||||
|
- **`prompt.files[]`** attaches content to that same input; measured last night, it must be a `data:`
|
||||||
|
URI (a `file://` one is accepted with 200 and dies inside the provider). Each attachment also takes
|
||||||
|
a `description`, which we don't send.
|
||||||
|
- **`prompt.agents[]`** attaches an agent to the input. Unused, unexplored.
|
||||||
|
- **`id`** lets the caller mint the `msg_…` id, which is how a send survives a retry without
|
||||||
|
double-posting. We let the server mint it and therefore can't.
|
||||||
|
|
||||||
|
Measured: the POST returns in **22 ms** with `{"admittedSeq":1,"id":"msg_…","delivery":"queue"}`. It is
|
||||||
|
an admission receipt, not a turn — and `admittedSeq` is the durable cursor for everything that follows.
|
||||||
|
|
||||||
|
### 4b. Surviving a restart mid-turn — verified working
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/session/{id}/event?after=<seq> "Replay durable events after an aggregate sequence,
|
||||||
|
then continue with new durable events."
|
||||||
|
GET /api/session/{id}/history?limit=&after= "Read one finite page of public durable Session events
|
||||||
|
after an exclusive aggregate sequence."
|
||||||
|
```
|
||||||
|
|
||||||
|
Driven end to end tonight on a real turn (free model, "Reply with exactly: hi"):
|
||||||
|
|
||||||
|
```
|
||||||
|
seq 1 session.next.prompt.admitted seq 6 session.next.context.updated
|
||||||
|
seq 2 session.next.prompted seq 7 session.next.step.started
|
||||||
|
seq 3 session.next.model.switched seq 8 session.next.text.started
|
||||||
|
seq 4 session.next.prompt.admitted seq 9 session.next.text.ended
|
||||||
|
seq 5 session.next.prompted seq 10 session.next.step.ended
|
||||||
|
```
|
||||||
|
|
||||||
|
`?after=5` returned exactly 6–10. `GET …/event?after=7` replayed 8, 9, 10 and then held the socket open
|
||||||
|
for more. Every durable event carries `{aggregateID, seq: integer, version}`, so `after=` is that
|
||||||
|
integer. This is the documented, working answer to the gap Phase B left open and
|
||||||
|
`docs/opencode-testing-checklist.md` calls the most likely thing to be broken.
|
||||||
|
|
||||||
|
The upstream implementation (`packages/core/src/event.ts`, `durable()`) makes three things explicit
|
||||||
|
that matter for building on it:
|
||||||
|
|
||||||
|
- `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session.
|
||||||
|
Omitting it replays the session from 0.
|
||||||
|
- **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`,
|
||||||
|
advances its cursor to the last row, and on every wake re-reads *the database* rather than draining a
|
||||||
|
pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit
|
||||||
|
`Sequence mismatch` / `Replay diverged` errors.
|
||||||
|
- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery,
|
||||||
|
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||||
|
|
||||||
|
Note the two cursor kinds are unrelated: the session *list* uses an opaque base64url cursor
|
||||||
|
(`cursor.previous` / `cursor.next`), this one is a plain integer.
|
||||||
|
|
||||||
|
**But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a
|
||||||
|
`oneOf` of exactly 28 members, and the five it omits are `text.delta`, `tool.input.delta`,
|
||||||
|
`reasoning.delta`, `compaction.delta` and the retry error. **Deltas are live-only by design; the
|
||||||
|
durable log stores whole values.** So a client that wants both token streaming and restart recovery
|
||||||
|
must read both streams: the global live one for deltas, the per-session durable one for the replayable
|
||||||
|
spine. Last night's 13-vs-21 event count was this same fact, found by counting instead of by reading.
|
||||||
|
|
||||||
|
### 4c. Knowing what is running, without having started it
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/session/active "Retrieve foreground Session drains currently owned by this OpenCode
|
||||||
|
process. Sessions absent from the result are inactive."
|
||||||
|
POST /api/session/{id}/wait "Wait for a session agent loop to become idle."
|
||||||
|
```
|
||||||
|
|
||||||
|
Today "what is running" is an in-memory map in our sidecar (`serve-runner.ts:66`). Restart the sidecar
|
||||||
|
and the truth is gone — which is why `/chat/live` can be wrong after a restart. `session/active` is the
|
||||||
|
server's own answer and survives us.
|
||||||
|
|
||||||
|
### 4d. Permissions and questions — nothing in officer models this
|
||||||
|
|
||||||
|
```
|
||||||
|
GET|POST /api/session/{id}/permission POST …/permission/{requestID}/reply
|
||||||
|
GET /api/permission/saved DELETE /api/permission/saved/{id}
|
||||||
|
GET /api/session/{id}/question POST …/question/{requestID}/reply | /reject
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus `permission.v2.asked` / `question.v2.asked` events (the v1 families still exist alongside; the
|
||||||
|
only `deprecated: true` operation in the entire document is `POST /session/{id}/permissions/{id}`).
|
||||||
|
|
||||||
|
The two "v2"s are not the same kind of change, which matters if we implement one of them:
|
||||||
|
|
||||||
|
- **Permissions v2 is a real contract change.** A rule goes from `{permission, pattern, action}` to
|
||||||
|
`{action, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to
|
||||||
|
`{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union
|
||||||
|
`source: {type:"tool", messageID, callID}`; and the reply loses its free-text `message`. The public
|
||||||
|
V2 docs say the same in config terms: *"Do not use `permission`, `bash`, or `task` in V2
|
||||||
|
configuration."*
|
||||||
|
- **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of
|
||||||
|
`{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and
|
||||||
|
event names changed.
|
||||||
|
|
||||||
|
Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the
|
||||||
|
`/api` protocol is *built* from excludes the v1 families, but the server wires the **full** manifest
|
||||||
|
(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event`
|
||||||
|
union on our own `/doc`.
|
||||||
|
|
||||||
|
An opencode agent that wants consent, or that asks a question mid-turn, gets no answer from officer. We
|
||||||
|
don't subscribe to those events and have no route to reply on. Claude's harness runs
|
||||||
|
`--dangerously-skip-permissions`, so this has never been modelled for either harness. Largest single
|
||||||
|
behavioural gap.
|
||||||
|
|
||||||
|
### 4e. Undo, compaction, context
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/session/{id}/revert/stage {messageID, files?} …/revert/commit …/revert/clear
|
||||||
|
POST /api/session/{id}/compact GET /api/session/{id}/context
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage a revert to a message, then commit or discard. Explicit compaction with
|
||||||
|
`compaction.started/delta/ended` events, and a readable context state. Officer has none of this.
|
||||||
|
|
||||||
|
### 4f. The rest
|
||||||
|
|
||||||
|
`GET /api/agent`, `/api/skill`, `/api/command`, `/api/model`, `/api/provider`, `/api/fs/{list,find,read}`,
|
||||||
|
`GET|POST /api/pty` (+`connect`, `connect-token`), `POST /api/session/{id}/agent` (switch agent
|
||||||
|
mid-session), `/api/reference`, `/api/location`, `/api/integration`, `/api/credential/{id}`.
|
||||||
|
|
||||||
|
`GET /api/model` and `/api/provider` are the `/api` equivalents of the `/config/providers` call our
|
||||||
|
model list is built on (87 models locally). `/api/pty` overlaps our own pty sidecar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What the event stream carries that we drop
|
||||||
|
|
||||||
|
Our mapper recognises 18 names and maps 7. The server emits **130 event type strings**, 32 in the
|
||||||
|
`session.next.*` family plus eight plain `session.*` (`idle`, `status`, `error`, `compacted`,
|
||||||
|
`created`, `deleted`, `updated`, `diff`).
|
||||||
|
|
||||||
|
| dropped | what it would give |
|
||||||
|
|---|---|
|
||||||
|
| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode |
|
||||||
|
| `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive |
|
||||||
|
| `tool.progress` | long tools reporting instead of appearing hung |
|
||||||
|
| `shell.started/ended` | shell commands as a first-class thing |
|
||||||
|
| `compaction.*` | telling the user the context was compacted |
|
||||||
|
| `revert.*` | §4e |
|
||||||
|
| `retried` | a retry that currently looks like a stall |
|
||||||
|
| `prompt.admitted` / `prompted` | acknowledgement — the exact window where silence has twice cost an afternoon |
|
||||||
|
| `session.idle` | the real turn-end signal (see below) |
|
||||||
|
|
||||||
|
We end a turn on `step.ended` with `finish !== 'tool-calls'` (`serve-runner.ts:139`), because there is
|
||||||
|
no turn-ended event in what we read. `session.idle` looks like what that rule approximates, and it is
|
||||||
|
not in our `KNOWN` set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Two silent-failure modes, both reproduced tonight
|
||||||
|
|
||||||
|
Both produce the identical signature — `prompt.admitted`, `prompted`, then **nothing, forever**:
|
||||||
|
|
||||||
|
1. **No credential connected** for the `/api` surface. Already known and fixed at boot
|
||||||
|
(`connect-credential.ts`), but the failure has no error.
|
||||||
|
2. **No model on the session and no server default.** New tonight: my first probe sat at
|
||||||
|
`admitted → prompted` and stopped. `GET /config` reports `model: None`, and the session had no model
|
||||||
|
because I hadn't set one. `POST …/model` then re-prompting produced the full 10-event turn above.
|
||||||
|
|
||||||
|
Our runner only sends `POST …/model` when `params.model` is set (`serve-runner.ts:231`). **A turn sent
|
||||||
|
with no model, against a serve with no configured default, hangs silently.** Worth an explicit check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. What finishing the migration would cost
|
||||||
|
|
||||||
|
- **Both readers stay.** §1a: `/api` reads 500 on legacy-owned sessions, legacy reads `[]` on
|
||||||
|
api-owned ones. Routing by ownership is required, and no field declares ownership.
|
||||||
|
- **Delete and rename cannot move.** `/api/session/{sessionID}` is **GET only**; `DELETE` and `PATCH`
|
||||||
|
exist only on the legacy route (spec-verified, and a live `DELETE` returned 200).
|
||||||
|
- **The transcript shape differs.** Legacy items are `{info:{role,…}, parts:[…]}` — what
|
||||||
|
`opencode-sessions.ts:81` parses. `/api` items are
|
||||||
|
`{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}],
|
||||||
|
finish, cost, tokens}`. A second mapper, or a shared normaliser.
|
||||||
|
- **The SSE parser needs to grow up.** `serve-runner.ts:89` is `data:`-only: no `event:`, no `id:`, no
|
||||||
|
comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream
|
||||||
|
must resume at `?after=<last seq>`, not restart.
|
||||||
|
- **Two envelope unwrappers and three hand-written type sets** (`serve-runner.ts:161`, `client.ts:38`;
|
||||||
|
types pinned by comment to two different opencode versions, 1.17.9 and 1.18.16). This is the part a
|
||||||
|
dependency would delete outright — see below.
|
||||||
|
- **Stale comments in at least nine files** still describe the deleted `opencode run` subprocess path
|
||||||
|
(`protocol.ts:198`, `serve-events.ts:5`, `connect-credential.ts:24`, `index.ts:137`,
|
||||||
|
`websocket.ts:454`, `chat.ts:127`, `list-models.ts:75`, `sidecar-server.ts:8`, `send-opencode.ts:7`).
|
||||||
|
Two of them actively lie: they say turns read `auth.json` and don't depend on the credential connect.
|
||||||
|
They now do.
|
||||||
|
|
||||||
|
Unrelated but found while inventorying: the settings UI writes provider keys to `~/.pi/agent/auth.json`
|
||||||
|
(`chat-providers.ts:10`) while the credential connect reads `~/.local/share/opencode/auth.json`
|
||||||
|
(`connect-credential.ts:29`). Two different files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7b. The SDK is generated from the document we have been reading by hand
|
||||||
|
|
||||||
|
`@opencode-ai/sdk@1.18.16` (published 2026-08-10, versioned in lockstep with the CLI) is built by
|
||||||
|
`packages/sdk/js/script/build.ts`, which runs opencode's own `generate` to produce the OpenAPI document
|
||||||
|
and feeds it to `@hey-api/openapi-ts`. **It is generated from the same `/doc` we probed**, which is
|
||||||
|
about as good a guarantee of shape-agreement as exists.
|
||||||
|
|
||||||
|
It ships two clients. The default export is the legacy surface. `@opencode-ai/sdk/v2` is ours:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
|
const client = createOpencodeClient({ baseUrl });
|
||||||
|
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
|
||||||
|
const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq });
|
||||||
|
for await (const ev of events.stream) { /* ev.type, ev.durable.seq */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
`client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/
|
||||||
|
context/history/events/interrupt/message(s); there is also `client.v2.event.subscribe`,
|
||||||
|
`client.v2.permission.*`, `client.v2.question.*`, `fs`, `model`, `provider`, `agent`, `skill`, `pty`.
|
||||||
|
`createOpencodeClient` takes `directory` and injects it as both the `x-opencode-*` headers and the
|
||||||
|
`location[directory]` query param — the thing we hand-roll in two places.
|
||||||
|
|
||||||
|
That would delete: our hand-rolled SSE reader, both envelope unwrappers, three hand-written type sets,
|
||||||
|
and the model-id string splitting. It is a dependency change, and installs here are frozen, so it is a
|
||||||
|
deliberate `bun install --no-frozen-lockfile` plus a read of the lockfile diff. Worth noting the
|
||||||
|
package's only dependency is `cross-spawn`.
|
||||||
|
|
||||||
|
Not to be confused with two siblings the v2 docs mention: `@opencode-ai/sdk-next` is marked private and
|
||||||
|
is not on npm, and `@opencode-ai/client` is a private generation target for the beta line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. What we'd gain immediately
|
||||||
|
|
||||||
|
Ordered by value over effort. 1–3 are bug fixes, not features.
|
||||||
|
|
||||||
|
1. **Transcripts that aren't empty** — route the read by session ownership. This is broken in
|
||||||
|
production now. (§1a)
|
||||||
|
2. **A list that doesn't stop at 50**, filtered server-side by `?directory=`. One call site. (§1b)
|
||||||
|
3. **A turn that can't hang silently** — set a model explicitly, or check `/config` for a default, and
|
||||||
|
say so out loud when neither exists. (§6)
|
||||||
|
4. **"Add to what you're doing" as a real control** — `delivery: "steer"` on a deliberate trigger
|
||||||
|
rather than only when a message happens to land mid-turn. The plumbing already exists. (§4a)
|
||||||
|
5. **Idempotent sends** — mint our own `msg_…`. One field. (§4a)
|
||||||
|
6. **Restart recovery** — subscribe `?after=<seq>` alongside the live stream. Verified working. (§4b)
|
||||||
|
7. **A truthful live panel** — `GET /api/session/active`. (§4c)
|
||||||
|
8. **Richer streaming for free** — reasoning deltas, tool-input deltas, tool progress, retries. Already
|
||||||
|
arriving on the socket we already read, and dropped in a `default:` case. (§5)
|
||||||
|
9. **Explicit compaction and context** instead of a long conversation quietly getting more expensive.
|
||||||
|
|
||||||
|
Then the two that are real features needing UI: **permissions/questions** (§4d) and **revert** (§4e).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What this does NOT get us
|
||||||
|
|
||||||
|
- It does not retire the legacy surface: delete, rename and every pre-2026-08-10 transcript stay there,
|
||||||
|
with no deprecation date published.
|
||||||
|
- It does not touch the Claude harness — a different sidecar, a different protocol. Every gain above
|
||||||
|
lands on one harness only, while the chat UI assumes the two behave alike.
|
||||||
|
- It does not put us on OpenCode 2.0. Note the direction of travel there: the beta **removes**
|
||||||
|
`/api/session/{id}/history` and `/api/session/{id}/event` — the two durable routes item 6 depends on
|
||||||
|
— replacing them with `GET /api/experimental/session/{id}/log?after=&follow=`. Same idea, new path,
|
||||||
|
`experimental/` prefix. So item 6 is worth doing *and* worth writing behind one function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open questions
|
||||||
|
|
||||||
|
1. Is there a field that says which engine owns a session? Tonight's only discriminator is behavioural
|
||||||
|
(legacy returns `[]`). If not, we need our own record — we already store `sessionKey → ses_…` in
|
||||||
|
`opencode/state.ts` and could record the surface with it.
|
||||||
|
2. Which permission/question family does a 1.18.16 agent actually emit? Both are declared and both
|
||||||
|
appear in our `/doc`, because the server wires the full manifest. Measure before building UI.
|
||||||
|
3. What is `/api/*`'s auth story? The spec declares no `securitySchemes` yet every route declares a
|
||||||
|
`401`. The v1 docs describe HTTP Basic via `OPENCODE_SERVER_PASSWORD`; we run with none, on
|
||||||
|
loopback. In the 2.0 beta this is formalised as basic auth read from
|
||||||
|
`~/.local/state/opencode/service.json`.
|
||||||
|
4. Does `@opencode-ai/sdk/v2` work against 1.18.16 exactly? It is generated from this exact server's
|
||||||
|
OpenAPI output and versioned in lockstep, so it should — but nobody here has run it.
|
||||||
|
5. When did `/api/*` first appear in the 1.x line? UNKNOWN; the changelog names no `/api/` additions.
|
||||||
|
The event family underneath it landed in 1.15.0. And no dated removal plan exists for `/session/*`,
|
||||||
|
`permission.asked/replied` or `question.*` — only the undated internal intent quoted in §2.
|
||||||
|
6. Will the `v2`-branch event renames reach the 1.x line, or only ship with OpenCode 2.0? No merge
|
||||||
|
found, no statement either way. This decides whether the mapping table in §2 is a one-off or a
|
||||||
|
permanent seam.
|
||||||
|
7. Is a durable `seq` stable across a server restart or a session move? It is a database column, so it
|
||||||
|
should be, but no durability guarantee is documented and we have not tested it. Item 6 in §8 depends
|
||||||
|
on the answer.
|
||||||
|
|
||||||
|
Version state at the time of writing: **1.18.16 is the newest release** (2026-08-10) and contains
|
||||||
|
nothing API-facing. The active stream is the 2.0 beta, cutting releases continuously — the most recent
|
||||||
|
was published hours before this file was written.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Sources
|
||||||
|
|
||||||
|
- Live: `GET /doc` on `opencode serve` 1.18.16 (162 paths, 51 under `/api/`), plus the probes recorded
|
||||||
|
above against the local sidecar's serve on port 49698.
|
||||||
|
- Code: `src/servers/sidecar/opencode/*`, `src/servers/api/chat/opencode/*`, `opencode-sessions.ts`,
|
||||||
|
`list-models.ts`, `send-opencode.ts`.
|
||||||
|
- Upstream, docs: opencode.ai/v2/docs/migrate-v1, opencode.ai/v2/docs, opencode.ai/v2/docs/permissions,
|
||||||
|
opencode.ai/docs/server, opencode.ai/changelog.
|
||||||
|
- Upstream, source at tag `v1.18.16` in **github.com/anomalyco/opencode** (formerly `sst/opencode`,
|
||||||
|
which 301s): `packages/protocol/src/api.ts` and `groups/session.ts` (the surface's own "experimental"
|
||||||
|
self-description, the `after` parameter), `packages/schema/src/session-event.ts` (`DurableDefinitions`
|
||||||
|
vs `Definitions` — the delta exclusion), `packages/schema/src/{permission,question}.ts` and their
|
||||||
|
`v1/` counterparts, `packages/schema/src/session-input.ts` (`admittedSeq`), `packages/schema/AGENTS.md`
|
||||||
|
(the V1/V2 naming intent), `packages/core/src/event.ts` (replay-then-live), `packages/sdk/js/script/
|
||||||
|
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||||
|
(the renames).
|
||||||
|
- npm: `@opencode-ai/sdk` 1.18.16, `@opencode-ai/client@next`.
|
||||||
|
- Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`,
|
||||||
|
`-serve-path.md`, `-testing-checklist.md`, `-phase0-review.md`, `-phase1-report.md`,
|
||||||
|
`-phase1-review.md`.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# The Phase 2 fork: reopened, and why the first answer was wrong
|
||||||
|
|
||||||
|
> **CORRECTION, same day.** An earlier version of this file concluded "the new pipeline does not
|
||||||
|
> execute, keep the subprocess". **That was wrong, and wrong for an embarrassing reason: my probe.**
|
||||||
|
> The conclusion is reversed below. The mistake is written up rather than deleted, because the shape of
|
||||||
|
> it is the useful part.
|
||||||
|
|
||||||
|
**Decision: the fork is worth taking, and it is no longer blocked.** Not started; the blocker turned out
|
||||||
|
to be a missing credential and is fixed — see RESOLVED below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What actually happened
|
||||||
|
|
||||||
|
The serve exposes a newer `/api/session/*` surface, alongside the `/session/*` one every prior document
|
||||||
|
was written against. It offers, natively, what the parity doc lists as impossible under `stdin: 'ignore'`:
|
||||||
|
`delivery: "steer" | "queue"` on `POST /prompt`, `/interrupt`, a per-session `text/event-stream` with an
|
||||||
|
`?after=` cursor, `/compact`, `prompt.files`, and `/permission` + `/question`.
|
||||||
|
|
||||||
|
I probed it, saw prompts accepted and never executed, and concluded it was an unfinished pipeline
|
||||||
|
("`session.next.*` is the tell"). Every one of those probes passed an explicit
|
||||||
|
`model: {providerID: 'opencode', id: 'claude-sonnet-4-6'}`.
|
||||||
|
|
||||||
|
**That model silently does not run on the new pipeline.** No error, no event, no assistant message — the
|
||||||
|
prompt is admitted, stored, `prompt.admitted` and `prompted` fire, and nothing else ever happens. Drop
|
||||||
|
the model field and the identical request completes normally.
|
||||||
|
|
||||||
|
So I had one broken variable in every experiment and read the result as a property of the system.
|
||||||
|
|
||||||
|
## What is actually true, measured on 1.18.16 (both machines upgraded 2026-08-10)
|
||||||
|
|
||||||
|
| Claim | Verdict |
|
||||||
|
| ----------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| The new pipeline executes turns | **Yes** — replies normally when no model is forced |
|
||||||
|
| `delivery: "steer"` injects mid-turn | **Yes, verified** — steered a running turn, output changed to order |
|
||||||
|
| `delivery: "queue"` runs after | **Yes, verified** — two replies, "ONE" then "TWO", zero errors |
|
||||||
|
| Model selection works at all | **Yes** — `POST /api/session/{id}/model` → 204, then runs on it |
|
||||||
|
| `claude-sonnet-4-6` works there | **No** — silent no-op, at create *and* via the model route |
|
||||||
|
| `claude-sonnet-4-6` works via `run --dir` | **Yes** — verified end to end the same day |
|
||||||
|
|
||||||
|
Steer and queue are exactly the two features we hand-built for Claude and that Andre called a game
|
||||||
|
changer. Having them as primitives, plus a resumable per-session cursor that mirrors officer's durable
|
||||||
|
replay, is a strong argument for migrating.
|
||||||
|
|
||||||
|
## RESOLVED — it was a missing credential, not a bug
|
||||||
|
|
||||||
|
Andre said he had a paid Zen key working in his terminal and suggested it simply was not set up here. He
|
||||||
|
was right, and this is the second wrong conclusion I reached on this page.
|
||||||
|
|
||||||
|
**The new pipeline has its own credential store, separate from `auth.json`.** `opencode run`, the CLI and
|
||||||
|
the legacy `/session` surface all read `~/.local/share/opencode/auth.json`, which holds the Zen key — so
|
||||||
|
they reach paid models. The `/api/*` surface reads integrations instead (`/api/integration`,
|
||||||
|
`/api/credential`), and ours had **none connected**. With no credential it silently fell back to what
|
||||||
|
needs none, which is exactly the free tier.
|
||||||
|
|
||||||
|
The fix was one call, and it persists across a serve restart (verified — a paid model still ran after
|
||||||
|
`pm2 restart officer-opencode`):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/integration/opencode/connect/key { "key": "<zen key>", "label": "…" } → 204
|
||||||
|
```
|
||||||
|
|
||||||
|
Afterwards `claude-sonnet-4-6` and `claude-haiku-4-5` both run on the new pipeline. **The fork is
|
||||||
|
unblocked**, and everything the table above promises — steer, queue, interrupt, resumable per-session
|
||||||
|
SSE — is available with real models.
|
||||||
|
|
||||||
|
Two consequences worth carrying:
|
||||||
|
|
||||||
|
- **alpha needs the same one-time connect** before it can use the new pipeline.
|
||||||
|
- The sidecar should do this itself at boot rather than relying on someone having run it by hand, since
|
||||||
|
a missing credential degrades to "only free models work" with no error anywhere.
|
||||||
|
|
||||||
|
### What the evidence looked like while I was getting it wrong
|
||||||
|
|
||||||
|
Recorded because the shape repeats: the failure was **silent and total** for paid models, and the cost
|
||||||
|
table drew a perfect line — every cost-0 model ran, every cost>0 model did not. I read that as a billing
|
||||||
|
boundary inside a broken pipeline. It was a billing boundary caused by an absent credential, which is a
|
||||||
|
far more ordinary explanation and one Andre reached from knowing his own setup rather than from the API.
|
||||||
|
|
||||||
|
The tell I had and did not use: the configured default is `opencode/big-pickle`, and a session created
|
||||||
|
with no model ran on `ling-3.0-tiny-free` **instead of the default**. A pipeline ignoring its configured
|
||||||
|
default is a pipeline that cannot use it — that is a credential symptom, and it was sitting in the
|
||||||
|
`/config/providers` output the whole time.
|
||||||
|
|
||||||
|
## The original diagnosis, kept for the record: only free models run
|
||||||
|
|
||||||
|
It is not sonnet, and it is not `variant`. Swept four models through `POST /api/session/{id}/model`
|
||||||
|
followed by a prompt:
|
||||||
|
|
||||||
|
| Model | New pipeline |
|
||||||
|
| -------------------- | ------------ |
|
||||||
|
| `longcat-2.0-free` | **ran** |
|
||||||
|
| `ling-3.0-tiny-free` | **ran** |
|
||||||
|
| `claude-haiku-4-5` | never ran |
|
||||||
|
| `claude-sonnet-4-6` | never ran |
|
||||||
|
| `gpt-5.1-codex-mini` | never ran |
|
||||||
|
|
||||||
|
**Every `-free` model runs; every paid model silently does not.** Ruled out along the way:
|
||||||
|
|
||||||
|
- **Not `variant`.** `claude-sonnet-4-6` advertises `["low","medium","high","max"]` and session create
|
||||||
|
echoes back `variant: "default"`, which is not among them — a promising theory that turned out to be
|
||||||
|
wrong: setting `variant: "high"` explicitly also never ran. Tested rather than assumed, which is the
|
||||||
|
whole lesson of this file.
|
||||||
|
- **Not missing credentials.** `opencode auth list` shows an OpenCode Zen API key in
|
||||||
|
`~/.local/share/opencode/auth.json` plus `ANTHROPIC_API_KEY` in the environment.
|
||||||
|
- **Not the sidecar's environment.** The *same* sidecar process runs `claude-sonnet-4-6` correctly
|
||||||
|
through `opencode run --model`, verified end to end. Same user, same home, same auth file.
|
||||||
|
|
||||||
|
So the new pipeline does not resolve paid-model credentials, and fails **silently** rather than
|
||||||
|
reporting it — while the legacy path and `run` both authenticate fine. Note it *can* surface provider
|
||||||
|
auth errors when it reaches that far: alpha's default (`nano-gpt`) returned a clean
|
||||||
|
`401 missing_api_key`. The silence is specific to opencode-zen paid models.
|
||||||
|
|
||||||
|
This reads as an upstream bug in an in-progress pipeline, not something configurable on our side.
|
||||||
|
|
||||||
|
**Consequence for the fork:** blocked, but precisely. Officer's users pick real models; a harness that
|
||||||
|
works only on free tiers is not adoptable. Re-run the sweep above after each `opencode upgrade` — the
|
||||||
|
day a paid model runs there, the migration is unblocked and worth doing immediately, because steer and
|
||||||
|
queue are already proven.
|
||||||
|
|
||||||
|
## Revised recommendation
|
||||||
|
|
||||||
|
**Take the fork, targeting the new surface, once the model question is answered.** Not the legacy
|
||||||
|
`/session/{id}/message` path — that generates fine but has neither steer nor queue, so it buys streaming
|
||||||
|
at the cost of SSE demux and warm-session lifetime for the two least interesting gaps.
|
||||||
|
|
||||||
|
Until then `opencode run --dir` stays, and it is verified working: `session:init` → `assistant:text` →
|
||||||
|
`result` with cost, on 1.18.16, after the upgrade.
|
||||||
|
|
||||||
|
## The lesson, which is the reason this file keeps its history
|
||||||
|
|
||||||
|
This project has now hit the same trap three times, each time in a different costume:
|
||||||
|
|
||||||
|
- `directory` in the body of `POST /session` — accepted, echoed, ignored. Produced a confident wrong
|
||||||
|
answer about per-request directories.
|
||||||
|
- `location.directory` in the parity doc — a field that did not exist on that surface, which would have
|
||||||
|
compiled and silently produced `''`.
|
||||||
|
- `model` on `POST /api/session` — accepted, echoed back in the response, and fatal to execution.
|
||||||
|
|
||||||
|
**OpenCode's API accepts input it does not honour, and says nothing.** So a probe that changes one thing
|
||||||
|
and sees nothing happen has not learned that the feature is missing; it has learned that *something* is
|
||||||
|
wrong, and the next step is to remove variables, not to conclude. The corrected probes here each changed
|
||||||
|
exactly one field.
|
||||||
|
|
||||||
|
## Findings worth keeping
|
||||||
|
|
||||||
|
- `delivery` defaults to `"steer"` when omitted.
|
||||||
|
- New-surface responses wrap in `{"data": …}`; the legacy surface returns bare objects, so reading
|
||||||
|
`body.id` instead of `body.data.id` silently yields `undefined`.
|
||||||
|
- The new surface is location-scoped per request: `x-opencode-directory` header, or
|
||||||
|
`?location[directory]=` as a `deepObject` query.
|
||||||
|
- `?after=` genuinely replays a finished session's events.
|
||||||
|
- **opencode versions: both machines on 1.18.16** as of 2026-08-10 (Mac was 1.18.11, alpha 1.17.9).
|
||||||
|
Several comments recorded these backwards; corrected. Policy from Andre: **write against the latest
|
||||||
|
version regardless of what alpha happens to run.**
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# OpenCode parity — where it stands, and what to do about it
|
||||||
|
|
||||||
|
> **If you were handed this to implement: do Phase 0 and Phase 1 only.**
|
||||||
|
>
|
||||||
|
> Phase 2 is a decision, not a task — if you reach it, investigate why the serve-based turn path was
|
||||||
|
> replaced and write the answer down. Do not start building it.
|
||||||
|
>
|
||||||
|
> **Before fixing anything, open `/chat` and look for an OpenCode session in the list.** If none appears,
|
||||||
|
> defect B1 is confirmed and this document was written against the current tree. If one does appear, stop
|
||||||
|
> and re-check the whole B-list — it came from read-only surveys and only the event-path claim was
|
||||||
|
> re-verified at source. See "If you are picking this up cold" near the end.
|
||||||
|
|
||||||
|
Written 2026-08-10, from three read-only surveys of the Claude sidecar, the OpenCode sidecar, and every
|
||||||
|
officer/frontend branch on harness. Nothing here has been implemented.
|
||||||
|
|
||||||
|
**The goal is not 100% parity.** OpenCode is a different harness with a different API contract, and some
|
||||||
|
of what Claude Code does has no equivalent. The goal is _as much parity as is worth having_, plus an
|
||||||
|
honest account of what is impossible so nobody re-litigates it in six months. Anything OpenCode can do
|
||||||
|
that Claude cannot is recorded too — that bucket is the one that quietly disappears in a project framed
|
||||||
|
as "catch up".
|
||||||
|
|
||||||
|
**Thinking/effort is deliberately out of scope, for BOTH harnesses** (Andre, 2026-08-10). He has never
|
||||||
|
turned it on, considers the reasoning output noise to the reader, and is happy with results without it.
|
||||||
|
It is currently dead on the Claude path as well — `ThinkingLevel` is accepted on the wire and never
|
||||||
|
forwarded — so the honest move is to hide the control rather than implement it. It is the first task
|
||||||
|
below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one architectural decision everything else depends on
|
||||||
|
|
||||||
|
**OpenCode turns run as a one-shot subprocess; Claude turns run inside a persistent session.**
|
||||||
|
|
||||||
|
`runner.ts:72` spawns `opencode run --format json …` per turn with `stdin: 'ignore'` — literally
|
||||||
|
`/dev/null`. The process exits with the turn, and there is no input channel to a running one. Claude, by
|
||||||
|
contrast, holds one long-lived `query()` per session driven by a streaming-input queue
|
||||||
|
(`claude-manager.ts:140-148`), which is why a message pushed mid-turn reaches the running turn.
|
||||||
|
|
||||||
|
Meanwhile the OpenCode **serve** is running the whole time (`index.ts:102-130`) and is used only for
|
||||||
|
session CRUD and model enumeration. Turns do not go through it.
|
||||||
|
|
||||||
|
So there is a fork, and most of the todo hangs off it:
|
||||||
|
|
||||||
|
- **Keep the subprocess.** Cheap, no rewrite. Permanently forfeits token streaming, mid-turn injection,
|
||||||
|
background tasks, live-session enumeration, and reattach-by-transcript-id.
|
||||||
|
- **Move turns onto the serve's HTTP/SSE API.** Larger, and the thing that makes the rest possible. The
|
||||||
|
dead `client.ts` + `event-mapper.ts` are the skeleton of exactly this design — it existed once and was
|
||||||
|
replaced (`5d077a4` → `71e39b7`). Worth understanding _why_ it was replaced before rebuilding it.
|
||||||
|
|
||||||
|
**Nothing in Phase 2 or beyond is worth starting until that question is answered.** Phases 0 and 1 are
|
||||||
|
worth doing either way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bucket 0 — not parity gaps, just broken
|
||||||
|
|
||||||
|
These are live defects, not missing features. Each makes OpenCode less usable than the code implies.
|
||||||
|
|
||||||
|
| # | Defect | Where | Effect |
|
||||||
|
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| B1 | **No OpenCode session ever appears in the chat list.** The list filters on `metadata.officer.cwd`, and the only writer of that tag (`client.createSession`) has zero callers — the sidecar creates sessions via `opencode run --dir` instead. `cwdOf` always passes a truthy cwd, so the filter rejects everything. | `opencode-sessions.ts:21`, `client.ts:108-118`, `chat.ts:35,49` | The `OpenCode` badge at `SessionList.tsx:197` is unreachable code. |
|
||||||
|
| B2 | **Resuming an OpenCode session dispatches it to the Claude CLI.** `selected.model` is never passed into `NewChat`, so no model reaches the socket, so `DEFAULT_MODEL` (`claude-code`) wins and `isClaudeModel` is true. A `ses_…` id is then handed to `claude --resume`. | `ChatDetailPanel.tsx:236-245` (no `model` prop), `useChat.ts:589`, `websocket.ts:278,283,359` | Wrong-harness dispatch, not degradation. |
|
||||||
|
| B3 | **Resumed OpenCode sessions lose their working directory.** Detail returns `cwd: ''` unconditionally; falsy all the way down to `resolveChatCwd`, which falls back to the default chat dir. | `opencode-sessions.ts:84`, `ChatDetailPanel.tsx:167`, `websocket.ts:88` | Directly contradicts the design note that OpenCode needs the cwd every turn. |
|
||||||
|
| B4 | **Images are offered and silently discarded.** Every OpenCode model is advertised `images: true`; the composer accepts drops and paste; the bubble renders the image — and `handleOpenCodeChat`'s param type omits `images`, so it never leaves officer. | `list-models.ts:44`, `websocket.ts:390-399`, `send-opencode.ts:12-25` | The user sees their image and the model never receives it. |
|
||||||
|
| B5 | **Duplicate subscriptions leak on OpenCode.** Claude guards on `_claudeKill` to avoid opening a second session-scoped subscription; the OpenCode handler has no guard and overwrites the previous handle every turn. | `websocket.ts:441,455-456`, cf. the warning at `:345` | Doubled delivery after any termination that isn't `result`/`error`/`stopped`. |
|
||||||
|
| B6 | **`clearOpenCodeSession` is never called**, so the sessionKey→`ses_…` map grows for the process lifetime and a reused key resumes a stale session. | `opencode/state.ts:13` | Also in-memory only — an officer restart loses every mapping. |
|
||||||
|
| B7 | **Latent spurious `cut-off`.** `resume-cursor` defaults `model` to `claude-code`; `endTurnIfAgentIsGone` then asks the Claude sidecar about a key it never had, gets `false`, and appends a durable "agent went away" row to a live turn. Currently masked only because the client always happens to send `model` alongside `sessionId`. | `websocket.ts:607,621,749-757` | A permanent, reload-surviving false error row. |
|
||||||
|
| B8 | **In-flight `opencode run` children survive sidecar shutdown** and are not tracked, so their output is lost. Separately, `sweepStaleServes` is `/proc`-based and therefore a no-op on macOS — orphaned serves accumulate on this machine. | `index.ts:197-209`, `:41-75` | |
|
||||||
|
|
||||||
|
**Status, 2026-08-10: bucket 0 is CLOSED — B1–B8 are all fixed.**
|
||||||
|
|
||||||
|
B1–B6 in phases 0 and 1 (`22bcd7d`, `492509a`, `013e629`, `7774a25`) — see `docs/opencode-phase1-report.md`.
|
||||||
|
B7 fixed separately, after the review: `decideResume` in `websocket.ts` replaces `msg.model || DEFAULT_MODEL`.
|
||||||
|
|
||||||
|
B7's write-up above understates it. The spurious `cut-off` was the visible half; the same default also
|
||||||
|
sent the session to `adoptOrphanedSession` as a Claude one, which subscribes it to the wrong sidecar's
|
||||||
|
bus (so an OpenCode turn's output never arrives) and pins `session.model`, so stopping it calls
|
||||||
|
`killClaude` on a key that sidecar never held — a stop button that silently does nothing. All three had
|
||||||
|
the one cause, and all three were masked by the client always sending `model`.
|
||||||
|
|
||||||
|
B8 fixed the same day, both halves. `stopAllOpenCodeTurns` on shutdown, settling each turn synchronously
|
||||||
|
so the transcript says why it ended; and a pidfile sweep beside the `/proc` one, which was a no-op on
|
||||||
|
macOS and let orphaned serves accumulate there.
|
||||||
|
|
||||||
|
Also fixed after the review, and not in this table because it was found by reviewing the fix for B5/B6:
|
||||||
|
a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See
|
||||||
|
`docs/opencode-phase1-review.md`.
|
||||||
|
|
||||||
|
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave *wrongly*
|
||||||
|
is gone. What remains is bucket 1 — capabilities Claude has and OpenCode does not — and most of the
|
||||||
|
visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are
|
||||||
|
downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork.
|
||||||
|
|
||||||
|
**The fork is REOPENED, unblocked, and worth taking.** The serve publishes a newer `/api/session/*` surface offering
|
||||||
|
those capabilities natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both
|
||||||
|
verified working** — mid-turn injection and queueing, as primitives, plus `/interrupt` and a resumable
|
||||||
|
per-session event stream. One blocker remains: `claude-sonnet-4-6` silently does not run on that surface
|
||||||
|
(it runs fine under `opencode run`). `docs/opencode-fork-decision.md` has the evidence, the open
|
||||||
|
question, and a correction — an earlier version of that file concluded the opposite because every probe
|
||||||
|
passed that one model.
|
||||||
|
|
||||||
|
Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on
|
||||||
|
1.18.16.
|
||||||
|
|
||||||
|
**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` capability and carries
|
||||||
|
`proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` /
|
||||||
|
`getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode
|
||||||
|
against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar
|
||||||
|
restart stops in-flight turns and writes the reason to `chat_session_events`, and `/chat/live`
|
||||||
|
enumerates what is running.
|
||||||
|
|
||||||
|
**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault`
|
||||||
|
and `opencode` receive no identity and are covered today only because those capabilities are owner-only —
|
||||||
|
"a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses
|
||||||
|
to share at any level, so this cannot be reached by a member. It is latent by construction.
|
||||||
|
|
||||||
|
**`messageCount` is a non-issue, not a gap.** `SessionList.tsx:197-203` renders an `OpenCode` badge in
|
||||||
|
place of the count for OpenCode rows, so the hardcoded `0` is never displayed. Computing a real count
|
||||||
|
would cost one HTTP call per listed session — the session record carries no count field — to populate
|
||||||
|
something nothing renders. Left alone deliberately.
|
||||||
|
|
||||||
|
**Images are done, and they never needed the fork** (bucket 1 lists them as "No — see B4", and Phase 4
|
||||||
|
put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path
|
||||||
|
carries them today: the sidecar spills each image to a temp file for the turn and removes it in
|
||||||
|
`settle`. Verified end to end — a red PNG over the chat socket to `opencode/claude-sonnet-4-6` came back
|
||||||
|
"Red". `list-models` now reports each model's own `capabilities.input.image` instead of a hardcoded
|
||||||
|
`false`, so the composer gate became load-bearing in the right direction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bucket 1 — Claude has it, OpenCode does not
|
||||||
|
|
||||||
|
Ordered roughly by user-visible value.
|
||||||
|
|
||||||
|
| Capability | Claude | OpenCode | Depends on the fork? |
|
||||||
|
| --------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------- |
|
||||||
|
| Token streaming | `delta` events from `stream_event` | **No** — `run` emits complete text parts (`runner.ts:176-177`) | **Yes** |
|
||||||
|
| Mid-turn injection / queue-into-turn | streaming input queue | **No** — `stdin: 'ignore'` | **Yes** |
|
||||||
|
| Transcript id reaching the browser live | `session:claude` → permalink, reattach | Emitted as a routing fact only (`index.ts:153-154`), never a transcript message | Partly |
|
||||||
|
| Reattach by transcript id | `claude:find-session` | **No verb** — `handleAttach` is Claude-only by construction | Partly |
|
||||||
|
| Live-session enumeration (`/chat/live`) | `claude:list` | **No verb** — a running OpenCode turn is invisible in the Live panel | Partly |
|
||||||
|
| Background tasks | `pendingTasks`, `task:started`/`task:notification` | **No** — nothing can arrive after `result` | **Yes** |
|
||||||
|
| Compaction seams | PreCompact hook + `compact_boundary` | **No signal exists** | Unknown |
|
||||||
|
| Interrupt without teardown | `claude:interrupt` keeps the session warm | **No** — stop is a full kill | **Yes** |
|
||||||
|
| Cut-off detection | `endTurnIfAgentIsGone` | Explicitly skipped (`websocket.ts:749`) | No |
|
||||||
|
| Images | content blocks | **No** — see B4 | No |
|
||||||
|
| MCP tools | `--mcp-config` | **Nothing** — no MCP anywhere in the OpenCode path | No |
|
||||||
|
| `messageCount` on the list | from the transcript | hardcoded `0` | No |
|
||||||
|
| Idle GC / warm-session lifetime | 30-min heartbeat, task-aware | N/A — nothing warm to collect | **Yes** |
|
||||||
|
| Crash-recovery state on disk | `claude-state.json` | **not a gap — see below** | No |
|
||||||
|
| Identity | validates `X-Officer-User` | **None** — flagged in `TODO.md:42-47` | No |
|
||||||
|
| Tests | 4 test files on the pure pieces | **Zero** | No |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bucket 2 — neither has it
|
||||||
|
|
||||||
|
- **Thinking/effort.** Accepted on the wire, never forwarded, on both paths. Out of scope by decision;
|
||||||
|
the control should be removed.
|
||||||
|
- **`attachmentIds`.** Declared at `websocket.ts:263` and read by neither handler — file content rides in
|
||||||
|
the prompt prefix instead. Dead for both; worth deleting or wiring.
|
||||||
|
- **`/clear` and `/model` as client-handled slash commands.** The `deliverBatch` comment claims they are;
|
||||||
|
only `/help` is implemented. Stale on both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bucket 3 — structural, unlikely to be worth forcing
|
||||||
|
|
||||||
|
- **`/clear` chain merging, dividers, `partCount`.** Claude's chains are an artefact of how the CLI
|
||||||
|
handles `/clear`; OpenCode has no equivalent concept. The UI already degrades correctly here.
|
||||||
|
- **Per-subagent text attribution.** Claude stamps `parentToolUseId`; nothing in the OpenCode path sets
|
||||||
|
it, so `turn-stream`'s per-speaker buffering collapses to one buffer. Only matters if OpenCode gains
|
||||||
|
subagents.
|
||||||
|
- **The Anthropic OAuth proxy.** Claude-specific by nature — OpenCode has its own auth story.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bucket 4 — OpenCode has it, Claude does not
|
||||||
|
|
||||||
|
Deliberately kept, though nothing here is scheduled. To be filled in as we learn the harness; the survey
|
||||||
|
was scoped to parity and did not go looking. Known so far:
|
||||||
|
|
||||||
|
- **A real HTTP + SSE server, always running**, with session CRUD as a first-class API rather than
|
||||||
|
transcript-file archaeology. Claude's session list is built by scanning and parsing `.jsonl` files off
|
||||||
|
disk; OpenCode's is a `GET /session`. If the turn path moves onto the serve, a lot of the Claude-side
|
||||||
|
file-scanning machinery has no OpenCode equivalent _because it does not need one_.
|
||||||
|
- **Multi-provider models** (`GET /config/providers`) — not tied to one vendor's credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The todo, in order
|
||||||
|
|
||||||
|
Each phase is independently shippable. Nothing here is a big-bang rewrite.
|
||||||
|
|
||||||
|
### Phase 0 — make what exists honest (no architecture decisions needed)
|
||||||
|
|
||||||
|
1. **Hide the thinking selector.** It is the cheapest item here and the most clearly right: the control
|
||||||
|
renders today and does nothing on _either_ harness, because `ThinkingLevel` is accepted on the wire
|
||||||
|
and never forwarded. A control that lies is worse than an absent one. Hide the selector first; the
|
||||||
|
dead plumbing under it (`types.ts:46`, `types.ts:69`, `websocket.ts:262`, the `thinkingLevel` thread
|
||||||
|
through `useChat`/`useEmbeddableChat`) can go in the same change or a follow-up.
|
||||||
|
2. ~~**Fix the session-list filter (B1).**~~ **DONE — `22bcd7d`.**
|
||||||
|
3. **Pass the model through on resume (B2).** Add `model` to `NewChatProps` and thread
|
||||||
|
`selected.model` → `useChat`. One prop, and it stops `ses_…` ids reaching `claude --resume`.
|
||||||
|
4. ~~**Return a real cwd on OpenCode session detail (B3).**~~ **DONE — `22bcd7d`.**
|
||||||
|
|
||||||
|
> **Correction, and read this before trusting any field name below.** This document told you to derive
|
||||||
|
> the directory from `location.directory`. **That field does not exist.** opencode 1.17.9's `GET /session`
|
||||||
|
> returns `directory` at the top level, with no `location` object and no `metadata` at all — so the type
|
||||||
|
> declared two fields the server never sends, which is the single cause of both B1 and B3. `22bcd7d`
|
||||||
|
> found this by reading the live server rather than the type, and deleted `officerMeta` and the metadata
|
||||||
|
> tag outright rather than fixing them: the only writer of that tag has no callers, and tagging would
|
||||||
|
> have been a second source of truth for something `directory` already answers.
|
||||||
|
>
|
||||||
|
> Two lessons for whoever picks up the rest. The surveys behind this document read types and call sites,
|
||||||
|
> not a running server, so **every field name here is a hypothesis** — the "check the installed version"
|
||||||
|
> warning was not boilerplate. And the confirmation that matters is the empirical one: 7 sessions present,
|
||||||
|
> 0 returned, badge unreachable; now 1 listed under the default dir and 6 filtered to their own. 5. **Stop advertising images on OpenCode models (B4)** — flip `list-models.ts:44` to `false` — _or_ plumb
|
||||||
|
> images through `OpenCodeRunParams`. Flipping the flag is the honest one-liner; plumbing is Phase 3. 6. **Guard the OpenCode subscription like the Claude one (B5)**, and call `clearOpenCodeSession` on
|
||||||
|
> disconnect (B6).
|
||||||
|
|
||||||
|
### Phase 1 — delete what is dead
|
||||||
|
|
||||||
|
7. **Remove `event-mapper.ts` entirely**, plus the unused SSE machinery, `createSession`, `postMessage`,
|
||||||
|
`abort`, `isServerHealthy`. Roughly 200 of ~390 platform-side lines. A prior audit
|
||||||
|
(`docs/sidecar-audit-2026-07.md`) already flagged this.
|
||||||
|
**Read it before deleting** — it is the skeleton of the serve-based design Phase 2 may rebuild, so it
|
||||||
|
may be worth reading into a design note first and deleting after.
|
||||||
|
8. **Correct the stale comments** — the "all sessions live in one project" claim, the `opencode-sidecar`
|
||||||
|
vs `opencode_server` path names, the AGENTS.md instruction to read the cwd from a system prompt that
|
||||||
|
is never sent.
|
||||||
|
9. **Add the first tests.** `runner.ts`'s NDJSON→ChatEvent mapping is pure and currently untested, and it
|
||||||
|
is the piece most likely to break against a new OpenCode release. Both files pin behaviour to
|
||||||
|
"verified against opencode 1.17.9" with nothing enforcing it.
|
||||||
|
|
||||||
|
### Phase 2 — the fork
|
||||||
|
|
||||||
|
10. **Decide: subprocess or serve.** Investigate why the serve-based turn path was replaced
|
||||||
|
(`5d077a4` → `71e39b7`) before rebuilding it. Write the answer down either way — this decision
|
||||||
|
determines whether items 11-15 are possible at all.
|
||||||
|
|
||||||
|
### Phase 3 — parity that follows from the fork (serve path only)
|
||||||
|
|
||||||
|
11. Token streaming (`delta` events).
|
||||||
|
12. Emit the `ses_…` id as a transcript-level session message → permalink, refresh survival, reattach.
|
||||||
|
13. `opencode:find-session` + `opencode:list` verbs → reattach-by-id and the Live panel.
|
||||||
|
14. Persistent session → interrupt-without-teardown, idle GC, and **mid-turn injection**.
|
||||||
|
15. Background tasks, if OpenCode has an equivalent concept at all.
|
||||||
|
|
||||||
|
### Phase 4 — the rest
|
||||||
|
|
||||||
|
16. Images (if not done as a Phase 0 flag-flip).
|
||||||
|
17. MCP tools.
|
||||||
|
18. Identity on the sidecar connection (`TODO.md:42-47`).
|
||||||
|
19. Real `messageCount` and model metadata rather than hardcoded defaults.
|
||||||
|
20. Crash-recovery state on disk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If you are picking this up cold
|
||||||
|
|
||||||
|
Phases 0 and 1 are ready to implement as written. Phase 2 is a **decision**, not a task — do not start it
|
||||||
|
as work.
|
||||||
|
|
||||||
|
**Reproduce each defect before fixing it.** The B-list came from read-only surveys, and one of those
|
||||||
|
surveys reasoned from a dead file for part of its report (see below). Only the event-path claim was
|
||||||
|
re-verified at source. Every B item names its files and lines; open them and confirm the defect is real
|
||||||
|
and still present before changing anything. A "fix" to something that was never broken is worse than the
|
||||||
|
defect, because the next reader will trust it.
|
||||||
|
|
||||||
|
Two that are worth extra care:
|
||||||
|
|
||||||
|
- **B2** is a six-hop chain from `ChatDetailPanel` down to `isClaudeModel`. Confirm the whole chain
|
||||||
|
rather than the endpoints — it is the kind of claim that is right in outline and wrong about which hop
|
||||||
|
drops the value.
|
||||||
|
- **B1** implies OpenCode sessions are invisible in the UI today. That is trivially checkable by opening
|
||||||
|
`/chat` and looking. Do that first: it either confirms the whole B-list's provenance in one glance, or
|
||||||
|
tells you the surveys were working from a stale tree.
|
||||||
|
|
||||||
|
This machine and the home-lab server run the same repo but not necessarily the same OpenCode binary.
|
||||||
|
Both `runner.ts:38` and `client.ts:170` pin behaviour to "verified against opencode 1.17.9" with nothing
|
||||||
|
enforcing it, so check the installed version before trusting any NDJSON shape in here.
|
||||||
|
|
||||||
|
## Two things to verify before trusting any of this
|
||||||
|
|
||||||
|
- The surveys initially disagreed about which file is the live event path. **`runner.ts:174-214` is
|
||||||
|
authoritative**; `event-mapper.ts` is dead. Any claim sourced from the mapper — particularly that
|
||||||
|
OpenCode emits `delta` — is wrong. The live path emits whole `text` blocks.
|
||||||
|
- `docs/sidecar-audit-2026-07.md` predates this and overlaps it. Where they disagree, this document was
|
||||||
|
written against the current tree and the audit was not.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Phase 0 review, and what the cwd tests found
|
||||||
|
|
||||||
|
Review of `22bcd7d`, `492509a`, `013e629` against `docs/opencode-parity.md`, plus two empirical tests of
|
||||||
|
the OpenCode cwd behaviour that change what Phase 1 and 2 should be.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — accepted, with one item to reopen
|
||||||
|
|
||||||
|
**B1, B2, B3 — good, and better than the spec.** Each was reproduced against a running server before
|
||||||
|
being changed, which is what the handover asked for and is what caught the document being wrong:
|
||||||
|
`location.directory` does not exist in opencode 1.17.9+, which returns `directory` at the top level with
|
||||||
|
no `location` and no `metadata`. Deleting `officerMeta` and the metadata tag rather than fixing them was
|
||||||
|
the right call — the only writer had no callers, and tagging would have been a second source of truth for
|
||||||
|
something `directory` already answers.
|
||||||
|
|
||||||
|
The B2 write-up is a model of what a fix note should be: the six-hop chain confirmed rather than
|
||||||
|
inferred, and the finding that the value was _produced correctly and read from the wrong place_ rather
|
||||||
|
than never produced.
|
||||||
|
|
||||||
|
**B5 — correct, including the part where it deliberately does not copy Claude.** A fresh subscription per
|
||||||
|
turn IS right for OpenCode, because a fresh `opencode run` is spawned per turn; the defect was
|
||||||
|
overwriting the old handle without detaching it. Mirroring Claude's `_claudeKill` guard would have been
|
||||||
|
wrong, and the commit says so.
|
||||||
|
|
||||||
|
**B6 — correct, and the restraint is the good part.** Clearing the `ses_…` map in `deleteSession` only,
|
||||||
|
never in `releaseSession`, with the reason stated: releasing means "let go, leave it running", so a
|
||||||
|
returning browser must be able to find the same `ses_…` again. Clearing it there would have quietly
|
||||||
|
orphaned every released OpenCode session.
|
||||||
|
|
||||||
|
**Thinking selector — good.** Removing the control rather than fixing it, and leaving the inert plumbing
|
||||||
|
for a follow-up that touches the socket contract, is the right split.
|
||||||
|
|
||||||
|
### B4 — reopen: the flag is now honest but inert
|
||||||
|
|
||||||
|
`013e629` flipped `images: true` → `false` for OpenCode models, and the commit says "61 OpenCode models
|
||||||
|
now decline, the three Claude ones still accept".
|
||||||
|
|
||||||
|
**Nothing declines.** No code in `src/workspaces` or `src/apps` reads that capability — the composer's
|
||||||
|
image affordances are ungated. Grep for a consumer of the model's `images` field returns nothing:
|
||||||
|
`InputArea`'s drop zone, the paste handler, and `AttachButton` all accept images regardless of model, and
|
||||||
|
`useAttachments` collects them regardless.
|
||||||
|
|
||||||
|
So the observable defect is unchanged: on an OpenCode chat you can still drop an image, watch it render
|
||||||
|
in your own bubble, and have it discarded before the model sees it. The metadata is more truthful now,
|
||||||
|
which is worth having, but B4 described a user-visible lie and that lie is still on screen.
|
||||||
|
|
||||||
|
Two ways to close it, and they are not equivalent:
|
||||||
|
|
||||||
|
1. **Gate the composer on the capability.** Read the selected model's `images` flag and hide the drop
|
||||||
|
zone, the paste path and the attach-image button when it is false. Cheap. Makes the flag load-bearing,
|
||||||
|
so the flip in `013e629` starts doing something.
|
||||||
|
2. **Plumb images through `OpenCodeRunParams`** (currently Phase 4). Removes the limitation rather than
|
||||||
|
surfacing it.
|
||||||
|
|
||||||
|
(1) is the honest one-liner Phase 0 was for; (2) is the real fix. Doing (1) now costs nothing if (2)
|
||||||
|
happens later — the gate simply stops firing once the capability is true.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the cwd tests found — this changes Phase 1 and 2
|
||||||
|
|
||||||
|
Andre's memory of why OpenCode was shelved: everything ran through one `opencode serve`, which anchored
|
||||||
|
every call to the directory that server was started in. That broke listing sessions by cwd and forced the
|
||||||
|
injected `AGENTS.md` telling the agent what its working directory was supposed to be.
|
||||||
|
|
||||||
|
**That constraint is gone, and it went away when turns moved off the serve.** Turns are now
|
||||||
|
`opencode run --dir <cwd>` subprocesses (`runner.ts:65`). Tested against the installed binary
|
||||||
|
(**1.18.11**; our comments still pin 1.17.9):
|
||||||
|
|
||||||
|
**Test 1 — does `--dir` anchor file operations, or only the process cwd?** Ran a turn in a scratch
|
||||||
|
directory containing two marker files. The agent's `ls` returned exactly those two files and nothing from
|
||||||
|
the repo. **`--dir` anchors the agent.**
|
||||||
|
|
||||||
|
**Test 2 — does the anchor survive a multi-step turn?** Four steps: `pwd`, write a file, `pwd` again,
|
||||||
|
`ls`. Both `pwd`s returned `/private/tmp/oc-cwd-test`, the file landed there, and the platform repo was
|
||||||
|
untouched. **No mid-turn drift.**
|
||||||
|
|
||||||
|
One cosmetic residue: each run ends with `Shell cwd was reset to <the platform repo>` — some global
|
||||||
|
notion of "the project" that is neither the invoking directory nor `--dir`. It prints after the process
|
||||||
|
finishes and had no effect on either test. Worth knowing it exists; not worth chasing.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
- **Do not build a server per directory.** It was a sound response to the old architecture and is now
|
||||||
|
unnecessary — N processes, N ports and a lifecycle to manage, to solve what `--dir` already solves.
|
||||||
|
- **The injected `AGENTS.md` is safe to delete.** `index.ts:22-35` seeds a file telling the agent to read
|
||||||
|
"Working directory for this session" from the system prompt; the run path sends no system prompt, so
|
||||||
|
that instruction points at nothing. `--dir` is the real anchor and it works. This is the "very nasty
|
||||||
|
hack" — it can go, and nothing needs to replace it.
|
||||||
|
- **The stale comment goes with it.** `opencode-sessions.ts:6-9` still claims all sessions live in one
|
||||||
|
server's project. They do not, and the listing now proves it: one serve returned 7 sessions across
|
||||||
|
several directories.
|
||||||
|
- **Phase 2's fork is narrower than the doc implies.** Moving turns onto the serve would reintroduce
|
||||||
|
exactly the single-directory coupling that caused the original pain, unless the serve's API can take a
|
||||||
|
per-request directory. That question — not "why was it replaced" — is now the one to answer first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested next work, in order
|
||||||
|
|
||||||
|
1. **B4 properly** — gate the composer on the model's `images` capability (above).
|
||||||
|
2. **Delete the `AGENTS.md` injection and the stale one-project comment**, now that `--dir` is verified.
|
||||||
|
This is Phase 1 work and it is the thing Andre most wanted gone.
|
||||||
|
3. **Then the rest of Phase 1** — the dead `event-mapper.ts` and SSE machinery, the wrong path names in
|
||||||
|
comments, and the first tests over `runner.ts`'s NDJSON mapping, which is pure, untested, and pinned to
|
||||||
|
a version two minor releases behind what is installed.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Phases 0 and 1: implementation report, and Phase 2's question answered
|
||||||
|
|
||||||
|
**To:** whoever wrote `opencode-parity.md` and `opencode-phase0-review.md`.
|
||||||
|
**From:** the implementation pass, 2026-08-10 (overnight).
|
||||||
|
**Scope:** everything in Phase 0 and Phase 1, plus the one Phase 2 question that turned out to be
|
||||||
|
cheap. Nothing of the Phase 2 migration was started.
|
||||||
|
|
||||||
|
Both documents were good to work from. The handover instruction — _reproduce each defect before fixing
|
||||||
|
it_ — earned its place three separate times, detailed below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
| Commit | Work |
|
||||||
|
| --------- | --------------------------------------------------------------- |
|
||||||
|
| `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory |
|
||||||
|
| `492509a` | B2 — route a resumed OpenCode session to OpenCode |
|
||||||
|
| `013e629` | B4 (first attempt), B5, B6, thinking selector |
|
||||||
|
| `7774a25` | B4 properly — gate the composer on the capability |
|
||||||
|
| `cfbf58c` | Delete the `AGENTS.md` injection + the one-project comment |
|
||||||
|
| `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` |
|
||||||
|
| `8b409e8` | Phase 1 finish — stale comments, version pin, first tests |
|
||||||
|
| `e8bd946` | `opencode:list` — running OpenCode turns in the Live panel |
|
||||||
|
|
||||||
|
Suite 562 → 573 tests, all passing. `tsgo` clean throughout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: the blocking question is answered
|
||||||
|
|
||||||
|
`opencode-phase0-review.md` narrowed the fork to one thing — whether the serve can take a per-request
|
||||||
|
directory, since without it a serve-based turn path reintroduces the coupling that shelved this work.
|
||||||
|
|
||||||
|
**It can.** Against the running serve (1.17.9 on the home-lab box):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /session?directory=/tmp/oc-phase2-probe → directory: "/tmp/oc-phase2-probe" honoured
|
||||||
|
POST /session with directory in the BODY → directory: "<serve cwd>" ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
`directory` is a **query parameter on every `/session*` route** — create, message, abort, fork,
|
||||||
|
summarize, prompt_async, shell — alongside `workspace`. Read off the serve's own `/doc`.
|
||||||
|
|
||||||
|
**Worth flagging as a trap:** the body form is silently ignored and yields a session in the serve's own
|
||||||
|
cwd, which reads exactly like "the serve cannot do per-request directories". My first probe did this and
|
||||||
|
gave a confident wrong answer. Anyone re-checking this should use the query string.
|
||||||
|
|
||||||
|
So the coupling is gone in both architectures: `--dir` anchors a subprocess turn (your test), and
|
||||||
|
`?directory=` anchors a serve turn (this one). `docs/opencode-serve-path.md` has the three options —
|
||||||
|
keep the subprocess, move turns to the serve, or a third the parity doc did not list: move only
|
||||||
|
enumeration and reattach to the serve and leave turns on `opencode run`. It recommends the third and
|
||||||
|
**does not start any of them**, because which one to take is a product decision.
|
||||||
|
|
||||||
|
The first slice of that third option is already in (`e8bd946`, below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where your documents were wrong, and how
|
||||||
|
|
||||||
|
Three corrections. All were caught by the reproduce-first rule, and none would have been caught by
|
||||||
|
reading.
|
||||||
|
|
||||||
|
**1. `location.directory` does not exist** (parity doc B1/B3, and its suggested fix). opencode 1.17.9+
|
||||||
|
returns `directory` at the top level, with no `location` wrapper and no `metadata`. The suggested fix —
|
||||||
|
"derive the directory from `location.directory`" — would have compiled and silently produced `''`
|
||||||
|
forever. You have since corrected this in `29d9912`.
|
||||||
|
|
||||||
|
**2. B2 is right in outline, and the interesting part is not in the outline.** The doc says
|
||||||
|
`selected.model` is never passed. In fact it _is_ produced correctly — `/chat/:id` resolves
|
||||||
|
`detail.model` to `opencode/big-pickle` and puts it on `selected` — and then dropped at the `<NewChat>`
|
||||||
|
boundary, while `initialModel` reads `locationState?.model`, a field **nothing in the tree ever writes**.
|
||||||
|
So the value existed and was read from the wrong place. Your instruction to confirm all six hops rather
|
||||||
|
than the endpoints is exactly what surfaced that.
|
||||||
|
|
||||||
|
**3. `runner.ts`'s mapping was untested but not pure** (Phase 1 item 9). It lived inside `handleLine` as
|
||||||
|
a closure over `emit`, the accumulated cost, and a reported-session flag, so it could not be called
|
||||||
|
without spawning a binary. Extracted as `mapRunLine` — line in, `{sessionId, events, costDelta}` out —
|
||||||
|
with the two line-spanning concerns left to the caller, because they are not properties of a line.
|
||||||
|
Behaviour unchanged; 11 tests now pin it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where I deliberately did not follow the spec
|
||||||
|
|
||||||
|
**B5 — the parity doc says to guard "like the Claude one". Copying that guard would have broken
|
||||||
|
OpenCode.** Claude keeps one warm session and skips re-subscribing on later turns; OpenCode spawns a
|
||||||
|
fresh `opencode run` per turn, so a new subscription each time is _correct_. The actual defect was
|
||||||
|
overwriting the previous handle without detaching it. Fixed that instead. (Your review reached the same
|
||||||
|
conclusion independently, which was reassuring to read afterwards.)
|
||||||
|
|
||||||
|
**B4 — closed the way your review asked, not the way the parity doc did.** The doc offered the flag flip
|
||||||
|
as "the honest one-liner"; you correctly pointed out that flipping it changed nothing observable because
|
||||||
|
no code read the capability. The composer now gates on it — drop zone, paste path, attach menu — so the
|
||||||
|
flag is load-bearing. Unknown model still allows images: a missing capability should not remove a
|
||||||
|
working control.
|
||||||
|
|
||||||
|
**Thinking selector — removed, not hidden.** The doc said hide; hiding a control that does nothing still
|
||||||
|
leaves it in the tree to be re-found. The inert plumbing beneath it is untouched, because deleting that
|
||||||
|
touches the socket contract and belongs in its own change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `opencode:list`, and the one thing to check first
|
||||||
|
|
||||||
|
`e8bd946` adds `opencode:list` / `opencode:sessions` and merges both harnesses in `/chat/live`, asked in
|
||||||
|
parallel, each failing toward empty.
|
||||||
|
|
||||||
|
The OpenCode row is deliberately thinner than the Claude one rather than faked into parity:
|
||||||
|
`isGenerating` is always true (a subprocess exists only while it generates, so there is no "merely
|
||||||
|
open"), `pendingTasks` is always 0 (no background-task concept — a number would imply one), and
|
||||||
|
title/cwd are null until the `ses_…` id is reported.
|
||||||
|
|
||||||
|
**It has never returned a non-empty list.** No OpenCode turn was running to enumerate. Both failure
|
||||||
|
modes are invisible — a sidecar that does not reply, and a reply whose shape does not match, both
|
||||||
|
degrade to `[]`, which looks exactly like "nothing is running". **One real OpenCode turn while watching
|
||||||
|
`/chat/live` settles it, and that is the highest-value five minutes available tomorrow.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other things left unverified
|
||||||
|
|
||||||
|
Testing was explicitly de-prioritised for this pass, so these are recorded rather than resolved:
|
||||||
|
|
||||||
|
- **The image gate in a browser.** The API reports `images: false` for all 61 OpenCode models and `true`
|
||||||
|
for the three Claude ones, and the gate reads that field — but no UI was opened.
|
||||||
|
- **`mapRunLine` fixtures are hand-written**, not captured from a live turn. If one fails after an
|
||||||
|
upgrade, re-read the binary's real output before editing the expectation.
|
||||||
|
- **No OpenCode turn was run after deleting `AGENTS.md`.** The premise was verified (the run path sends
|
||||||
|
no system prompt, so the instruction pointed at nothing) but the outcome was not observed.
|
||||||
|
|
||||||
|
`COMMS/BLOCKERS.md` has the same list plus the mistakes I made getting here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested next, if you are writing the following spec
|
||||||
|
|
||||||
|
1. **Exercise `opencode:list`** — it is the only new capability whose happy path is unproven.
|
||||||
|
2. **Decide the fork.** The blocker is gone; `opencode-serve-path.md` frames it. If the answer is "not
|
||||||
|
yet", say so in the parity doc so it stops reading as pending work.
|
||||||
|
3. **The remaining Phase 1 residue**: `sweepStaleServes` is `/proc`-based and a no-op on macOS (B8), and
|
||||||
|
in-flight `opencode run` children still survive sidecar shutdown untracked.
|
||||||
|
4. **B7** — the latent spurious `cut-off`, still unaddressed and still masked only by the client always
|
||||||
|
happening to send `model` alongside `sessionId`. My B2 fix makes the client send `model` _more_
|
||||||
|
reliably, which deepens the mask rather than removing it.
|
||||||
|
|
||||||
|
Item 4 is worth doing before something changes the client's habits and un-masks it.
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# Phase 1 review, and one bug the Live panel work made load-bearing
|
||||||
|
|
||||||
|
> **Status: everything below is now FIXED — do not re-fix it.** Andre asked me to take it while you were
|
||||||
|
> on another matter. The supersede defect, both comment corrections, and two related leaks found while
|
||||||
|
> fixing it landed after this review was written; see "What was actually done" at the end for the diff
|
||||||
|
> you are inheriting. The review text is left exactly as first written so the reasoning is still readable.
|
||||||
|
|
||||||
|
Review of `cfbf58c`, `d7b2231`, `8b409e8`, `e8bd946`, `1402880` against `docs/opencode-parity.md` and
|
||||||
|
`docs/opencode-phase1-report.md`.
|
||||||
|
|
||||||
|
**Verdict: Phase 1 accepted. Phase 2's answer accepted, and the recommendation is the right one.** One
|
||||||
|
real defect, reproduced below, plus two comments that describe behaviour the code does not have.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accepted, and why
|
||||||
|
|
||||||
|
**The `AGENTS.md` deletion is complete.** Not just the seeding code — the file itself is gone from
|
||||||
|
`DATA_PATH/opencode_server/` on alpha, which the code could not have done and which a lesser pass would
|
||||||
|
have left behind for the next reader to find and wonder about.
|
||||||
|
|
||||||
|
**The dead serve-turn deletion is the model for how to delete things.** `5d077a4` recorded as the last
|
||||||
|
commit where the path was live, `isServerHealthy`'s removal annotated with what it did, that it *worked*,
|
||||||
|
and which route to prefer if a health check is ever wanted back. Nothing here needs archaeology later.
|
||||||
|
|
||||||
|
**`mapRunLine` is a faithful extraction.** Checked branch by branch against the original `handleLine`,
|
||||||
|
including the two easy things to get wrong: cost accrues only on `step_finish` (every other line returns
|
||||||
|
a zero delta, so unconditional accumulation is equivalent), and the early `tool_use` bail on a missing
|
||||||
|
`callID` still contributes nothing. The 11 tests are real ones — NaN-vs-missing-tokens, empty text parts,
|
||||||
|
non-JSON interleaving, an unknown future event type.
|
||||||
|
|
||||||
|
**Phase 2's blocker is genuinely answered**, and the trap is the valuable half: the body form of
|
||||||
|
`directory` being silently ignored yields a session in the serve's own cwd, which reads exactly like "the
|
||||||
|
serve cannot do per-request directories". That is a wrong answer anyone re-checking this would have
|
||||||
|
reached too. Recording it cost a line and saves the next person a day.
|
||||||
|
|
||||||
|
**Verified independently:** `tsgo` clean. 573 tests, 571 passing — the two failures are host-local
|
||||||
|
(`cliamp not found on host`, and the pty test timing out waiting for a real shell), unrelated to this
|
||||||
|
work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The defect: a superseded turn takes the live turn down with it
|
||||||
|
|
||||||
|
`runOpenCodeTurn` supersedes a lingering turn for the same `sessionKey` (`runner.ts:59-67`) by killing the
|
||||||
|
process and dropping it from `running`. It does not mark the handle. So when that process actually dies,
|
||||||
|
its own `proc.exited` closure still runs — with `done === false` and `killedByUser === false` — and calls
|
||||||
|
`finish(...)`, which now acts on **the turn that replaced it**.
|
||||||
|
|
||||||
|
Reproduced with a stub binary in place of `opencode` (a script that sleeps), two turns on one
|
||||||
|
`sessionKey`:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- turn 1 starts ---
|
||||||
|
running: [{"sessionKey":"sess-A"}]
|
||||||
|
--- turn 2 supersedes it ---
|
||||||
|
running immediately after: [{"sessionKey":"sess-A"}]
|
||||||
|
EMIT: {"type":"opencode:event","sessionKey":"sess-A",
|
||||||
|
"event":{"type":"error","message":"OpenCode exited with code 143"}}
|
||||||
|
--- after turn 1 is reaped, turn 2 still generating ---
|
||||||
|
running: []
|
||||||
|
stop button reached a process? false
|
||||||
|
orphan still alive: /bin/sh fake-opencode run --format json … --dir /tmp/oc-probe two
|
||||||
|
```
|
||||||
|
|
||||||
|
Four consequences, worst first:
|
||||||
|
|
||||||
|
1. **A false error is committed to the transcript.** The emit goes through
|
||||||
|
`sessionLog.push(sessionKey, event, durable)` in `index.ts:139-143`, so `OpenCode exited with code 143`
|
||||||
|
lands in `chat_session_events` against a session that is generating normally, and replays on every
|
||||||
|
reload. This is not a transient UI artefact.
|
||||||
|
2. **`listRunningOpenCodeTurns` goes blind.** `finish` calls `running.delete(sessionKey)` unconditionally,
|
||||||
|
removing the *new* handle. The Live panel added in `e8bd946` therefore omits exactly the turn it
|
||||||
|
exists to show.
|
||||||
|
3. **The stop button dies.** `killOpenCodeTurn` finds nothing in `running` and returns silently.
|
||||||
|
4. **The process orphans.** No handle means the next turn's supersede cannot kill it either.
|
||||||
|
|
||||||
|
The bug predates this pass — it is a property of the supersede path, not of anything in these commits.
|
||||||
|
It is reported here because `e8bd946` is what made `running`'s accuracy load-bearing: before the Live
|
||||||
|
panel, entries 2 and 3 were invisible.
|
||||||
|
|
||||||
|
**Suggested fix**, both halves needed:
|
||||||
|
|
||||||
|
- Mark the handle in the supersede branch (`stale.superseded = true`), and have `proc.exited` return
|
||||||
|
early on it — no `finish`, no emit, no delete. A turn the system replaced on purpose is not an error.
|
||||||
|
- Make the delete identity-checked regardless: `if (running.get(sessionKey) === handle) running.delete(...)`.
|
||||||
|
Cheap, and it closes the whole family rather than this one path.
|
||||||
|
|
||||||
|
**Worth reproducing before fixing**, in your own words back to me: the stub-binary trick above is enough,
|
||||||
|
no real `opencode` needed. `RunnerConfig.bin` is the only injection point required.
|
||||||
|
|
||||||
|
### The related gap
|
||||||
|
|
||||||
|
The extraction moved the pure mapping under test and left the caller holding the two line-spanning
|
||||||
|
concerns — emit-the-session-id-once, and cost accumulation. That split is correct. But those two are now
|
||||||
|
the *only* untested logic on the path, and this defect lives in that same untested caller. Worth one test
|
||||||
|
over `runOpenCodeTurn`'s lifecycle (stub binary, two turns, assert `running` and the emitted events)
|
||||||
|
rather than more tests over `mapRunLine`, which is well covered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two comments that outrun the code
|
||||||
|
|
||||||
|
**1. `/chat/live` describes a lookup that does not happen** (`chat.ts`, the OpenCode block). The comment
|
||||||
|
says title and cwd "come from the session store, which is keyed on the `ses_…` id the runner reports …
|
||||||
|
so a turn whose id has not been reported yet shows unnamed rather than guessing." Nothing is looked up —
|
||||||
|
both fields are literal `null`, and `LiveOpenCodeSession` carries only `sessionKey`, so there is no id to
|
||||||
|
look one up with. They are null permanently, not until-reported.
|
||||||
|
|
||||||
|
The report repeats this as "title/cwd are null until the `ses_…` id is reported". No mechanism exists to
|
||||||
|
change them. Either say so plainly, or plumb the id into `LiveOpenCodeSession` and do the lookup — but
|
||||||
|
the comment should not describe the second while the code does the first.
|
||||||
|
|
||||||
|
**2. `protocol.ts` — the new type landed inside another type's docblock.** `LiveOpenCodeSession` was
|
||||||
|
inserted between `LiveClaudeSession`'s comment and `LiveClaudeSession` itself. That comment (about
|
||||||
|
`isGenerating`, `pendingTasks`, and what the idle GC consults) now reads as documentation for the
|
||||||
|
OpenCode type, where it is not merely wrong but directly contradicted by the correct comment immediately
|
||||||
|
below it. Cosmetic anywhere else; this file is the wire contract between the server and every sidecar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## On `opencode:list` being unexercised
|
||||||
|
|
||||||
|
Your own flag — it has never returned a non-empty list — is the right thing to have flagged, and it
|
||||||
|
compounds with the defect above: the first time it *is* exercised with two messages on one session, it
|
||||||
|
will return `[]` for a turn that is plainly running, and that will look like the enumeration being broken
|
||||||
|
rather than the supersede path deleting the handle. Fix the supersede first, then exercise it; otherwise
|
||||||
|
the five minutes of testing produces a misleading result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agreed next, unchanged from your list
|
||||||
|
|
||||||
|
Your four suggestions are the right four. Ordering note only: **item 4 (B7's latent spurious `cut-off`)
|
||||||
|
before the fork decision**, for the reason you gave yourself — your B2 fix deepens the mask, and a latent
|
||||||
|
bug that is getting better hidden is the one to take while it is still findable.
|
||||||
|
|
||||||
|
The fork itself: agreed, do not start it, and agreed the third option is the one to take if it is taken.
|
||||||
|
Andre makes that call, not either of us.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What was actually done
|
||||||
|
|
||||||
|
Written after the fact. Andre asked me to implement this rather than hand it back, so you are inheriting
|
||||||
|
a fixed tree, not a task list.
|
||||||
|
|
||||||
|
**Reproduced first, as asked of you.** `runner.test.ts` grew a lifecycle block that needs no real
|
||||||
|
`opencode`: `RunnerConfig.bin` points at a shell script that sleeps, which stands in perfectly for a turn
|
||||||
|
that is still generating. The supersede test failed with exactly the predicted
|
||||||
|
`OpenCode exited with code 143` before any fix went in.
|
||||||
|
|
||||||
|
**The fix, in `runner.ts`:**
|
||||||
|
|
||||||
|
- `RunHandle` gained `superseded`, set in the supersede branch *before* the kill.
|
||||||
|
- `finish` became `settle(event | null)`. `null` retires a turn silently. The delete is now
|
||||||
|
identity-checked — `if (running.get(sessionKey) === handle)` — because a superseded turn no longer owns
|
||||||
|
that key.
|
||||||
|
- `proc.exited` returns through `settle(null)` for a superseded handle: no result, no error, no emit.
|
||||||
|
|
||||||
|
**Two further leaks in the same family, found while fixing it and not in the original review:**
|
||||||
|
|
||||||
|
1. **An early return would not have been enough.** Both watchdogs call `finish`, so a superseded turn
|
||||||
|
that simply returned early would leave an armed 10-minute `hardTimer` to fire an error at whichever
|
||||||
|
turn held the key by then — the identical cross-talk, delayed past the point anyone would connect it
|
||||||
|
to a supersede. `settle(null)` clears the timers, which is why the fix retires rather than ignores.
|
||||||
|
2. **Buffered stdout outlived the turn.** `handleLine` had no `done` guard, so lines still draining from
|
||||||
|
a killed process were emitted under a sessionKey that now belonged to its replacement — interleaving
|
||||||
|
one turn's output into another's. Guarded.
|
||||||
|
|
||||||
|
**The control test matters as much as the failing one.** `still reports a turn that dies on its own`
|
||||||
|
pins that the guard did not overreach: an ordinary non-zero exit still produces an error event. It uses
|
||||||
|
its own stub rather than `/bin/false`, which is `/usr/bin/false` on macOS — and note that `Bun.spawn`
|
||||||
|
*throws* on a missing binary rather than emitting, so a bad `OPENCODE_BIN` currently escapes
|
||||||
|
`runOpenCodeTurn` synchronously into the command handler. Left alone: it is a real edge, but it is not
|
||||||
|
this bug and it deserves its own change.
|
||||||
|
|
||||||
|
**Both comments corrected.** `LiveOpenCodeSession` moved below `LiveClaudeSession` so the docblock
|
||||||
|
documents the type it describes again, and it now states plainly that it carries no `ses_…` id — which
|
||||||
|
is *why* the `/chat/live` rows are permanently unnamed. `chat.ts` says "always null, not
|
||||||
|
null-until-known" and names what widening the type would buy.
|
||||||
|
|
||||||
|
**Verified:** `tsgo` clean; 575 tests, 573 passing. The two failures are the same host-local pair as
|
||||||
|
before (`cliamp not found on host`, pty test timing out) and are unrelated.
|
||||||
|
|
||||||
|
**Still yours, unchanged:** exercising `opencode:list` against a real turn — now worth doing, since the
|
||||||
|
defect that would have made it lie is gone — plus B7, `sweepStaleServes` on macOS, and untracked
|
||||||
|
`opencode run` children surviving sidecar shutdown.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Moving OpenCode turns onto the serve — the plan
|
||||||
|
|
||||||
|
Written 2026-08-10, after the fork was unblocked (`docs/opencode-fork-decision.md`). **Nothing here is
|
||||||
|
implemented.** It exists so the work can start from verified facts rather than from the API docs, which
|
||||||
|
have been wrong or misleading three times on this path.
|
||||||
|
|
||||||
|
Andre should read "What changes for the user" and "The risk I would not take blind" before this starts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What we are moving from and to
|
||||||
|
|
||||||
|
Today every turn is `opencode run --dir <cwd> --format json`, a subprocess with `stdin: 'ignore'`. It
|
||||||
|
works, it is verified end to end, and its limits are all consequences of that one closed pipe.
|
||||||
|
|
||||||
|
The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16:
|
||||||
|
|
||||||
|
| Capability | How | Verified |
|
||||||
|
| ------------------------ | --------------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn |
|
||||||
|
| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors |
|
||||||
|
| Token streaming | `GET /api/event` (GLOBAL, live) — `text.delta` | yes — deltas reassemble to the committed text |
|
||||||
|
| Reconnect + replay | `GET /api/session/{id}/event?after=<seq>` (durable) | yes — replayed a finished session |
|
||||||
|
| Interrupt, session lives | `POST /interrupt` → 204 | endpoint only, not exercised |
|
||||||
|
| Model selection | `POST /model` → 204 | yes — runs on the chosen model |
|
||||||
|
| Images | `prompt.files` | not exercised (we have images via `--file` already) |
|
||||||
|
|
||||||
|
## There are TWO streams, and this is the thing to get right
|
||||||
|
|
||||||
|
Corrected after Phase A; the table above originally implied one. The serve publishes each turn twice:
|
||||||
|
|
||||||
|
- **`GET /api/session/{id}/event?after=<seq>`** — durable, per session, replayable, every event carrying
|
||||||
|
`durable.seq`. Whole values only (`text.ended` with the full text). **No deltas.**
|
||||||
|
- **`GET /api/event`** — live, **global**, ephemeral. Carries `text.delta` and `tool.input.delta`. No cursor.
|
||||||
|
|
||||||
|
Measured on one real turn: 13 events durable, 21 live, the difference being 3 `text.delta` and 5
|
||||||
|
`tool.input.delta`. **Reading only the per-session stream — which is what I did first — makes it look
|
||||||
|
like the serve cannot stream at all**, and would have quietly removed the main reason to migrate.
|
||||||
|
|
||||||
|
The split maps exactly onto what officer already does for Claude: durable → `chat_session_events`, live →
|
||||||
|
UI deltas. The cost is that the live stream is GLOBAL, so a consumer must filter on `sessionID` and
|
||||||
|
cannot assume it owns the socket.
|
||||||
|
|
||||||
|
## Facts that will bite whoever implements this
|
||||||
|
|
||||||
|
Each of these cost time to find. None is in the API docs.
|
||||||
|
|
||||||
|
1. **The location is per REQUEST, not per session.** `x-opencode-directory: <cwd>` header, or
|
||||||
|
`?location[directory]=` as a deepObject query. A session created with `location` in the body and then
|
||||||
|
prompted without the header does not behave.
|
||||||
|
2. **Responses wrap in `{"data": …}`** on this surface; the legacy `/session/*` returns bare objects.
|
||||||
|
Reading `body.id` instead of `body.data.id` yields `undefined` silently.
|
||||||
|
3. **`delivery` defaults to `"steer"`.** Omitting it injects into a running turn, which is NOT the safe
|
||||||
|
default for an ordinary "send" — it must be set explicitly per intent.
|
||||||
|
4. **A model with no connected credential fails silently.** Prompt admitted, `prompt.admitted` and
|
||||||
|
`prompted` emitted, then nothing, forever. The sidecar now connects the credential at boot
|
||||||
|
(`connect-credential.ts`), and this failure mode is why that exists.
|
||||||
|
5. **The event names are `session.next.*`** — `step.started`, `text.started`, `text.ended`,
|
||||||
|
`tool.called`, `tool.success`, `step.ended`, `step.failed`. Not the shapes `mapRunLine` handles.
|
||||||
|
|
||||||
|
## Status, 2026-08-10
|
||||||
|
|
||||||
|
- **Phase A — done.** `serve-events.ts` + tests, fixtures captured from real turns.
|
||||||
|
- **Phase B — done, behind `OPENCODE_TURNS=serve` (default: subprocess).** `serve-runner.ts`. Verified
|
||||||
|
end to end through the chat socket: tool call, tool result, **3 streaming deltas**, text, cost. Stop is
|
||||||
|
an interrupt and the session survives it.
|
||||||
|
- **Phase C — server half done.** A message sent while a turn runs is injected with `delivery: "steer"`
|
||||||
|
into the RUNNING turn, verified end to end. No client change was needed: officer's composer already
|
||||||
|
sends mid-turn, and the subprocess path was superseding where the serve steers.
|
||||||
|
- **Phase D — done.** The subprocess is deleted: no `runner.ts`, no `OPENCODE_TURNS` switch, no fallback
|
||||||
|
engine. Andre called it — nothing depends on OpenCode, so the cost of removing the escape hatch is
|
||||||
|
near zero and the recovery is git. 818 lines went with it, all of them workarounds for `stdin` being
|
||||||
|
`/dev/null`.
|
||||||
|
|
||||||
|
Not yet lived with. **Nothing here has run a real conversation with a person at the other end** — see
|
||||||
|
`docs/opencode-testing-checklist.md` for what to try and what is most likely to be broken.
|
||||||
|
|
||||||
|
## Shape of the work
|
||||||
|
|
||||||
|
**Phase A — read the stream without depending on it.** Add a serve-based reader alongside the existing
|
||||||
|
runner: subscribe to `/api/session/{id}/event`, map `session.next.*` → `ChatEvent`, and prove the mapping
|
||||||
|
against real turns. Do not route any user traffic through it. This is where `mapRunLine`'s successor gets
|
||||||
|
written and tested, and it is the only phase with no user-visible risk.
|
||||||
|
|
||||||
|
**Phase B — turns through the serve, behind a switch.** `POST /prompt` for the turn, events from Phase A,
|
||||||
|
`POST /interrupt` for stop. Keep `opencode run` reachable by config so a bad day is one restart from the
|
||||||
|
known-good path. The switch is the deliverable, not a detail.
|
||||||
|
|
||||||
|
**Phase C — the capabilities that motivated it.** `delivery: "steer"` wired to the existing "send now"
|
||||||
|
button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins
|
||||||
|
and they are cheap once B holds.
|
||||||
|
|
||||||
|
**Phase D — retire the subprocess**, only after B has run for a while. Deleting it early converts every
|
||||||
|
future problem into an emergency.
|
||||||
|
|
||||||
|
## What changes for the user
|
||||||
|
|
||||||
|
Better: text appears as it is generated instead of in blocks; the queue and "send now" work on OpenCode
|
||||||
|
exactly as they do on Claude; stop interrupts without destroying the session.
|
||||||
|
|
||||||
|
Worse, potentially: the serve becomes load-bearing. Today a serve crash costs session listing and nothing
|
||||||
|
else, because turns are subprocesses. After this it costs every turn in flight. That trade is the whole
|
||||||
|
decision.
|
||||||
|
|
||||||
|
## The risk I would not take blind
|
||||||
|
|
||||||
|
**Warm sessions bring a lifetime problem OpenCode does not currently have.** A subprocess ends when the
|
||||||
|
turn ends; there is nothing to garbage-collect, adopt after a restart, or leak. A serve session persists,
|
||||||
|
so this migration imports the entire class of problems the Claude path spent months getting right — idle
|
||||||
|
GC, orphan adoption, releasing versus killing, the supersede race I fixed this morning.
|
||||||
|
|
||||||
|
That is not an argument against doing it. It is an argument for Phase B keeping the old path one config
|
||||||
|
flip away, and for not doing Phase D on the same day as Phase B.
|
||||||
|
|
||||||
|
## Where to start
|
||||||
|
|
||||||
|
Phase A, `runner.ts`'s sibling, with the `session.next.*` fixtures captured from a real turn rather than
|
||||||
|
hand-written — `docs/opencode-fork-decision.md` records how to drive one with plain `curl`, and
|
||||||
|
`runner.test.ts` is the pattern for pinning a mapping without spawning anything.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# The serve-based turn path: what it was, and whether to rebuild it
|
||||||
|
|
||||||
|
Written 2026-08-10, before deleting `event-mapper.ts` and the unused half of
|
||||||
|
`api/chat/opencode/client.ts`. `docs/opencode-parity.md` item 7 asks for exactly this: read the dead
|
||||||
|
design into a note first, because Phase 2 may rebuild it.
|
||||||
|
|
||||||
|
It also answers Phase 2's blocking question, which turned out to be cheap to settle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The question Phase 2 was waiting on
|
||||||
|
|
||||||
|
`docs/opencode-phase0-review.md` narrowed the fork to one thing:
|
||||||
|
|
||||||
|
> Moving turns onto the serve would reintroduce exactly the single-directory coupling that caused the
|
||||||
|
> original pain, **unless the serve's API can take a per-request directory**. That question — not "why
|
||||||
|
> was it replaced" — is now the one to answer first.
|
||||||
|
|
||||||
|
**It can. Tested against the running serve (opencode 1.17.9 on this machine):**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /session?directory=/tmp/oc-phase2-probe → { directory: "/tmp/oc-phase2-probe" } ✅ matches
|
||||||
|
POST /session body { directory: … } → { directory: "<serve cwd>" } ❌ ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
`directory` is a **query parameter on every `/session*` route** — create, message, abort, fork,
|
||||||
|
summarize, prompt_async, shell, all of them — alongside a `workspace` param. It is not a body field,
|
||||||
|
which is why a first probe that sent it in the body appeared to disprove the whole idea.
|
||||||
|
|
||||||
|
**So the coupling is gone on both paths.** `opencode run --dir` anchors a subprocess turn (verified in
|
||||||
|
the phase-0 review), and `?directory=` anchors a serve turn. The single-server constraint that shelved
|
||||||
|
this work does not exist in either architecture any more.
|
||||||
|
|
||||||
|
That removes the reason not to move. It does not by itself decide the move — see the trade below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the dead code actually was
|
||||||
|
|
||||||
|
Two files, ~200 of ~390 platform-side lines, all reachable from nothing:
|
||||||
|
|
||||||
|
**`event-mapper.ts` (146 lines)** — maps OpenCode's SSE event stream to officer's `ChatEvent`s. Handles
|
||||||
|
`message.part.updated` (text deltas, tool state transitions), `message.updated`, `session.idle` and
|
||||||
|
`session.error`. Its shape assumes a _streaming_ source: partial text arriving as deltas, tool calls
|
||||||
|
transitioning pending → running → completed as separate events.
|
||||||
|
|
||||||
|
**The SSE half of `client.ts`** — `subscribe(sessionId, listener)`, one shared `GET /event` stream per
|
||||||
|
base URL demultiplexed to per-session listeners, with reconnect. Plus `createSession`, `postMessage`,
|
||||||
|
`abort`, `isServerHealthy`.
|
||||||
|
|
||||||
|
The live half of `client.ts` stays: `listSessions`, `getSession`, `getMessages`, `deleteSession`,
|
||||||
|
`renameSession`, all used by `opencode-sessions.ts` for the chat list and transcript reads.
|
||||||
|
|
||||||
|
### Why this matters for a rebuild
|
||||||
|
|
||||||
|
The dead mapper is **not** a sketch to be dusted off — it is a finished, working shape for a design that
|
||||||
|
was measured against a real event stream. Two things in it are worth keeping if the serve path returns:
|
||||||
|
|
||||||
|
1. **The delta model.** `runner.ts` emits whole `text` blocks because `opencode run --format json` emits
|
||||||
|
whole blocks; the mapper emits deltas because SSE emits deltas. Token streaming (parity Phase 3, item 11) is not new work on the serve path — it is this file.
|
||||||
|
2. **Tool-state transitions.** The mapper tracks a tool call across pending/running/completed. The
|
||||||
|
subprocess path only ever sees the finished call.
|
||||||
|
|
||||||
|
Both are recoverable from git after deletion (`5d077a4` is the last commit where the serve path was
|
||||||
|
live), which is the argument for deleting rather than keeping it compiled-but-unreachable: an unused
|
||||||
|
file rots silently against a moving API, and this one is already pinned to a version two minor releases
|
||||||
|
behind what some machines run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The trade, now that the blocker is gone
|
||||||
|
|
||||||
|
**Keep the subprocess (`opencode run --dir`)**
|
||||||
|
|
||||||
|
- No rewrite; it works today.
|
||||||
|
- Permanently forfeits: token streaming, mid-turn injection, background tasks, live-session enumeration,
|
||||||
|
reattach-by-id, interrupt-without-teardown. Every one of those is a `stdin: 'ignore'` consequence.
|
||||||
|
- One process per turn, no warm state to leak or garbage-collect.
|
||||||
|
|
||||||
|
**Move turns onto the serve (`POST /session/{id}/message?directory=…`)**
|
||||||
|
|
||||||
|
- Unblocks the whole of parity Phase 3 at once — those six capabilities are all downstream of a
|
||||||
|
persistent, addressable session.
|
||||||
|
- Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already
|
||||||
|
exists in the deleted code, so the cost is smaller than it looks.
|
||||||
|
- Introduces warm sessions and therefore a lifetime question OpenCode currently does not have: idle GC,
|
||||||
|
orphan adoption after a restart, the same problems the Claude path spent months getting right.
|
||||||
|
- The serve becomes load-bearing rather than a convenience. Today a serve crash costs session listing;
|
||||||
|
then it would cost every turn in flight.
|
||||||
|
|
||||||
|
**A third option, not in the parity doc:** move only what needs the serve. Keep `opencode run` for turns
|
||||||
|
and add `?directory=`-scoped serve calls for enumeration and reattach. That buys the Live panel and
|
||||||
|
reattach-by-id without warm sessions or an SSE loop. It does not buy streaming or mid-turn injection,
|
||||||
|
which are the two most visible gaps.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Do not start the migration on this pass.** The blocker is cleared and that is the deliverable here;
|
||||||
|
choosing between the three is a product call about how much OpenCode should behave like Claude, and it
|
||||||
|
should be made deliberately rather than as a side effect of a cleanup branch.
|
||||||
|
|
||||||
|
If it is taken: the third option first. It is incremental, it is the only one with no lifetime
|
||||||
|
questions, and it makes the Live panel — which today silently omits every running OpenCode turn — tell
|
||||||
|
the truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified facts this note rests on
|
||||||
|
|
||||||
|
| Claim | How |
|
||||||
|
| ---------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||||
|
| `?directory=` on `POST /session` is honoured | Created a session, read `directory` back: matched |
|
||||||
|
| A body `directory` is ignored | Same call with the field in the body: got the serve's cwd |
|
||||||
|
| `directory` is on every `/session*` route | Read the serve's own `/doc` (OpenAPI) |
|
||||||
|
| `event-mapper.ts` has no importers outside `client.ts` | grep |
|
||||||
|
| `createSession`/`postMessage`/`abort`/`isServerHealthy` have no callers | grep for call sites |
|
||||||
|
| `listSessions`/`getSession`/`getMessages`/`deleteSession`/`renameSession` are live | all from `opencode-sessions.ts` |
|
||||||
|
|
||||||
|
Machine note: this server runs opencode **1.17.9**; the phase-0 review's tests ran against **1.18.11**
|
||||||
|
elsewhere. The `?directory=` result above is from 1.17.9, so it holds on the older of the two.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# OpenCode: what to test tonight
|
||||||
|
|
||||||
|
Every OpenCode turn now runs through the serve (`serve-runner.ts`). The `opencode run` subprocess is
|
||||||
|
deleted, so this is not a comparison against a fallback — it is the only path.
|
||||||
|
|
||||||
|
I have driven each item in **Should already work** end to end through the real chat socket, with a
|
||||||
|
script rather than a browser. Nothing below has been used by a person in a real conversation, and the
|
||||||
|
things in **Unproven** are unproven because probes cannot answer them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/projects/officer-suite/platform
|
||||||
|
pm2 logs officer-opencode --lines 40 # the sidecar's own account of what happened
|
||||||
|
```
|
||||||
|
|
||||||
|
On boot you should see three lines: `serve healthy on port …`, `connected the opencode credential to the
|
||||||
|
api surface`, and the sidecar registering. **If the credential line is missing or says it could not
|
||||||
|
connect, stop** — paid models will silently do nothing, which is the failure that cost most of an
|
||||||
|
afternoon (`docs/opencode-fork-decision.md`).
|
||||||
|
|
||||||
|
Rolling back is `git revert` of `a3dbda7` (Phase D) and a restart. There is no config flag any more.
|
||||||
|
|
||||||
|
Alpha is unaffected until you pull.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Should already work — confirm, do not investigate
|
||||||
|
|
||||||
|
Each verified by me end to end. If one fails, that is new information and worth stopping on.
|
||||||
|
|
||||||
|
- [ ] **A plain turn.** Ask for something short. Text arrives.
|
||||||
|
- [ ] **Streaming.** Ask for something long — "count slowly to 50". Text should appear **progressively**,
|
||||||
|
not in one block at the end. This is the headline change; the old path could not do it.
|
||||||
|
- [ ] **A tool call.** "Run `echo hi` with bash." A tool row appears with the command as its arguments,
|
||||||
|
then its output.
|
||||||
|
- [ ] **Cost.** The turn ends with a token count attached.
|
||||||
|
- [ ] **An image.** Drop a screenshot in and ask what it shows. It must actually be described — the
|
||||||
|
failure mode is a confident answer about nothing, which is what B4 was.
|
||||||
|
- [ ] **Stop.** Press stop mid-turn. The turn ends **and the conversation stays usable** — send another
|
||||||
|
message straight after and it should answer. Previously stop destroyed the session.
|
||||||
|
- [ ] **Mid-turn injection.** While a turn runs, send another message. It should join the RUNNING turn
|
||||||
|
rather than starting a new one or superseding it.
|
||||||
|
|
||||||
|
## Unproven — this is the actual testing
|
||||||
|
|
||||||
|
- [ ] **Resume from history.** Open an older OpenCode conversation from the list and continue it. This
|
||||||
|
exercises `resumeSessionId`, which I never tested against the serve. **Most likely thing to be
|
||||||
|
broken.**
|
||||||
|
- [ ] **An idle session, an hour later.** Send a message to a conversation you have not touched for a
|
||||||
|
while. Warm sessions are new here — the subprocess had nothing to go stale.
|
||||||
|
- [ ] **A sidecar restart mid-turn.** `pm2 restart officer-opencode` while a turn is generating. Expect
|
||||||
|
the transcript to say the turn stopped. **The turn itself keeps running inside the serve** — that
|
||||||
|
is intended, not a bug, but nobody has watched what it looks like from the browser.
|
||||||
|
- [ ] **An officer restart mid-turn.** Different from the above: officer is the relay, the sidecar keeps
|
||||||
|
committing to `chat_session_events`. On reload the transcript should be intact. This is the one I
|
||||||
|
would least like to be wrong about.
|
||||||
|
- [ ] **Two conversations at once.** The live event stream is GLOBAL — one socket carries every session
|
||||||
|
and `serve-runner` filters on `sessionID`. If that filter is wrong, output from one conversation
|
||||||
|
appears in another. Two panels side by side is the test.
|
||||||
|
- [ ] **The Live panel.** A running OpenCode turn should appear, named, and disappear when it ends.
|
||||||
|
- [ ] **A long turn.** Ten-plus minutes. The old path had watchdogs (inactivity, hard cap) that are gone
|
||||||
|
with it; the serve has its own ideas about timeouts and I have not found their edges.
|
||||||
|
- [ ] **A failing tool.** Ask it to run a command that does not exist. The error should land in the
|
||||||
|
transcript as a failed tool, not as a dead turn.
|
||||||
|
|
||||||
|
## Known gaps, so you do not report them as bugs
|
||||||
|
|
||||||
|
- **No durable replay.** `serve-runner` reads the live stream only. Events are still committed to
|
||||||
|
`chat_session_events` as they arrive, so the transcript survives — but recovering a turn *this sidecar
|
||||||
|
process never saw* would need the `?after=` cursor, and that is not built.
|
||||||
|
- **Notifications/thinking/background tasks.** Not implemented on this harness. Thinking is deliberately
|
||||||
|
out of scope for both harnesses.
|
||||||
|
- **`messageCount` shows nothing** for OpenCode rows. The UI renders an `OpenCode` badge instead; not a
|
||||||
|
gap (`docs/opencode-parity.md`).
|
||||||
|
|
||||||
|
## What is useful to tell me
|
||||||
|
|
||||||
|
For anything that misbehaves: what you did, what appeared, and the last twenty lines of
|
||||||
|
`pm2 logs officer-opencode`. The sidecar logs its own errors, and a silent failure with clean logs is a
|
||||||
|
different diagnosis from a loud one.
|
||||||
|
|
||||||
|
If a turn produces **nothing at all** — no text, no error, no spinner ending — check the credential line
|
||||||
|
from boot first. That specific silence has one cause and I have chased it twice.
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
# Per-user Linux accounts
|
||||||
|
|
||||||
|
**Status: in progress.** Stage 1 (the account and the privilege-drop mechanism) is being built now.
|
||||||
|
Agents are explicitly out of scope for the first pass.
|
||||||
|
|
||||||
|
## What this is for
|
||||||
|
|
||||||
|
Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the
|
||||||
|
**owner's OS user in the owner's home**. That is why `capabilities/registry.ts` declares them
|
||||||
|
`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one.
|
||||||
|
The registry says so out loud: *"revisit only if per-user home confinement is ever solved — and that is a
|
||||||
|
project, not a checkbox."*
|
||||||
|
|
||||||
|
This is that project. A member gets a real Linux account whose home is the directory the platform already
|
||||||
|
provisions for them, and the surfaces that execute code run **as that account**. The payoff is three
|
||||||
|
things at once:
|
||||||
|
|
||||||
|
- **Isolation** — a member cannot read another member's files, because the kernel says so rather than
|
||||||
|
because a path check happened to be right.
|
||||||
|
- **Permissions** — "may they see this" becomes a mode bit, checked by the OS on every syscall, instead
|
||||||
|
of a predicate the platform has to remember to apply on every route.
|
||||||
|
- **Separable agents** — `claude` and `opencode` run as the member, with their own `~/.claude`, their own
|
||||||
|
transcripts and their own session state, because the CLI groups by HOME and cwd.
|
||||||
|
|
||||||
|
## The target for the first test
|
||||||
|
|
||||||
|
A member signs in and:
|
||||||
|
|
||||||
|
- the **file browser** shows their home as the root and cannot navigate above it;
|
||||||
|
- the **terminal** lands in their home and has no permission to see anything above it.
|
||||||
|
|
||||||
|
Nothing else changes. Agents stay owner-only until this much is solid.
|
||||||
|
|
||||||
|
## The layout, and what each mode bit is for
|
||||||
|
|
||||||
|
```
|
||||||
|
data/ 711 service user traverse only — a member cannot enumerate the members
|
||||||
|
└── <email>/ 711 service user traverse only — a member cannot see their OWN siblings
|
||||||
|
├── home/ 700 the member their real Linux home
|
||||||
|
├── attachments/ 700 service user platform-written; unreachable even by name
|
||||||
|
├── email_accounts/ 700 service user "
|
||||||
|
├── dashboards/ 700 service user "
|
||||||
|
└── … 700 service user "
|
||||||
|
```
|
||||||
|
|
||||||
|
The important line is the second one. `data/<email>/` is traverse-only **to its own member**: they need
|
||||||
|
`x` to reach `home/`, and they must not have `r`, or they could list the platform's private tree beside
|
||||||
|
it. And because every sibling is `700 service user`, knowing a name does not help — traversal without
|
||||||
|
read gets you exactly one place, which is where they are going anyway.
|
||||||
|
|
||||||
|
This is also what resolves the two-sided ownership problem. The platform runs as the service user and
|
||||||
|
writes attachments, email databases and dashboards into `data/<email>/`; the member owns only `home/`.
|
||||||
|
Nobody needs a shared group, a setgid bit or an ACL, and neither side can write where the other lives.
|
||||||
|
|
||||||
|
**Members' homes stay under `DATA_PATH`** rather than moving to `/home/<user>`. They are the platform's
|
||||||
|
data, they belong with the rest of that account's data, and the directory is already provisioned there by
|
||||||
|
`provisionUserDirs`. A move would also break `getOwnerHomeDir`'s fallback, which is the only shape the
|
||||||
|
code has ever had for a non-owner home.
|
||||||
|
|
||||||
|
## Hard prerequisite: the secrets a shell can currently read
|
||||||
|
|
||||||
|
**This must be fixed before any member gets a shell, and it is not optional.**
|
||||||
|
|
||||||
|
On this machine, verified 2026-08-11:
|
||||||
|
|
||||||
|
| path | mode | consequence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
|
||||||
|
| `…/officer.dev` | 775 | listable by anyone |
|
||||||
|
| `…/platform/.env` | **664** | **world-readable** |
|
||||||
|
|
||||||
|
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
|
||||||
|
a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than
|
||||||
|
not doing it — the capability model would be intact and completely bypassed.
|
||||||
|
|
||||||
|
So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is
|
||||||
|
traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env`
|
||||||
|
under the project root is group- or world-readable.** A prerequisite that is merely written down is a
|
||||||
|
prerequisite that gets skipped.
|
||||||
|
|
||||||
|
The same applies to `capabilities/` (775 today) and to the repo checkout itself: a member can read the
|
||||||
|
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
|
||||||
|
|
||||||
|
## The mechanism, and the trap in it
|
||||||
|
|
||||||
|
### `Bun.spawn` silently ignores `uid` and `gid`
|
||||||
|
|
||||||
|
Verified on bun 1.3.10, 2026-08-11. From uid 1000:
|
||||||
|
|
||||||
|
```js
|
||||||
|
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }) // exit 0, prints "1000"
|
||||||
|
```
|
||||||
|
|
||||||
|
It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and
|
||||||
|
script spawn in this codebase goes through `Bun.spawn`.
|
||||||
|
|
||||||
|
Two honest qualifications, because the danger is narrower than it first looks:
|
||||||
|
|
||||||
|
- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by
|
||||||
|
accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all.
|
||||||
|
- What *can* reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
|
||||||
|
sidecars are `.mjs`.
|
||||||
|
|
||||||
|
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
|
||||||
|
an external wrapper, and a test pins Bun's runtime behaviour. If Bun ever implements the option, that test
|
||||||
|
fails and tells us we may simplify. **A silently absent isolation boundary is the worst possible outcome of
|
||||||
|
this project**, so it is worth a test that exists only to observe something staying broken.
|
||||||
|
|
||||||
|
### `sudo -n setpriv`, and why both words are needed
|
||||||
|
|
||||||
|
`runAs` builds:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv…>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to.
|
||||||
|
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the *owner's*
|
||||||
|
groups, which is a quiet way to retain access we just took away.
|
||||||
|
- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and
|
||||||
|
`PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`,
|
||||||
|
and on a process started by PM2 in the platform directory it contains everything Bun auto-loaded from
|
||||||
|
`.env`.
|
||||||
|
|
||||||
|
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
|
||||||
|
`initgroups failed: Operation not permitted` for an unprivileged caller *even when reuid'ing to its own
|
||||||
|
account* — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
|
||||||
|
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
|
||||||
|
user will ever see.
|
||||||
|
|
||||||
|
Verified end to end, dropping to the current account:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sudo -n setpriv --reuid=pastilhas --regid=pastilhas --init-groups --reset-env -- \
|
||||||
|
sh -c 'id -u; id -G; echo HOME=$HOME; echo SECRET=${POSTGRES_URL:-unset}'
|
||||||
|
1000
|
||||||
|
1000 4 24 27 30 46 101 988 1001 ← supplementary groups from the account, not inherited
|
||||||
|
HOME=/home/pastilhas ← from passwd, after the reset
|
||||||
|
SECRET=unset ← the platform's .env did NOT cross
|
||||||
|
```
|
||||||
|
|
||||||
|
That last line is the whole security property, demonstrated rather than asserted, and it is pinned by a
|
||||||
|
test (`os-user.test.ts` → "does not pass the platform environment through").
|
||||||
|
|
||||||
|
`sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is
|
||||||
|
sudoers *policy* — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
||||||
|
shell" must not depend on a config file someone may have edited.
|
||||||
|
|
||||||
|
Root is available: `scripts/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
|
||||||
|
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
|
||||||
|
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
|
||||||
|
rule anyway.
|
||||||
|
|
||||||
|
### The terminal is the easy one
|
||||||
|
|
||||||
|
`sidecar/pty/sessions.mjs` uses **node-pty** under **node**, and node-pty's `spawn` genuinely honours
|
||||||
|
`uid`/`gid` (it is a native binding, not Bun's spawn). Two options; we take the second:
|
||||||
|
|
||||||
|
1. Run the pty sidecar as root and pass `uid`/`gid` per session.
|
||||||
|
2. Keep the sidecar unprivileged and make the command `setpriv … <shell> -i`.
|
||||||
|
|
||||||
|
(2) means no root daemon and one mechanism shared with everything else. A root daemon accepting session
|
||||||
|
requests over a socket is a bigger promise than this feature needs to make.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
**The username the owner chose, verbatim.** `whoami` in a member's terminal says who they are, their
|
||||||
|
prompt is their name, and a commit from their edge checkout is attributed to something recognisable.
|
||||||
|
|
||||||
|
This carried an `officer_` prefix for about an hour. The prefix bought three things — no collision with a
|
||||||
|
system account, a greppable record of what the feature created, and a member unable to pick a name that
|
||||||
|
shadows something real — and cost the only thing anyone would notice. Measured before removing it:
|
||||||
|
`useradd` on this host accepts everything `validateUsername` already permits, including dots, hyphens,
|
||||||
|
underscores and uppercase.
|
||||||
|
|
||||||
|
**What replaced the prefix's safety is the adoption rule, and it had to.** `ensureOsUser` reuses an
|
||||||
|
existing Linux account, which is what makes it re-runnable. That was safe by construction while only we
|
||||||
|
created `officer_*` names. With the name being whatever was typed, adoption became the dangerous path: a
|
||||||
|
platform account named `root` would have found root in passwd, and every `runAs` for that member would
|
||||||
|
have been a root shell. So an existing account is adopted **only when its passwd home is already exactly
|
||||||
|
the home we are about to confine** — that is what makes it ours — and any uid below 1000 is refused
|
||||||
|
outright as belt and braces.
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
|
||||||
|
```
|
||||||
|
username "root" -> refused: 'root' is a system account on this machine.
|
||||||
|
username "daemon" -> refused: 'daemon' is a system account on this machine.
|
||||||
|
the owner's own account -> refused: 'pastilhas' is already a user on this machine, with its
|
||||||
|
home at /home/pastilhas. Refusing to take it over.
|
||||||
|
```
|
||||||
|
|
||||||
|
The resolved name is **stored** on the user row (`users.os_user`) rather than re-derived. `useradd` can
|
||||||
|
adjust or refuse a name, and re-deriving would mean the platform's idea of who a member is could drift
|
||||||
|
from what is actually in `/etc/passwd`.
|
||||||
|
|
||||||
|
## The ancestor trap
|
||||||
|
|
||||||
|
A member's home sits under `DATA_PATH`, which on a normal install sits under the **owner's** home — and
|
||||||
|
`/home/<owner>` is `750` on Debian and Ubuntu. Every mode bit on the account tree can be correct, the
|
||||||
|
directory can exist, and the member still cannot reach it, because they have no `x` on an ancestor four
|
||||||
|
levels up.
|
||||||
|
|
||||||
|
What that surfaced as, on the first real install:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh-keygen failed: Could not stat …/data/jg@pertento.ai/home/.ssh: Permission denied
|
||||||
|
```
|
||||||
|
|
||||||
|
Which points at exactly the wrong thing. `.ssh` was there and correctly owned; the account could not
|
||||||
|
traverse `/home/pastilhas`.
|
||||||
|
|
||||||
|
`firstUntraversableAncestor` now walks the chain **as the member** before anything tries to use the home,
|
||||||
|
and the error names the directory and the fix (`chmod o+x <dir>`). `x` without `r` is the ask throughout:
|
||||||
|
traversal, not listing — nobody gains the ability to enumerate the owner's home.
|
||||||
|
|
||||||
|
The development machine happened to be `751` already, which is exactly why the probe passed there and
|
||||||
|
failed on a fresh install. Worth remembering as a shape of mistake: the probe used `/tmp`, so it never
|
||||||
|
crossed the ancestor that mattered.
|
||||||
|
|
||||||
|
## Out of scope, and honest about it
|
||||||
|
|
||||||
|
- **This is not a sandbox.** A member with a shell is on the machine. They cannot read the owner's files
|
||||||
|
or another member's, and `sudo` is not theirs — but they can run code, see process names, and reach the
|
||||||
|
network. It isolates members from each other and from accidents, not from the host.
|
||||||
|
- **Agents come later** — but by less than this said. Two claims here were wrong and are corrected on
|
||||||
|
2026-08-11; the superseded text is in the git history of this file, and the working state is
|
||||||
|
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
|
||||||
|
|
||||||
|
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen *outside* it, making a
|
||||||
|
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
|
||||||
|
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
|
||||||
|
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
|
||||||
|
existing sidecar wraps the CLI spawn in `runAsArgv` per turn and there is no second process to stand up.
|
||||||
|
|
||||||
|
It also said the credential is not the problem because `officer-anthropic-proxy` already holds it, so a
|
||||||
|
member's `claude` "needs only `ANTHROPIC_BASE_URL` pointed at the proxy". That is backwards. The proxy holds
|
||||||
|
the **owner's** credential (`sidecar/claude/proxy.ts:7` reads the owner's own
|
||||||
|
`~/.claude/.credentials.json`), so pointing a member at it spends the owner's account on the member's
|
||||||
|
turns. Per-user Claude means their own login in their own home, and `setpriv --reset-env` is what makes that
|
||||||
|
the default rather than something to remember: nothing crosses into their process unless it is written into
|
||||||
|
the argv.
|
||||||
|
|
||||||
|
What does remain out of scope: **no platform process ever runs as a member.** The agent sidecar needs
|
||||||
|
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
|
||||||
|
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
|
||||||
|
harness stays the service user's; only `claude` itself drops privileges.
|
||||||
|
- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely
|
||||||
|
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill *every*
|
||||||
|
session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell
|
||||||
|
that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after.
|
||||||
|
- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account
|
||||||
|
would leave its home behind under the old address. Pre-existing, unfixed, worth knowing.
|
||||||
|
|
||||||
|
## What the first real run proved, and what it corrected
|
||||||
|
|
||||||
|
Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `useradd`. Every property
|
||||||
|
below was **observed**, not reasoned about:
|
||||||
|
|
||||||
|
| attempted, as the member | result |
|
||||||
|
| --- | --- |
|
||||||
|
| write in own home | OK |
|
||||||
|
| read `…/<email>/attachments/private.txt` | Permission denied |
|
||||||
|
| `ls …/<email>/` (their own account dir) | Permission denied |
|
||||||
|
| `ls $DATA_PATH` (enumerate the members) | Permission denied |
|
||||||
|
| `ls …/other-member@example.com/home` | Permission denied |
|
||||||
|
| `cd $HOME/..` | **succeeds** — see below |
|
||||||
|
|
||||||
|
Three bugs surfaced only by running it:
|
||||||
|
|
||||||
|
1. **`chmod` after `chown` fails forever.** `chmod` requires ownership, so once the home belongs to the
|
||||||
|
member the service user cannot set its mode. Both orderings fail unprivileged — the first on the second
|
||||||
|
run, the second immediately. Both operations now go through sudo, which is what makes the function
|
||||||
|
re-runnable.
|
||||||
|
2. **A member could read another member's home.** `provisionUserDirs` created directories at the default
|
||||||
|
umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being
|
||||||
|
unlistable is not protection when the child is world-readable and the attacker knows an email address.
|
||||||
|
The skeleton is now created closed — `711` on the account directory, `700` inside — so *unconfined* is
|
||||||
|
also *unreachable*.
|
||||||
|
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
|
||||||
|
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
|
||||||
|
start with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable.
|
||||||
|
|
||||||
|
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
|
||||||
|
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
|
||||||
|
shell can reach `/etc`, `/usr` and anything else the system leaves world-readable, because that is what a
|
||||||
|
shell is. So:
|
||||||
|
|
||||||
|
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
|
||||||
|
enforced by the platform;
|
||||||
|
- the **terminal** cannot *read* anything above the home, but is not confined to it. Confining it would
|
||||||
|
mean a namespace or a chroot, which is a different and much larger feature.
|
||||||
|
|
||||||
|
Say "cannot see behind it", not "cannot leave it".
|
||||||
|
|
||||||
|
## SSH: two keys, two directions
|
||||||
|
|
||||||
|
A member is meant to behave like a real user on the machine — reachable over SSH, able to push to Gitea as
|
||||||
|
themselves, able to have an agent do the same on their behalf. That needs two keys, and they are **not**
|
||||||
|
alternatives:
|
||||||
|
|
||||||
|
| | where | who holds the private half | what it is for |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine |
|
||||||
|
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | *the machine* authenticates to Gitea as them |
|
||||||
|
|
||||||
|
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
|
||||||
|
Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket
|
||||||
|
to borrow**, so an edge checkout it is asked to commit and push needs a key that lives on the box. So the
|
||||||
|
inbound key is optional — an account without one is simply platform-only — and the outbound keypair is
|
||||||
|
generated regardless.
|
||||||
|
|
||||||
|
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
|
||||||
|
*password* login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
|
||||||
|
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
|
||||||
|
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
|
||||||
|
Linux password would be a second door that changing the platform password does not close and deleting the
|
||||||
|
platform account does not lock.
|
||||||
|
|
||||||
|
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
|
||||||
|
pasted value containing a newline would silently install a *second* authorized key. `validatePublicKey`
|
||||||
|
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
|
||||||
|
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
|
||||||
|
force a command.
|
||||||
|
|
||||||
|
**Everything is written with `sudo install`.** The home is 700 and owned by the member, so the service user
|
||||||
|
cannot create `.ssh` at all. `install` sets content, owner and mode in one step, which also closes the
|
||||||
|
window where a key file briefly exists at the process umask. File content goes via a temp path rather than
|
||||||
|
shell text, so nothing has to reason about quoting a value that came from a form.
|
||||||
|
|
||||||
|
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
|
||||||
|
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
|
||||||
|
The failure this avoids is specific: the default setting makes a first connection *prompt*, and a prompt in
|
||||||
|
a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a
|
||||||
|
*changed* host key, which is the attack that matters.
|
||||||
|
|
||||||
|
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
|
||||||
|
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
|
||||||
|
else will remind anyone about: it has to be added to that person's Gitea account or their pushes fail with
|
||||||
|
a permission error that says nothing about a missing key.
|
||||||
|
|
||||||
|
Verified end to end with a real `useradd`: `.ssh` 700 and `id_ed25519` 600 both owned by the member and
|
||||||
|
readable by them, `authorized_keys` byte-identical to what was pasted, the key **not** rotated on a second
|
||||||
|
run (it has been added to Gitea by then), and a multi-line paste refused with `authorized_keys` left
|
||||||
|
untouched.
|
||||||
|
|
||||||
|
## Docker: rootless, one daemon per member
|
||||||
|
|
||||||
|
Verified working on a real member account, 2026-08-11.
|
||||||
|
|
||||||
|
**Not the `docker` group.** `usermod -aG docker <user>` is the one-line version and it is root: membership
|
||||||
|
means talking to the host daemon, which runs as root, so `docker run -v /:/host -it alpine chroot /host` is
|
||||||
|
a root shell. That reads `.env`, every other member's home and the wallet seed — every boundary above,
|
||||||
|
bypassed by one documented command. The group is not "access to Docker", it is "root, by a longer route".
|
||||||
|
|
||||||
|
Rootless gives what was actually wanted: a daemon per account, containers in that account's user namespace,
|
||||||
|
images under their own home. Measured — the daemon runs as the member, `docker pull` put 403 MB in their
|
||||||
|
home, and `docker ps -a` showed nothing while the owner had four containers running.
|
||||||
|
|
||||||
|
**Host prerequisites**, all in `setup.sh` as core packages: `uidmap` (newuidmap/newgidmap — rootless cannot
|
||||||
|
start without them), `dbus-user-session`, and Docker's own rootless extras. `useradd` allocates the
|
||||||
|
`/etc/subuid` range automatically wherever `login.defs` sets `SUB_UID_COUNT`, and `userdel` reclaims it.
|
||||||
|
|
||||||
|
**`loginctl enable-linger` is required, not optional.** Officer's shells are not login sessions, so without
|
||||||
|
it a member's daemon would stop the moment their terminal closed.
|
||||||
|
|
||||||
|
**The setup tool's exit code is not the gate.** It writes `~/.config/systemd/user/docker.service` and then
|
||||||
|
fails its own `systemctl --user start` with "Unit docker.service not found", because nothing reloaded a
|
||||||
|
manager that was already running. So: run it, `daemon-reload`, start it ourselves, and verify by asking the
|
||||||
|
daemon its version.
|
||||||
|
|
||||||
|
**Two features built the same day collided.** Creating a volume copies xattrs, and the DEFAULT ACLs on a
|
||||||
|
member's home — added so the file browser could read their files — are inherited by Docker's storage, where
|
||||||
|
a mapped id inside a user namespace is not a valid id to set:
|
||||||
|
|
||||||
|
```
|
||||||
|
failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data: invalid argument
|
||||||
|
```
|
||||||
|
|
||||||
|
Every container failed to start while the image pulled perfectly. The fix strips DEFAULT ACLs from
|
||||||
|
`~/.local/share/docker` only (`setfacl -R -k`), leaving the access ACLs the file browser depends on. Losing
|
||||||
|
the platform's reach into Docker's internal storage costs nothing: it is layers and volume data, read
|
||||||
|
through `docker` or not at all.
|
||||||
|
|
||||||
|
### The port space is shared, and that is not fixed
|
||||||
|
|
||||||
|
A rootless daemon is isolated; the **host's port space is not**. RootlessKit publishes into it, so a member
|
||||||
|
mapping `5432` collides with the owner's production Postgres — observed immediately:
|
||||||
|
|
||||||
|
```
|
||||||
|
error while calling RootlessKit PortManager.AddPort(): listen tcp4 0.0.0.0:5432: bind: address already in use
|
||||||
|
```
|
||||||
|
|
||||||
|
Two consequences worth knowing:
|
||||||
|
|
||||||
|
- **Publish on `127.0.0.1` explicitly.** A bare `-p 15432:5432` binds `0.0.0.0` in rootless mode, putting a
|
||||||
|
member's dev database on the network. `127.0.0.1:15432:5432` is all they need to reach it from their own
|
||||||
|
shell.
|
||||||
|
- **Nothing allocates ports.** With one member the owner manages it by hand, which is where this stands
|
||||||
|
deliberately. With several, a per-member offset is the crude answer that works.
|
||||||
|
|
||||||
|
## Follow-ups this creates
|
||||||
|
|
||||||
|
- **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot
|
||||||
|
remove it — `rm -rf` fails with EPERM, which is how it was noticed. `deleteUserHandler` does not touch
|
||||||
|
disk today so nothing is broken, but account deletion will need `userdel` and a sudo `rm` to stop
|
||||||
|
leaving an orphaned, unremovable directory behind.
|
||||||
|
- **`confineUserTree` sets `DATA_PATH` itself to 711.** If a container ever bind-mounts a path under
|
||||||
|
`DATA_PATH` and runs as another uid, it will traverse but not list.
|
||||||
|
|
||||||
|
**This happened, and it was worse than the note predicted.** Reported from a live server: a bind-mounted
|
||||||
|
`postgres:18-alpine` crash-looped with `mkdir: can't create directory '…/18/docker'` on a directory that
|
||||||
|
already existed. Two reasons the prediction was too mild. The image's inner uid is 70, which maps through
|
||||||
|
the member's subuid range to 231141 — neither the service user nor the member, so `other`. And by then the
|
||||||
|
home carried `default:other::---` from the ACL work, so `other` had lost even the traverse bit that the 711
|
||||||
|
reasoning assumed. Traverse-but-not-list became no-traverse-at-all.
|
||||||
|
|
||||||
|
`3bea46f`'s fix — stripping defaults from `~/.local/share/docker` — covered NAMED VOLUMES only. A bind
|
||||||
|
source lives wherever the member put it. A named volume passes with the bug present, which is exactly why
|
||||||
|
that fix looked complete.
|
||||||
|
|
||||||
|
Now: `~/.local/dockers` is provisioned at `711` with **all** ACLs removed (`setfacl -R -b`, not `-k`), and
|
||||||
|
is the documented place for compose bind mounts. `711` rather than `700` is the point — a container's inner
|
||||||
|
uid needs `x` to reach a bind source inside, and no ACL can grant what the mode denies. `-b` rather than
|
||||||
|
`-k` because `-k` left `mask::---` behind, so inherited named entries read as `rwx #effective:---`: an ACL
|
||||||
|
that says one thing and means another.
|
||||||
|
|
||||||
|
Bounded deliberately. A member bind-mounting from elsewhere in their home still hits the denial; this is
|
||||||
|
the place that works, not a guarantee about everywhere. The alternatives were worse — extending the strip
|
||||||
|
cannot work when the member chooses the path, and `d:other::--x` on the whole home loosens every directory
|
||||||
|
forever to fix one local case.
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
1. **The account and the mechanism.** `users.os_user`; `ensureOsUser` (useradd + chown + the mode bits
|
||||||
|
above); `runAs`; the `.env` permission gate; tests including the Bun-ignores-uid pin. **No behaviour
|
||||||
|
change** — accounts are created and nothing uses them yet.
|
||||||
|
2. **`getOwnerHomeDir` honours its email argument.** It takes an email and throws it away whenever
|
||||||
|
`HOME_DIR` is set, which is always on a real install. Seven call sites; this one change repoints the
|
||||||
|
file browser, chat, tasks, agents and the VNC password file per account.
|
||||||
|
3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` +
|
||||||
|
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
|
||||||
|
security code.
|
||||||
|
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` capability reopened.
|
||||||
|
5. **Agents.** Separately, later, with the SDK problem solved first.
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Sidecars as installable apps
|
||||||
|
|
||||||
|
**Status: DESIGN, agreed in conversation 2026-08-10. Nothing implemented.** This supersedes the framing
|
||||||
|
of `sidecar-bootstrapping.md`, which stays as the record of how the mechanics work _today_.
|
||||||
|
|
||||||
|
The goal: a clean machine runs chat, the terminal and the file browser, and **everything else arrives by
|
||||||
|
the user asking for it** — from an app store inside Officer. Eventually including sidecars the user did
|
||||||
|
not write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this is mostly not a rewrite
|
||||||
|
|
||||||
|
Three things are already true, which is why "nothing exactly blocks it":
|
||||||
|
|
||||||
|
- **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment
|
||||||
|
states it: features whose sidecars are absent report themselves unavailable rather than disappearing.
|
||||||
|
So the app store never needs to mount or unmount routes.
|
||||||
|
- **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by capability.
|
||||||
|
Installing one is starting a process, not teaching officer about it.
|
||||||
|
- **`service_connections` already solves the multi-user case**, including the part nobody would get
|
||||||
|
right independently — see below.
|
||||||
|
|
||||||
|
What is genuinely new: provisioning containers, per-sidecar schema, and persisted install state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Light becomes the baseline
|
||||||
|
|
||||||
|
`ecosystem.light.config.cjs` stops being a variant and becomes what a fresh install runs:
|
||||||
|
|
||||||
|
```
|
||||||
|
officer · officer-anthropic-proxy · officer-agent · officer-opencode · officer-pty · officer-gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Chat, terminal, file browser. The file-browsing APIs live in the main process, so they cost nothing
|
||||||
|
extra.
|
||||||
|
|
||||||
|
The other fourteen become app-store entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three install shapes
|
||||||
|
|
||||||
|
The prompt the user sees depends on which of these the sidecar is. This is the taxonomy the installer
|
||||||
|
branches on:
|
||||||
|
|
||||||
|
| Shape | What install means | Examples |
|
||||||
|
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Point at an instance you already have** | Ask for URL + credential, write `service_connections`, start the sidecar | gitea, memos, photos (Immich), jellyfin, invoiceshelf, headscale |
|
||||||
|
| **Provision one** | Render our compose template, `docker compose up -d`, wait for health, write the connection _we already know_, start the sidecar | vault (Vaultwarden), slskd, transmission, caldav (Radicale), and any of the above where the user has none |
|
||||||
|
| **Configuration only** | Ask for credentials, start the sidecar. No service to reach | email (IMAP), notify, music, wallet, vnc |
|
||||||
|
|
||||||
|
A sidecar can be more than one: Gitea is "existing instance" for someone who runs one and "provision"
|
||||||
|
for someone who does not. The prompt is the fork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker: we install, the user owns
|
||||||
|
|
||||||
|
**Officer is the installer, never the owner.** Concretely:
|
||||||
|
|
||||||
|
- A real compose file per service, written into **`<root>/dockers/<id>/`**, from our template — using
|
||||||
|
the convention the owner already applies to 47 services: one directory per service,
|
||||||
|
`docker-compose.yaml` inside, and **relative bind mounts** (`./data`, `./database`, `./storage`) so
|
||||||
|
configuration and data sit beside the compose file where both we and a human can see them. Named
|
||||||
|
volumes are used by 3 of those 47 and are the exception; templates use bind mounts, always.
|
||||||
|
- Started with `docker compose up -d` **as the owner**, not as officer's own identity.
|
||||||
|
- Found again by **label** (`officer.sidecar=<id>`), not by holding a handle.
|
||||||
|
|
||||||
|
Consequences, which are the point:
|
||||||
|
|
||||||
|
- `docker ps`, `docker logs`, `docker compose down` all behave normally.
|
||||||
|
- If Officer is removed, the containers keep running and stay manageable.
|
||||||
|
- A user who runs his own estate can edit the compose file — it is his file, in his directory.
|
||||||
|
- We can always find what we installed without pretending to own it.
|
||||||
|
|
||||||
|
The template is what makes this non-technical-user-friendly: sensible defaults, ports, volumes and
|
||||||
|
health checks already correct, so "install Gitea" does not become a tutorial.
|
||||||
|
|
||||||
|
**`USER_UID` / `USER_GID` are set to the owner**, as the existing services already do. That answers the
|
||||||
|
"do containers run as root" question: no, and this is not a new convention — it is the one in use.
|
||||||
|
|
||||||
|
**The app store's containers are isolated from the user's own**, and that is the point of the layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/officerdev/
|
||||||
|
platform/ the app
|
||||||
|
data/ DATA_PATH
|
||||||
|
dockers/ services the app store provisioned <- exclusively ours
|
||||||
|
capabilities/ the file-based item store
|
||||||
|
```
|
||||||
|
|
||||||
|
`OFFICER_ROOT` is derived as the parent of `DATA_PATH` rather than configured separately — a second
|
||||||
|
variable that must agree with the first is a second thing to get wrong.
|
||||||
|
|
||||||
|
Deliberately **not** `~/dockers`, which is where a seasoned user already keeps their estate. Two
|
||||||
|
consequences, both wanted:
|
||||||
|
|
||||||
|
1. Containers the app store created are distinguishable from the user's own **structurally**, not by a
|
||||||
|
naming convention we would have to enforce and they could break.
|
||||||
|
2. **We never reason about someone else's compose files.** The store does not scan, adopt or modify
|
||||||
|
anything outside its own directory. "I already have one of these" is answered by the user giving a
|
||||||
|
URL (`mode: 'existing'`) — never by us finding a directory and guessing whose it is.
|
||||||
|
|
||||||
|
`[open]` Podman, for anyone wanting genuinely rootless.
|
||||||
|
|
||||||
|
### Docker is assumed, and nothing guarantees it
|
||||||
|
|
||||||
|
Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** `setup.sh` calls
|
||||||
|
`setup-dockers.sh`, which invokes `docker compose` with no preflight, so a fresh host without Docker
|
||||||
|
fails partway through setup with a bare "command not found".
|
||||||
|
|
||||||
|
That is the seam where this project's origin shows — it began as one person's own machine, provisioned
|
||||||
|
by his own scripts, where Docker was simply always there.
|
||||||
|
|
||||||
|
The intended fix is **a `setup.sh` per sidecar**, ensuring its own dependencies before its compose file
|
||||||
|
is used. That is also the shape a sidecar needs once it lives in its own repository, so a sidecar package
|
||||||
|
becomes:
|
||||||
|
|
||||||
|
```
|
||||||
|
metadata (catalogue entry) · compose template · setup.sh · schema
|
||||||
|
```
|
||||||
|
|
||||||
|
Until that exists, the app store **detects and reports** rather than guessing or half-installing:
|
||||||
|
`preflight.ts` checks `docker compose version` — which exercises the binary, the daemon connection and
|
||||||
|
the plugin in one call, unlike `docker --version`, which passes with a dead daemon — and distinguishes
|
||||||
|
"not installed" from "daemon unreachable", because the remedies differ.
|
||||||
|
|
||||||
|
The check is **per mode, not per entry**: a host without Docker can still install Photos by pointing at
|
||||||
|
an Immich elsewhere. Refusing the whole entry would be the over-strict check that makes people work
|
||||||
|
around the installer instead of using it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install state
|
||||||
|
|
||||||
|
Two independent flags, because they answer different questions:
|
||||||
|
|
||||||
|
- **`installed`** — the thing exists: container provisioned, config written, schema applied.
|
||||||
|
- **`enabled`** — the process should be running.
|
||||||
|
|
||||||
|
| Action | Sidecar process | Container | Data & schema |
|
||||||
|
| ------------- | --------------- | ------------------------------ | ------------- |
|
||||||
|
| **Disable** | stopped | stopped | untouched |
|
||||||
|
| **Enable** | started | started | untouched |
|
||||||
|
| **Uninstall** | stopped | `docker compose down`, removed | untouched |
|
||||||
|
|
||||||
|
Disable stops the container too — there is no reason to leave Immich holding memory while Photos is
|
||||||
|
switched off. For `mode: 'existing'` there is no container of ours, so disable is only the sidecar.
|
||||||
|
|
||||||
|
Uninstall additionally deletes the `sidecar_installs` row. It does **not** drop the sidecar's tables.
|
||||||
|
|
||||||
|
**Nothing above deletes data, and there is no option that does.**
|
||||||
|
|
||||||
|
### Why the schema survives uninstall too
|
||||||
|
|
||||||
|
Dropping a sidecar's tables is deleting data. Not media, but real: music favourites, the Jellyfin server
|
||||||
|
registry, photos configuration, saved connections. That is the same category as volumes and gets the
|
||||||
|
same answer.
|
||||||
|
|
||||||
|
It also buys something. **Reinstall becomes restore** — uninstall Photos in June, reinstall in August,
|
||||||
|
and the configuration and favourites are still there. Drop the schema and reinstalling hands back a
|
||||||
|
blank service that looks subtly broken to someone who remembers setting it up.
|
||||||
|
|
||||||
|
Keeping them costs nothing: an unused table is a row in `information_schema`. No queries, no memory, no
|
||||||
|
maintenance. Dropping them joins volume deletion in the later, deliberate cleanup feature, where the
|
||||||
|
user sees what they are removing.
|
||||||
|
|
||||||
|
**Install must be idempotent and resumable.** Provision → health → config → schema → start is five steps
|
||||||
|
and any of them can fail. The failure mode to design against is a half-installed service that neither
|
||||||
|
works nor uninstalls. Each step records what it did; re-running install resumes rather than restarts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-sidecar schema
|
||||||
|
|
||||||
|
Today all 42 tables live in one Drizzle schema and arrive together via `bun db:push`. That changes:
|
||||||
|
**each sidecar owns its own schema and applies it on install.**
|
||||||
|
|
||||||
|
This is right _because third-party plugins are a real goal_. For our own fourteen it would be
|
||||||
|
over-engineering — an unused table costs nothing — but a marketplace plugin cannot ship a table into a
|
||||||
|
schema it does not own.
|
||||||
|
|
||||||
|
**The dependency graph makes this tractable.** Measured across the 19 non-core schema files:
|
||||||
|
|
||||||
|
```
|
||||||
|
core: auth.ts, server.ts, chat-events.ts (depend on nothing)
|
||||||
|
sidecars: every single one -> auth.ts, and nothing else
|
||||||
|
```
|
||||||
|
|
||||||
|
There is **no sidecar-to-sidecar dependency anywhere**. One file (`user-data.ts`) touches two, and it is
|
||||||
|
core. So the contract for a plugin's schema is nearly the smallest it could be:
|
||||||
|
|
||||||
|
> **You may reference `users.id`. You may not reference anything else.**
|
||||||
|
|
||||||
|
Which also makes full uninstall well-defined: drop the tables this sidecar declared. Nothing else points
|
||||||
|
at them, by construction.
|
||||||
|
|
||||||
|
`[open]` Where do a plugin's migrations live, and what applies them — the installer, or the sidecar on
|
||||||
|
first boot? Versioning and upgrade are unsolved here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `service_connections` is part of the contract
|
||||||
|
|
||||||
|
Decided: it stays **core and shared**, one table, with each plugin identified by its own ID — rather than
|
||||||
|
a connections table per service.
|
||||||
|
|
||||||
|
It already does the hard part. The row is keyed `(userId, service)` and **a NULL `url` means "inherit the
|
||||||
|
instance"**: the owner's row carries the URL and _is_ the instance; every other user's row carries only
|
||||||
|
their own credential and resolves the base from the owner's row at read time.
|
||||||
|
|
||||||
|
So "members never see the instance URL" is a property of the schema rather than a filter someone must
|
||||||
|
remember on every response — and a member cannot supply a URL, which closes what would otherwise be a
|
||||||
|
per-user SSRF hop wearing a settings form. Gitea is the first service of this kind; five sidecars use the
|
||||||
|
table today (memos, wallet, transmission, slskd, gitea).
|
||||||
|
|
||||||
|
A third-party plugin inherits all of that for free, which is the argument for sharing the table: it is
|
||||||
|
the part nobody would get right independently.
|
||||||
|
|
||||||
|
Two things it needs before third parties touch it:
|
||||||
|
|
||||||
|
1. **Namespaced IDs.** `service` is free text — deliberately, so adding a service is not a schema change.
|
||||||
|
With a marketplace, two plugins could both claim `"gitea"` and collide on the unique index. Needs a
|
||||||
|
convention (reverse-DNS, or IDs issued by the marketplace).
|
||||||
|
2. **Somewhere for plugin-specific config.** The columns are shaped around the services that exist:
|
||||||
|
`url`, `username`, `secret`, `path`, `version`. A plugin needing anything else has nowhere to put it,
|
||||||
|
and adding a column per plugin defeats the shared table. Likely a `config` JSONB for the remainder —
|
||||||
|
with `url` staying first-class, because the inheritance rule above depends on it being a real column.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The API contract, when we open this up
|
||||||
|
|
||||||
|
What a plugin author is promised, and bound by. To be written properly; the shape is:
|
||||||
|
|
||||||
|
- **Register** by name + capabilities over `/api/sidecar/register`; be reachable by capability.
|
||||||
|
- **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a
|
||||||
|
schema.
|
||||||
|
- **May reference** `users.id`, and use `service_connections` under its own ID.
|
||||||
|
- **May not** reference another plugin's tables, or write outside its own.
|
||||||
|
- **Must** tolerate being disabled, re-enabled, and uninstalled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provisioning has three shapes, not one
|
||||||
|
|
||||||
|
This document originally said provisioning "writes the connection we already know". That is only true
|
||||||
|
some of the time, and the difference decides whether an install can finish unattended:
|
||||||
|
|
||||||
|
1. **We set the credentials.** Passed as container environment, so the connection is known the moment it
|
||||||
|
is up. Transmission (`USER`/`PASS`), Vaultwarden (`ADMIN_TOKEN`).
|
||||||
|
2. **We generate a secret into a file.** The bind mount lets us write it before first boot, so it is
|
||||||
|
still known without asking. slskd's API key lives in its `slskd.yml`.
|
||||||
|
3. **A human must mint a token in the service's own UI after it boots.** Immich, Jellyfin and Memos all
|
||||||
|
work this way — no environment variable pre-seeds an API key.
|
||||||
|
|
||||||
|
Shape 3 means an install can be **provisioned and running but not yet connected**. That is a real state,
|
||||||
|
not a failure: the container is up, the compose file is written, and we are waiting for a token. The
|
||||||
|
step machine stops there, and the UI asks for the key with a link to the page that mints it. Resuming
|
||||||
|
finishes the job — which is what `completedSteps` was for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Members get their own accounts
|
||||||
|
|
||||||
|
The owner installs, but a server may already have members — and a member added next month needs the same
|
||||||
|
work done. So the unit is **(service × member)**, reachable from two triggers:
|
||||||
|
|
||||||
|
```
|
||||||
|
install a service -> provision every member who already exists
|
||||||
|
add a member -> provision every service already installed
|
||||||
|
```
|
||||||
|
|
||||||
|
Only handling the first is the classic thing that works on day one and rots quietly. There is no new
|
||||||
|
table: a member is provisioned for a service exactly when they hold a `service_connections` row for it —
|
||||||
|
their own credential, `url` NULL, inheriting the instance from the owner's. That schema was built for
|
||||||
|
this before this existed.
|
||||||
|
|
||||||
|
Three outcomes, declared per catalogue entry as `members`, so the installer never special-cases a
|
||||||
|
service:
|
||||||
|
|
||||||
|
| | Meaning | Services |
|
||||||
|
| ---------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||||
|
| `accounts` | Admin API creates the user **and** mints a credential. Fully transparent — the member just finds it working. | Immich, Jellyfin, Memos, InvoiceShelf, CalDAV |
|
||||||
|
| `invite` | The account can be created; a usable credential cannot. The member sets their own password. | Vaultwarden |
|
||||||
|
| `none` | Single-tenant daemon, no user concept. Access is mediated by Officer alone. | Transmission, slskd, headscale, email, music, wallet, notify, vnc |
|
||||||
|
|
||||||
|
**`invite` is not a weaker `accounts`** — it is the correct outcome. Vaultwarden derives its encryption
|
||||||
|
key from the master password, so a credential we could mint would mean a vault we could read. Transparent
|
||||||
|
right up to the point where being transparent would be a defect.
|
||||||
|
|
||||||
|
The per-service work is an **interface implemented beside each sidecar**, never a switch in core: a
|
||||||
|
central function growing one case per service is exactly what would stop any of this shipping from its
|
||||||
|
own repository. Implementations must be idempotent — both triggers can fire for the same pair, and
|
||||||
|
creating a second account upstream is not something we can undo.
|
||||||
|
|
||||||
|
Deprovision is deliberately optional and defaults to doing nothing upstream. Deleting a user in Immich
|
||||||
|
deletes their photos; an app store that destroys data as a side effect of an unrelated action is worse
|
||||||
|
than one that leaves a stale account behind.
|
||||||
|
|
||||||
|
**Assumed working:** the vault's own multi-user adaptation is being done separately. Today `/api/vault`
|
||||||
|
is owner-only by an explicit `ownerGate`, so a member is refused before Vaultwarden is reached — this
|
||||||
|
design is written as though that has landed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Phase 0 must not foreclose
|
||||||
|
|
||||||
|
Three things are coming, and each one constrains a decision that looks free today.
|
||||||
|
|
||||||
|
**1. `marketplace.officer.dev`.** Phase 1 keeps the catalogue inside this repo; later the app lists what
|
||||||
|
is on a remote marketplace instead. So catalogue entries must stay **serialisable data** — no functions,
|
||||||
|
no imports, nothing that only means something at compile time. They are plain objects today and must
|
||||||
|
remain so, because the same shape has to arrive as JSON over HTTP. Compose templates travel with them.
|
||||||
|
|
||||||
|
**2. Every sidecar becomes its own repository.** Today `catalogue.test.ts` asserts the catalogue equals
|
||||||
|
"everything in ecosystem.config.cjs that light excludes". That is the right check _now_, and it inverts
|
||||||
|
later: once sidecars live elsewhere, the catalogue entry becomes the source of truth for how to run one
|
||||||
|
(command, args, env) and the ecosystem file is generated from what is installed, not the other way
|
||||||
|
round. **Do not treat that test as a permanent law** — it pins Phase 0's invariant, not the design's.
|
||||||
|
|
||||||
|
**3. Third-party plugins.** Already the reason per-sidecar schema is in scope. It is also why the
|
||||||
|
`service_connections` ID needs namespacing before the marketplace opens, not after.
|
||||||
|
|
||||||
|
The through-line: **nothing in Phase 0 may assume the catalogue is compiled in, or that a sidecar's code
|
||||||
|
is in this repository.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. `user:` in compose, and Podman support for rootless.
|
||||||
|
2. Plugin migrations: who applies them, how versioned, how upgraded.
|
||||||
|
3. `config` JSONB on `service_connections` — or a different escape hatch.
|
||||||
|
4. ID namespacing authority.
|
||||||
|
5. What the app store does when Docker is absent — hide "provision", or refuse to install?
|
||||||
|
6. Does an installed-but-unhealthy sidecar surface in the UI as broken, or as not installed?
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Sidecar setup and bootstrapping
|
||||||
|
|
||||||
|
**Status: LIVE — this document is being written as the investigation runs.** Sections marked
|
||||||
|
`[unverified]` are read from code but not exercised; sections marked `[open]` are questions I have not
|
||||||
|
answered yet. Nothing here proposes a change yet.
|
||||||
|
|
||||||
|
Started 2026-08-10. Scope: how a sidecar comes into existence, how it finds officer, how officer finds
|
||||||
|
it, and what a new one has to do. Everything below was read from the tree or measured on the running
|
||||||
|
machine, not recalled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The shape, in one pass
|
||||||
|
|
||||||
|
A sidecar is a **PM2 peer of `officer`** — never a child. It dials _in_; officer never spawns it.
|
||||||
|
|
||||||
|
```
|
||||||
|
PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
|
||||||
|
→ it opens a WS to officer at /api/sidecar/register
|
||||||
|
→ it sends { type:'register', name, capabilities[] }
|
||||||
|
→ officer replies { type:'registered', id }
|
||||||
|
→ it sends { type:'<name>:server', port } (HTTP sidecars only)
|
||||||
|
→ officer remembers the port and proxies <prefix>/* to it
|
||||||
|
```
|
||||||
|
|
||||||
|
Officer's side of that is `src/servers/sidecar-registry.ts`; the sidecar's side is
|
||||||
|
`src/servers/sidecar/connect.ts`.
|
||||||
|
|
||||||
|
**Nothing in this path is officer starting a process.** `waitForCapability` in the registry says so
|
||||||
|
explicitly — it replaced ~77 lines of spawn-and-poll (`ensureClaudeSidecar`,
|
||||||
|
`spawnAndWaitForRegistration`, and per-email process maps). The only startup problem left is _ordering_,
|
||||||
|
handled by waiting up to 15s for a capability to appear rather than failing the first request after boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The two kinds
|
||||||
|
|
||||||
|
Counted across 18 sidecar directories:
|
||||||
|
|
||||||
|
| Kind | How it is reached | Count |
|
||||||
|
| ------------------------------------------------------------------------------------- | -------------------- | ------------------- |
|
||||||
|
| **HTTP-proxied** — binds a port, officer forwards `<prefix>/*` | `createSidecarProxy` | 16 |
|
||||||
|
| **Command-vocabulary** — no HTTP, answers typed commands over the registration socket | `sendCommand` | 2 (`claude`, `vnc`) |
|
||||||
|
|
||||||
|
The 16 that report a port each declare a `'<name>:server'` event in `protocol.ts` (verified: exactly 16
|
||||||
|
such declarations). `claude` and `vnc` report no port — they are driven entirely by commands.
|
||||||
|
|
||||||
|
`pty` is the odd one out and is worth stating plainly: it is `index.mjs`, run by **node** rather than
|
||||||
|
bun, because `node-pty` is a native addon. It does **not** use `connect.ts` and carries its own copy of
|
||||||
|
the reconnect loop. The ecosystem file says so in a comment, which is the right place for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What officer requires of a new sidecar
|
||||||
|
|
||||||
|
Four things, and three of them fail loudly if missed.
|
||||||
|
|
||||||
|
1. **A PM2 entry** in `ecosystem.config.cjs` (`script: 'bun'`, `args: 'run src/servers/sidecar/<n>/index.ts'`).
|
||||||
|
2. **A registration** with a `name` and `capabilities[]`. Officer indexes by capability, not by name —
|
||||||
|
`findSidecarByCapability` is how every caller reaches one.
|
||||||
|
3. **A `'<name>:server'` event in `protocol.ts`**, if it serves HTTP. Without it the type does not exist
|
||||||
|
and `createSidecarProxy`'s listener never matches.
|
||||||
|
4. **A capability-registry entry**, if it mounts a router. `assertCapabilityTotality` runs in
|
||||||
|
`server.tsx` _before_ `serve()` and **throws**, so a missing entry means the server refuses to boot,
|
||||||
|
naming what is missing. Alternatively an `EXEMPT_API_PREFIXES` entry _with a stated reason_.
|
||||||
|
|
||||||
|
Item 4 is the one that is a deliberate wall rather than a convention, and the reasoning is recorded in
|
||||||
|
`totality.ts`: a Member could 403 on `GET /api/tasks` and open `/api/tasks/pipeline/ws` with a 101 in the
|
||||||
|
same minute, because Bun's route table matches the socket before the `/api/*` catch-all. Refusing to boot
|
||||||
|
survives the next door being added; a patch does not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Details worth knowing before changing any of this
|
||||||
|
|
||||||
|
**Ports are ephemeral and re-reported on every reconnect.** A sidecar binds `port: 0`, reads the port
|
||||||
|
back, then releases and rebinds (`getFreePort` — bind, read, `stop(true)`). Officer stores whatever was
|
||||||
|
last reported. Observed live: the photos sidecar moved 33891 → 34349 → 36337 → 41829 → 33637 across
|
||||||
|
restarts tonight, and officer followed each time. **A stale port is a 502, not a hang** — `getHttpUrl()`
|
||||||
|
returns null before first registration and the proxy answers 503.
|
||||||
|
|
||||||
|
`[unverified]` The bind-read-release in `getFreePort` is a classic TOCTOU: another process can take the
|
||||||
|
port between release and rebind. Never seen it happen here; noting it because it is a real race, not
|
||||||
|
because it has bitten.
|
||||||
|
|
||||||
|
**Re-registration replaces, it does not duplicate.** `registerSidecar` unregisters any existing sidecar
|
||||||
|
with the same _name_ first, so a sidecar that reconnects without officer noticing the old socket die
|
||||||
|
does not leave a ghost.
|
||||||
|
|
||||||
|
**Reconnect is the sidecar's job, with backoff** — `[200, 500, 1000, 2000, 4000, 8000, 15000]`ms in
|
||||||
|
`connect.ts`. Officer does nothing to bring a sidecar back; PM2 restarts the process, the process
|
||||||
|
re-dials.
|
||||||
|
|
||||||
|
**`API_URL` is derived, not configured.** Every sidecar computes
|
||||||
|
`process.env.API_URL ?? ws://127.0.0.1:${process.env.PORT ?? '5000'}`. Note the fallback port is **5000**
|
||||||
|
while this machine runs officer on **9010** — so the default is wrong here and it works only because
|
||||||
|
`.env` supplies `PORT`. `[open]` Is that fallback ever exercised, and should it be a hard failure
|
||||||
|
instead of a wrong guess?
|
||||||
|
|
||||||
|
**One directory, two processes — and this is the easiest thing here to get wrong.** `sidecar/claude/`
|
||||||
|
contains two entrypoints that register as _different sidecars_:
|
||||||
|
|
||||||
|
| File | PM2 entry | Registers as | What it is |
|
||||||
|
| ------------------------- | ------------------------- | ------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| `claude/index.ts` | `officer-anthropic-proxy` | name `proxy`, capability `['proxy']` | Holds the Anthropic credential, forwards API traffic |
|
||||||
|
| `claude/user-instance.ts` | `officer-agent` | capability `['claude']` | The process that actually spawns `claude` |
|
||||||
|
|
||||||
|
So **capability `proxy` is the Anthropic proxy, and capability `claude` is the agent.** Nothing named
|
||||||
|
"claude" registers the `claude` capability from `claude/index.ts`, which is exactly the sort of thing
|
||||||
|
that reads as a bug in a grep and is not one.
|
||||||
|
|
||||||
|
That resolves the special-casing: `isConnected()` returns "a sidecar with capability `proxy` exists" —
|
||||||
|
i.e. **the Anthropic proxy is up**, which is _not_ the same as "the agent is up", though the name reads
|
||||||
|
that way. `[verified]` It currently has **no callers** outside the registry itself, so nothing is
|
||||||
|
misreading it today. Worth either renaming or deleting before something starts trusting the name.
|
||||||
|
|
||||||
|
`registerSidecar` also fires a notification when a registration includes capability `claude`
|
||||||
|
(`sidecar-registry.ts:75`) — "a new agent process has come up". That one is correctly aimed at the agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Duplication, measured
|
||||||
|
|
||||||
|
Each HTTP sidecar's `index.ts` independently contains: the `API_URL` line, `getFreePort`, a `Bun.serve`,
|
||||||
|
an `X-Officer-User` check, a `createSidecarConnector` call, and an `onConnected` that reports the port.
|
||||||
|
Sizes range 73–505 lines (`email` smallest at 73, `music` largest at 505).
|
||||||
|
|
||||||
|
`createSidecarProxy` factored out officer's side of this — the comment records that eight copies of the
|
||||||
|
port capture were byte-identical once the app name was normalised away. **The sidecar side has had no
|
||||||
|
equivalent factoring.** `[open]` Is a `createSidecarServer` worth it, or is the duplication load-bearing
|
||||||
|
because each sidecar's routes differ enough that a shared shell would grow options faster than it saved
|
||||||
|
lines?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions, in the order I would answer them
|
||||||
|
|
||||||
|
1. ~~What provides the `proxy` capability~~ — **answered above**: the Anthropic proxy, not the agent.
|
||||||
|
`isConnected()` has no callers; rename or delete it before its name misleads someone.
|
||||||
|
2. **Is the sidecar-side boilerplate worth factoring**, given `create-proxy.ts` already proved the
|
||||||
|
officer side was?
|
||||||
|
3. **What happens on a partial boot** — officer up, a sidecar permanently down. `waitForCapability`
|
||||||
|
throws after 15s; who catches it, and what does the user see?
|
||||||
|
4. **Is the `PORT ?? '5000'` fallback reachable**, and should it fail loudly instead?
|
||||||
|
5. **`sweepStaleServes` is `/proc`-based and a no-op on macOS** (already noted in the OpenCode parity
|
||||||
|
doc as B8). Does any other sidecar have a Linux-only assumption?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified facts this document rests on
|
||||||
|
|
||||||
|
| Claim | How |
|
||||||
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| 20 PM2 entries, 18 sidecar dirs | `ecosystem.config.cjs`, `ls src/servers/sidecar/` |
|
||||||
|
| 16 sidecars report a port, 2 do not | `grep` for `':server'` in each `index.ts`, cross-checked against 16 declarations in `protocol.ts` |
|
||||||
|
| `pty` is node + `.mjs` + its own reconnect loop | `ecosystem.config.cjs` comment and `ls sidecar/pty/` |
|
||||||
|
| Officer spawns nothing | `waitForCapability` comment; no spawn call in the registry |
|
||||||
|
| Ports change across restarts and officer follows | observed live tonight across five photos restarts |
|
||||||
|
| Boot fails on a missing capability entry | `assertCapabilityTotality` throws before `serve()` |
|
||||||
|
| `sidecar/claude/` is two processes with different capabilities | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
|
||||||
|
| `isConnected()` has no callers outside the registry | grep across `src/servers` |
|
||||||
@@ -13,10 +13,20 @@ officer/
|
|||||||
└── data/ runtime state — NOT version controlled
|
└── data/ runtime state — NOT version controlled
|
||||||
```
|
```
|
||||||
|
|
||||||
Officer is a self-hosted personal platform: an AI agent, a terminal, a file browser, a code editor,
|
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
|
||||||
email, a bitcoin wallet, a remote desktop and dashboards, behind one web app. **It serves exactly one
|
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
|
||||||
person — the owner of this server.** There is no tenancy, no roles, no other users. If a question
|
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
|
||||||
turns on "which user", the answer is the owner.
|
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
|
||||||
|
|
||||||
|
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
|
||||||
|
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
|
||||||
|
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
|
||||||
|
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
|
||||||
|
granted, because they run as the owner's OS user in the owner's home.
|
||||||
|
|
||||||
|
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
|
||||||
|
and five non-owner accounts are live; treat the capability registry as the source of truth over any
|
||||||
|
prose, here or elsewhere.
|
||||||
|
|
||||||
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
|
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
|
||||||
above them: where things live, how to change them safely, and the things that are true of the running
|
above them: where things live, how to change them safely, and the things that are true of the running
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ module.exports = defineProfile({
|
|||||||
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
|
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
|
||||||
// A light install simply is not running the thing behind it.
|
// A light install simply is not running the thing behind it.
|
||||||
excluded: {
|
excluded: {
|
||||||
|
// Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs
|
||||||
|
// nothing installed locally. True, and beside the point: a baseline process appears in the Permissions
|
||||||
|
// screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea
|
||||||
|
// access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which
|
||||||
|
// makes "is Gitea here" one question with one answer instead of two that disagree.
|
||||||
|
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
||||||
'officer-vnc': 'no desktop to mirror on a light install',
|
'officer-vnc': 'no desktop to mirror on a light install',
|
||||||
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
|
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
|
||||||
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
|
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ module.exports = defineProfile({
|
|||||||
// Cannot run on macOS at all.
|
// Cannot run on macOS at all.
|
||||||
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
|
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
|
||||||
|
|
||||||
|
// Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed
|
||||||
|
// locally — it points at a remote instance over the network — but a baseline process shows up in the dock
|
||||||
|
// and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It
|
||||||
|
// is an app-store install now: `existing` mode, URL and token, same as any other remote service.
|
||||||
|
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
|
||||||
|
|
||||||
// Would run, but needs something setup_mac_light.sh deliberately does not install.
|
// Would run, but needs something setup_mac_light.sh deliberately does not install.
|
||||||
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
|
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
|
||||||
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
|
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
|
||||||
|
|||||||
@@ -16,26 +16,10 @@
|
|||||||
|
|
||||||
import { mkdirSync, existsSync } from 'node:fs';
|
import { mkdirSync, existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
// The list and DATA_PATH itself come from the platform rather than being restated here. The owner's
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
// create-account handler provisions the same skeleton, and a script that drifted from it would produce
|
||||||
|
// accounts that differ by how they were made. Importing data-path.ts pulls in no database and no server.
|
||||||
// Mirrors the shape the owner's root grew organically. Most of these are also created on demand by
|
import { DATA_PATH, USER_DIRS } from '../src/servers/data-path';
|
||||||
// whichever feature owns them (attachments, dashboards, email_accounts…), so pre-creating them buys
|
|
||||||
// legibility more than function — the tree shows what a user has without having to use it first.
|
|
||||||
//
|
|
||||||
// `home` is the exception, and the reason this exists at all: nothing creates it today. getOwnerHomeDir
|
|
||||||
// returns process.env.HOME_DIR whenever it is set, which it always is on a real install, so the
|
|
||||||
// per-user home has never actually been reached. It is where a non-owner's sessions will run.
|
|
||||||
const USER_DIRS = [
|
|
||||||
'home',
|
|
||||||
'attachments',
|
|
||||||
'cache',
|
|
||||||
'dashboards',
|
|
||||||
'email_accounts',
|
|
||||||
'general_chat_sessions',
|
|
||||||
'logs',
|
|
||||||
'sidecar',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const DRY_RUN = process.env.DRY_RUN === '1';
|
const DRY_RUN = process.env.DRY_RUN === '1';
|
||||||
const emails = process.argv.slice(2).filter(Boolean);
|
const emails = process.argv.slice(2).filter(Boolean);
|
||||||
|
|||||||
+73
-27
@@ -14,7 +14,7 @@
|
|||||||
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
|
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
|
||||||
#
|
#
|
||||||
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust,
|
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust,
|
||||||
# PulseAudio, cliamp, Neovim, the shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp, and
|
# PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and
|
||||||
# the remote desktop. Of the Docker services only Postgres is brought up.
|
# the remote desktop. Of the Docker services only Postgres is brought up.
|
||||||
#
|
#
|
||||||
# The app itself is identical — every API route stays mounted, so the features whose sidecars
|
# The app itself is identical — every API route stays mounted, so the features whose sidecars
|
||||||
@@ -230,6 +230,37 @@ case $PM in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# uidmap — newuidmap/newgidmap, needed for a member's own rootless Docker.
|
||||||
|
#
|
||||||
|
# Rootless containers map subordinate uid ranges, and those two setuid helpers are the only way to do it
|
||||||
|
# unprivileged. Without them `dockerd-rootless-setuptool.sh` fails at the first step. Core rather than a
|
||||||
|
# profile extra for the same reason acl is: the alternative to a member having their own daemon is adding
|
||||||
|
# them to the `docker` group, which is root on the host — see src/servers/os-user-docker.ts.
|
||||||
|
case $PM in
|
||||||
|
apt)
|
||||||
|
if dpkg -s uidmap &>/dev/null 2>&1; then skip "uidmap"; else CORE_PKGS+=(uidmap); fi
|
||||||
|
if dpkg -s dbus-user-session &>/dev/null 2>&1; then skip "dbus-user-session"; else CORE_PKGS+=(dbus-user-session); fi
|
||||||
|
;;
|
||||||
|
pacman)
|
||||||
|
if has newuidmap; then skip "uidmap (shadow)"; else CORE_PKGS+=(shadow); fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# acl — setfacl/getfacl, needed by per-user Linux accounts.
|
||||||
|
#
|
||||||
|
# A member's home is 700 and owned by them, which is right for a shell and locks the platform out of the
|
||||||
|
# file browser. Named ACL entries are what let both act on the same files without opening the home to every
|
||||||
|
# account on the box; mode bits cannot express it in both directions. Core rather than a profile extra
|
||||||
|
# because the alternative is an account that provisions and then cannot list its own home.
|
||||||
|
case $PM in
|
||||||
|
apt)
|
||||||
|
if dpkg -s acl &>/dev/null 2>&1; then skip "acl"; else CORE_PKGS+=(acl); fi
|
||||||
|
;;
|
||||||
|
pacman|dnf|yum)
|
||||||
|
if has setfacl; then skip "acl"; else CORE_PKGS+=(acl); fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if [ ${#CORE_PKGS[@]} -gt 0 ]; then
|
if [ ${#CORE_PKGS[@]} -gt 0 ]; then
|
||||||
install_pkg "${CORE_PKGS[@]}"
|
install_pkg "${CORE_PKGS[@]}"
|
||||||
ok "Installed: ${CORE_PKGS[*]}"
|
ok "Installed: ${CORE_PKGS[*]}"
|
||||||
@@ -476,13 +507,50 @@ else
|
|||||||
skip "bun symlink at /usr/local/bin/bun"
|
skip "bun symlink at /usr/local/bin/bun"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─── 6b. Starship prompt ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Outside the light-profile skip below, unlike the rest of the terminal tooling. The light profile exists to
|
||||||
|
# serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is also
|
||||||
|
# what every member gets when per-user Linux accounts are on. `src/servers/shell-skel/zshrc` deploys this same
|
||||||
|
# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain
|
||||||
|
# one on exactly the installs most likely to have members.
|
||||||
|
#
|
||||||
|
# One static binary and one config file. oh-my-zsh, eza and lazygit stay in section 12, where `light` skips
|
||||||
|
# them: those are host comforts, and the shell template treats each as optional.
|
||||||
|
echo ""
|
||||||
|
echo "── Prompt (starship) ──"
|
||||||
|
|
||||||
|
# Starship prompt
|
||||||
|
if has starship; then
|
||||||
|
skip "starship"
|
||||||
|
else
|
||||||
|
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
|
||||||
|
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
|
||||||
|
# every run, silently — the nvim step below already gets this right by guarding on the config's
|
||||||
|
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
|
||||||
|
# wrote when there is.
|
||||||
|
mkdir -p "$HOME/.config"
|
||||||
|
STARSHIP_DEST="$HOME/.config/starship.toml"
|
||||||
|
if [ ! -f "$STARSHIP_DEST" ]; then
|
||||||
|
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
|
||||||
|
ok "starship config deployed"
|
||||||
|
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
|
||||||
|
skip "starship config"
|
||||||
|
else
|
||||||
|
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
|
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
|
||||||
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
|
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
|
||||||
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
|
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
|
||||||
# terminal and a chat.
|
# terminal and a chat.
|
||||||
if is_light; then
|
if is_light; then
|
||||||
echo ""
|
echo ""
|
||||||
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp"
|
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp"
|
||||||
else
|
else
|
||||||
|
|
||||||
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -669,32 +737,10 @@ else
|
|||||||
ok "LazyVim starter installed at ~/.config/nvim"
|
ok "LazyVim starter installed at ~/.config/nvim"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─── 12. Terminal tools ──────────────────────────────────────────────────────
|
# ─── 12. Terminal tools (starship is section 6b, outside the light skip) ─────
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──"
|
echo "── Terminal tools (oh-my-zsh, eza, lazygit) ──"
|
||||||
|
|
||||||
# Starship prompt
|
|
||||||
if has starship; then
|
|
||||||
skip "starship"
|
|
||||||
else
|
|
||||||
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
|
|
||||||
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
|
|
||||||
# every run, silently — the nvim step below already gets this right by guarding on the config's
|
|
||||||
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
|
|
||||||
# wrote when there is.
|
|
||||||
mkdir -p "$HOME/.config"
|
|
||||||
STARSHIP_DEST="$HOME/.config/starship.toml"
|
|
||||||
if [ ! -f "$STARSHIP_DEST" ]; then
|
|
||||||
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
|
|
||||||
ok "starship config deployed"
|
|
||||||
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
|
|
||||||
skip "starship config"
|
|
||||||
else
|
|
||||||
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Oh-My-Zsh
|
# Oh-My-Zsh
|
||||||
if [ -d "$HOME/.oh-my-zsh" ]; then
|
if [ -d "$HOME/.oh-my-zsh" ]; then
|
||||||
@@ -809,7 +855,7 @@ else
|
|||||||
skip "claude (claude-code)"
|
skip "claude (claude-code)"
|
||||||
else
|
else
|
||||||
echo " Installing claude-code via Anthropic installer..."
|
echo " Installing claude-code via Anthropic installer..."
|
||||||
curl -fsSL https://claude.ai/install.sh | sh
|
curl -fsSL https://claude.ai/install.sh | bash # bash, not sh: a piped script ignores its shebang and install.sh is bash
|
||||||
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
|
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ export function App() {
|
|||||||
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
|
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
|
||||||
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
|
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||||
<Route path="/plans" element={<Dashboard.Plans />} />
|
|
||||||
<Route path="/plans/:name" element={<Dashboard.Plans />} />
|
|
||||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||||
@@ -65,6 +63,7 @@ export function App() {
|
|||||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||||
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
|
||||||
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
|
||||||
|
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
|
||||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
import { WorkspaceView } from 'officerdev';
|
||||||
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
|
// /app-store — what this server can run, what it is running, and the four verbs that change it.
|
||||||
|
//
|
||||||
|
// Owner-only, and gated server-side: every route under /api/app-store refuses a non-owner before it
|
||||||
|
// reaches a handler. This screen is the courtesy half of that, and would show an empty store rather
|
||||||
|
// than a working one if it were ever reached by someone else.
|
||||||
|
//
|
||||||
|
// Which app is open lives in `?selected=`, read by both panels independently rather than passed between
|
||||||
|
// them — the list and the detail cannot disagree if neither is telling the other anything.
|
||||||
|
export const AppStoreScreen = () => {
|
||||||
|
const workspace = useDashboardState<LayoutNode>('screens/app-store', defaultLayout);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full pt-2">
|
||||||
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
locked
|
||||||
|
appTypes={{ allowed: ['app-store-list', 'app-store-detail'], fallback: 'app-store-detail' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
// List on the left, detail on the right — a master list with a live preview, which is why the selection
|
||||||
|
// is `?selected=` rather than a detail route: linking rows to /app-store/:id would make the detail the
|
||||||
|
// whole page and destroy the side-by-side. See docs/navigation-audit.md.
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'app-store-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'app-store-list', appType: 'app-store-list' }, size: 30 },
|
||||||
|
{ node: { type: 'panel', id: 'app-store-detail', appType: 'app-store-detail' }, size: 70 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './AppStoreScreen';
|
||||||
@@ -1,11 +1,31 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
/** Does this tree contain a panel running `appType`? */
|
||||||
|
export function hasAppType(node: LayoutNode, appType: string): boolean {
|
||||||
|
if (node.type === 'panel') return node.appType === appType;
|
||||||
|
return node.children.some((child) => hasAppType(child.node, appType));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The left column is itself a split: what is running now on top, the whole history below. They answer
|
||||||
|
// different questions from different sources — the agent's in-memory map versus transcripts on disk —
|
||||||
|
// and the live one is small and usually empty, hence the lopsided 25/75.
|
||||||
export const defaultLayout: LayoutNode = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: 'chat-history-root',
|
id: 'chat-history-root',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
children: [
|
||||||
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 35 },
|
{
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'chat-sidebar',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'chat-live', appType: 'chat-live' }, size: 25 },
|
||||||
|
{ node: { type: 'panel', id: 'chat-list', appType: 'chat-session-list' }, size: 75 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: 35,
|
||||||
|
},
|
||||||
{ node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 },
|
{ node: { type: 'panel', id: 'chat-detail', appType: 'chat-detail' }, size: 65 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout, hasAppType } from './defaultLayout';
|
||||||
|
|
||||||
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
||||||
const CHAT_TAIL = 20;
|
const CHAT_TAIL = 20;
|
||||||
@@ -32,6 +32,22 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
||||||
|
|
||||||
|
// Adopt a structural change to this screen's layout.
|
||||||
|
//
|
||||||
|
// `useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened
|
||||||
|
// /chat keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs
|
||||||
|
// which app a panel runs, never the tree, so adding the Live panel above the list would have been
|
||||||
|
// invisible to every existing user and visible only on a fresh account.
|
||||||
|
//
|
||||||
|
// Replacing outright is safe *here* specifically because the screen is `locked`: its structure is
|
||||||
|
// dictated by code and the only thing a user can have contributed is the column sizes, which is a
|
||||||
|
// cheap thing to lose once. Terminates because the replacement contains the panel it tests for.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace.isLoaded) return;
|
||||||
|
if (hasAppType(workspace.value, 'chat-live')) return;
|
||||||
|
workspace.setValue(defaultLayout);
|
||||||
|
}, [workspace.isLoaded, workspace.value, workspace.setValue]);
|
||||||
|
|
||||||
// Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests
|
// Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests
|
||||||
// the address bar verbatim — so one left over from before the path-based groups sits there forever,
|
// the address bar verbatim — so one left over from before the path-based groups sits there forever,
|
||||||
// looking like it means something. On a bare /chat it still says which group you wanted, so upgrade
|
// looking like it means something. On a bare /chat it still says which group you wanted, so upgrade
|
||||||
@@ -101,7 +117,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
locked
|
locked
|
||||||
appTypes={{ allowed: ['chat-session-list', 'chat-detail'], fallback: 'chat-detail' }}
|
appTypes={{ allowed: ['chat-session-list', 'chat-live', 'chat-detail'], fallback: 'chat-detail' }}
|
||||||
mobilePanelId={mobilePanelId}
|
mobilePanelId={mobilePanelId}
|
||||||
onMobilePanelChange={(id) => {
|
onMobilePanelChange={(id) => {
|
||||||
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
|
||||||
|
|||||||
@@ -1,36 +1,46 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { useDock, MusicPlayerHost } from 'officerdev';
|
import { useDock, MusicPlayerHost, usePanelFullscreen } from 'officerdev';
|
||||||
import { useCapabilities } from 'hooks/useCapabilities';
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||||
import { Background } from './Background';
|
import { Background } from './Background';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
|
||||||
import { useIsTouch } from './useIsTouch';
|
import { useIsTouch } from './useIsTouch';
|
||||||
import { usePageTitleSync } from '@/state/usePageTitle';
|
import { usePageTitleSync } from '@/state/usePageTitle';
|
||||||
|
import { RouteGate } from './RouteGate';
|
||||||
|
|
||||||
type DashboardLayoutProps = {
|
type DashboardLayoutProps = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
const { canVisit } = useCapabilities();
|
const { canVisit, plugins } = useCapabilities();
|
||||||
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
|
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
|
||||||
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
|
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
|
||||||
// either way — every one of these routes is refused server-side too — but an app that offers a door it
|
// either way — every one of these routes is refused server-side too — but an app that offers a door it
|
||||||
// will then slam is worse than one that never showed it.
|
// will then slam is worse than one that never showed it.
|
||||||
const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]);
|
// The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
|
||||||
|
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
|
||||||
|
const permitted = useMemo(
|
||||||
|
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
|
||||||
|
[canVisit, plugins],
|
||||||
|
);
|
||||||
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
|
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
usePageTitleSync();
|
usePageTitleSync();
|
||||||
|
// A panel can maximize into the content region on its own, but it cannot paint over this header — the
|
||||||
|
// region below is an `absolute z-2` stacking context and the header is a `fixed z-10` sibling of it. So
|
||||||
|
// "full screen" is cooperative: the panel asks, and the chrome steps aside.
|
||||||
|
const panelFullscreen = usePanelFullscreen();
|
||||||
// The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
|
// The content region shrinks when the (in-flow) music dock takes its space; the nav dock measures its
|
||||||
// reveal boundary from this element, so it always sits just above whatever's at the bottom.
|
// reveal boundary from this element, so it always sits just above whatever's at the bottom.
|
||||||
const regionRef = useRef<HTMLElement | null>(null);
|
const regionRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
|
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
|
||||||
<Header dockItems={visibleItems} />
|
<Header dockItems={visibleItems} hidden={panelFullscreen} />
|
||||||
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
{/* overflow-CLIP, not hidden: `hidden` still makes this a scroll container, and the nav Dock —
|
||||||
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
absolute, parked below the bottom edge by translateY while hidden — adds its transformed box to
|
||||||
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
the scrollable overflow. So clicking a link let the browser scroll this section ~70px to reveal
|
||||||
@@ -46,7 +56,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
resetKeys={[pathname]}
|
resetKeys={[pathname]}
|
||||||
fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />}
|
fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />}
|
||||||
>
|
>
|
||||||
{children}
|
{/* Inside the boundary and around every screen, so one place decides whether a route exists for
|
||||||
|
this account on this server. Filtering the dock was never enough: the tile was hidden and the
|
||||||
|
route still rendered for anyone who typed it, followed an old link or restored a tab. */}
|
||||||
|
<RouteGate>{children}</RouteGate>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { NavLink } from 'react-router';
|
import { NavLink } from 'react-router';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { resolveIcon } from 'officerdev';
|
||||||
|
import type { PluginManifest } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
export type DockItem = {
|
export type DockItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -147,34 +149,67 @@ import {
|
|||||||
Contact,
|
Contact,
|
||||||
Clapperboard,
|
Clapperboard,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
Store,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
/**
|
||||||
|
* The dock items that belong to the SHELL — present on every install, with no sidecar behind them.
|
||||||
|
*
|
||||||
|
* Everything else is contributed by an installed sidecar's UI manifest and arrives from
|
||||||
|
* `/capabilities` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be
|
||||||
|
* installed and uninstalled must not be hardcoded here, or the dock would list things this server does
|
||||||
|
* not have and the shell would need editing every time a sidecar is added.
|
||||||
|
*
|
||||||
|
* These are the baseline — chat, files, the terminal and the app's own screens — plus Gitea, which is in
|
||||||
|
* the light profile because it fronts a remote instance and installs nothing locally.
|
||||||
|
*/
|
||||||
|
export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||||
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
|
|
||||||
{ label: 'Calendar', to: '/calendar', icon: CalendarDays, color: '#3b82f6' },
|
|
||||||
{ label: 'Contacts', to: '/contacts', icon: Contact, color: '#0ea5e9' },
|
|
||||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||||
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
|
||||||
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
|
|
||||||
{ label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' },
|
|
||||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
|
||||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
|
||||||
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
|
||||||
{ label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' },
|
|
||||||
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
|
|
||||||
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
||||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
|
||||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
||||||
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
|
|
||||||
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
|
||||||
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||||
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||||
|
// 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/dashboards', '/chat'];
|
/**
|
||||||
|
* Turn the manifests of installed sidecars into dock tiles.
|
||||||
|
*
|
||||||
|
* `resolveIcon` maps a NAME to a glyph, which is why manifests carry names rather than imports — they
|
||||||
|
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
|
||||||
|
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
|
||||||
|
*/
|
||||||
|
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
|
||||||
|
return plugins.flatMap((plugin) => {
|
||||||
|
const tile = (t: { name: string; icon?: string; image?: string; color: string; route: string }): DockItem => ({
|
||||||
|
label: t.name,
|
||||||
|
to: t.route,
|
||||||
|
color: t.color,
|
||||||
|
...(t.image ? { image: t.image } : { icon: resolveIcon(t.icon ?? 'Box') }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
tile({ name: plugin.name, icon: plugin.icon, image: plugin.image, color: plugin.color, route: plugin.rootRoute }),
|
||||||
|
...(plugin.extraTiles ?? []).map(tile),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is pinned before anyone has chosen. Deliberately drawn only from CORE_DOCK_ITEMS.
|
||||||
|
*
|
||||||
|
* This used to pin `/music`, which is now an installable sidecar. `useDock` drops a path with no item
|
||||||
|
* behind it, so nothing breaks — the default dock just quietly comes up one tile short on a machine
|
||||||
|
* where Music was never installed. Defaults that reference optional features are how an app ends up
|
||||||
|
* looking subtly wrong on a fresh install for no stated reason.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat'];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, NavLink } from 'react-router';
|
import { Link, NavLink } from 'react-router';
|
||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
|
import { EditableTitle } from '@/components/EditableTitle';
|
||||||
// import { Menu, Terminal } from 'lucide-react'; // Terminal used by the commented-out web inspector button
|
// import { Menu, Terminal } from 'lucide-react'; // Terminal used by the commented-out web inspector button
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
import type { DockItem } from '../Dock';
|
import type { DockItem } from '../Dock';
|
||||||
@@ -12,55 +13,40 @@ import { usePageTitle } from '@/state/usePageTitle';
|
|||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||||
|
|
||||||
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
||||||
// refresh and navigation and dies with the tab — see usePageTitle.
|
// refresh and navigation and dies with the tab — see usePageTitle. `allowEmpty` is what makes clearing
|
||||||
|
// the field hand the tab back to the route's own name.
|
||||||
function EditablePageTitle() {
|
function EditablePageTitle() {
|
||||||
const [title, rename] = usePageTitle();
|
const [title, rename] = usePageTitle();
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// The edit is a draft committed on blur/Enter rather than a live write. Writing every keystroke was
|
|
||||||
// fine while the title was a plain string, but an empty field now means "use the route name", so
|
|
||||||
// deleting the last character would snap the input to "Chat" underneath the cursor.
|
|
||||||
if (draft !== null) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={draft}
|
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
rename(draft);
|
|
||||||
setDraft(null);
|
|
||||||
}}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') ev.currentTarget.blur();
|
|
||||||
if (ev.key === 'Escape') setDraft(null); // discard, keeping whatever the tab was called
|
|
||||||
}}
|
|
||||||
aria-label="Tab name"
|
|
||||||
placeholder={title}
|
|
||||||
className="pointer-events-auto max-w-[50vw] border-b border-[#1d2724]/40 bg-transparent text-center text-base font-bold text-[#1d2724] outline-none md:text-xl"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<button
|
<EditableTitle
|
||||||
onClick={() => setDraft(title)}
|
value={title}
|
||||||
title="Click to rename this tab — clear it to go back to the page name"
|
onCommit={rename}
|
||||||
|
allowEmpty
|
||||||
|
ariaLabel="Tab name"
|
||||||
|
hint="Click to rename this tab — clear it to go back to the page name"
|
||||||
className="pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold text-[#1d2724] transition-opacity hover:opacity-70 md:text-xl"
|
className="pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold text-[#1d2724] transition-opacity hover:opacity-70 md:text-xl"
|
||||||
>
|
inputClassName="pointer-events-auto max-w-[50vw] border-b border-[#1d2724]/40 bg-transparent text-center text-base font-bold text-[#1d2724] outline-none md:text-xl"
|
||||||
{title}
|
/>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
dockItems?: DockItem[];
|
dockItems?: DockItem[];
|
||||||
|
/**
|
||||||
|
* Stand down for a panel that has taken the whole window. `display: none` rather than an unmount: the
|
||||||
|
* header holds a half-typed tab rename and its own popovers, and a panel going fullscreen is not a
|
||||||
|
* reason to throw those away. It is `fixed`, so hiding it costs no layout shift either.
|
||||||
|
*/
|
||||||
|
hidden?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Header({ dockItems }: HeaderProps) {
|
export function Header({ dockItems, hidden }: HeaderProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed z-10 w-full">
|
<header className={`fixed z-10 w-full ${hidden ? 'hidden' : ''}`}>
|
||||||
<div
|
<div
|
||||||
className="relative shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
|
className="relative shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
|
||||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
|
||||||
|
|||||||
@@ -2,21 +2,41 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { Loader2, ListOrdered } from 'lucide-react';
|
import { Loader2, ListOrdered } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
type Counts = { running: number; runningJobId: string | null; queued: number };
|
type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||||
|
|
||||||
// Always-present header badges: how many jobs are running (→ the running job) and queued (→ the queue).
|
// Header badges: how many jobs are running (→ the running job) and queued (→ the queue).
|
||||||
|
//
|
||||||
|
// Shown only to an account that holds `tasks`, which today means the owner — the queue runs scripts as the
|
||||||
|
// server owner and is `kind: 'execution'`. It used to render for everyone and poll `/jobs/counts` every
|
||||||
|
// three seconds regardless, so a member's console filled with 403s at 20 a minute and the header offered two
|
||||||
|
// links to a screen they cannot open. Neither is a security problem; both are the app lying about what it is.
|
||||||
export const JobsIndicator = () => {
|
export const JobsIndicator = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const { can } = useCapabilities();
|
||||||
|
const allowed = can('tasks');
|
||||||
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Guarded inside the effect as well as at the render below, because the timer is the expensive half:
|
||||||
|
// an early return in the body would still leave an interval running from a previous render.
|
||||||
|
if (!allowed) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () => client.get<Counts>('/jobs/counts').then((c) => alive && setCounts(c)).catch(() => {});
|
const load = () =>
|
||||||
|
client
|
||||||
|
.get<Counts>('/jobs/counts')
|
||||||
|
.then((c) => alive && setCounts(c))
|
||||||
|
.catch(() => {});
|
||||||
load();
|
load();
|
||||||
const timer = setInterval(load, 3000);
|
const timer = setInterval(load, 3000);
|
||||||
return () => { alive = false; clearInterval(timer); };
|
return () => {
|
||||||
}, []);
|
alive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [allowed]);
|
||||||
|
|
||||||
|
if (!allowed) return null;
|
||||||
|
|
||||||
const pill = 'flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold tabular-nums transition-colors';
|
const pill = 'flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold tabular-nums transition-colors';
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RotateCw } from 'lucide-react';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
||||||
|
|
||||||
@@ -13,8 +14,14 @@ const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'process
|
|||||||
export function RescanButton() {
|
export function RescanButton() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const { can } = useCapabilities();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
|
||||||
|
// owner's disk, `kind: 'execution'`. A member pressing this got a 403 and a red toast about a feature
|
||||||
|
// whose existence is not their business.
|
||||||
|
if (!can('items')) return null;
|
||||||
|
|
||||||
const rescan = async () => {
|
const rescan = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router';
|
||||||
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
|
||||||
|
// A screen exists only if this server has the thing behind it and this account may reach it. Otherwise the
|
||||||
|
// path is treated exactly as an unknown one: redirect home, same as App.tsx's `path="*"`.
|
||||||
|
//
|
||||||
|
// ── Why a redirect and not an explanation ──
|
||||||
|
//
|
||||||
|
// The first version of this rendered a panel saying "Music is not installed" with a link to the app store,
|
||||||
|
// on the reasoning that a redirect erases what you asked for. That was wrong, and the owner's correction is
|
||||||
|
// the better principle: a naked platform should not know about a sidecar it does not have. Explaining the
|
||||||
|
// absence of Music is the app describing a feature that, as far as this server is concerned, does not exist —
|
||||||
|
// and it leaks the whole catalogue of what could be installed to every member who types a URL.
|
||||||
|
//
|
||||||
|
// So "no such page" is the honest answer, and it is the same answer for a member without a grant, for an
|
||||||
|
// owner whose sidecar is not installed, and for a typo. One behaviour, nothing disclosed.
|
||||||
|
//
|
||||||
|
// This is still a courtesy rather than the lock — every one of these routes is refused server-side too. What
|
||||||
|
// it stops is the app offering a door it will then slam.
|
||||||
|
//
|
||||||
|
// ── What this is NOT ──
|
||||||
|
//
|
||||||
|
// Routes are still declared in App.tsx for every screen, and this hides the ones that should not resolve. The
|
||||||
|
// end state the owner described is different and better: routes REGISTERED from the manifests of installed
|
||||||
|
// sidecars, so an uninstalled feature has no route to hide. The manifests already exist (`plugins`, carrying
|
||||||
|
// `rootRoute` and `routes`) and the dock is already built from them; the router is not, yet.
|
||||||
|
|
||||||
|
export function RouteGate({ children }: { children?: React.ReactNode }) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { denialReason } = useCapabilities();
|
||||||
|
|
||||||
|
// `replace`, so Back does not bounce between the denied path and home.
|
||||||
|
if (denialReason(pathname)) return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useNavigate, useParams } from 'react-router';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import rehypeRaw from 'rehype-raw';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { Card } from '@/components/Card';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A plan is a markdown document on disk, so it gets an address: `/plans/:name`. No redirect guard — the
|
|
||||||
* bare route is "no plan open" and a name that no longer exists gets the empty pane, not a rewritten URL.
|
|
||||||
*
|
|
||||||
* The picker stays a native `<select>` rather than becoming a link list. It is chrome for one document,
|
|
||||||
* not a master list, and a `<select>` is the right control for that on a phone; it navigates instead of
|
|
||||||
* setting state, which is what M4 was actually about.
|
|
||||||
*/
|
|
||||||
export const Plans = () => {
|
|
||||||
const client = useClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const selected = useParams<{ name: string }>().name ?? null;
|
|
||||||
|
|
||||||
const { data: plans = [] } = useQuery<string[]>({
|
|
||||||
queryKey: ['plans'],
|
|
||||||
queryFn: () => client.get<string[]>('/plans'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: content = '' } = useQuery<string>({
|
|
||||||
queryKey: ['plans', selected],
|
|
||||||
queryFn: () => client.getText(`/plans/${encodeURIComponent(selected!)}`),
|
|
||||||
enabled: !!selected,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full p-4">
|
|
||||||
<Card className="flex-1 overflow-hidden">
|
|
||||||
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
|
|
||||||
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
|
|
||||||
{plans.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={selected ?? ''}
|
|
||||||
onChange={(ev) => navigate(`/plans/${encodeURIComponent(ev.target.value)}`)}
|
|
||||||
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
|
|
||||||
>
|
|
||||||
{/* Only while nothing is chosen: it disappears once you pick, so it can never be picked back. */}
|
|
||||||
{!selected && <option value="">Select a plan…</option>}
|
|
||||||
{plans.map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-y-auto h-full p-6">
|
|
||||||
{selected ? (
|
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none prose-td:text-sm prose-th:text-sm">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-duck-dark/50">
|
|
||||||
{plans.length === 0 ? 'No plans yet.' : 'Pick a plan to read it.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Copy, Ban, Plus, KeyRound, Loader2 } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
// Your own API keys: one per app or device, so a phone holds a credential you can revoke on its own
|
||||||
|
// instead of a session everything shares.
|
||||||
|
//
|
||||||
|
// The key is returned by POST and never again — the column holds a SHA-256, so there is nothing to read
|
||||||
|
// back. That drives the whole screen: the new key sits in a panel that stays put until dismissed, because
|
||||||
|
// the moment it disappears the only remedy is to revoke and mint another. Same reasoning as the DAV app
|
||||||
|
// passwords beside this.
|
||||||
|
//
|
||||||
|
// A key carries your full account authority. It is not more than you already had — it is what your
|
||||||
|
// password could already do — but it does mean a leaked key is a leaked account, so revoke is one click
|
||||||
|
// and "last used" is on every row: a key that has never been used is the tell that something was set up
|
||||||
|
// wrong, and one used from somewhere you did not expect is the tell that matters more.
|
||||||
|
|
||||||
|
type ApiKey = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
revokedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Minted = { key: string; name: string };
|
||||||
|
|
||||||
|
const API_KEYS_QUERY_KEY = ['API_KEYS'];
|
||||||
|
|
||||||
|
const formatDate = (value: string | null) =>
|
||||||
|
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
||||||
|
|
||||||
|
const copy = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success('Key copied');
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy — select and copy manually');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApiKeys = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [minted, setMinted] = useState<Minted | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<{ keys: ApiKey[] }>({
|
||||||
|
queryKey: API_KEYS_QUERY_KEY,
|
||||||
|
queryFn: () => client.get<{ keys: ApiKey[] }>('/api-keys'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed || isCreating) return;
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await client.post<{ key: string }>('/api-keys', { name: trimmed });
|
||||||
|
setMinted({ key: res.key, name: trimmed });
|
||||||
|
setName('');
|
||||||
|
await refresh();
|
||||||
|
} catch (ex) {
|
||||||
|
toast.error(ex instanceof Error ? ex.message : 'Could not create the key');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await client.delete(`/api-keys/${id}`);
|
||||||
|
toast.success('Revoked — anything using that key is signed out');
|
||||||
|
await refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not revoke');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const keys = data?.keys ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||||
|
An API key signs in as you, without a password and without expiring. Give each app or device its own, so you can
|
||||||
|
cut one off without touching the rest. Send it as{' '}
|
||||||
|
<code className="font-mono text-xs">Authorization: Bearer …</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{minted && (
|
||||||
|
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
|
||||||
|
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||||
|
Key for “{minted.name}” — shown once
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||||
|
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
|
||||||
|
revoke this entry and make another.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
||||||
|
{minted.key}
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key">
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
|
||||||
|
I've saved it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Label className="grid flex-1 gap-2">
|
||||||
|
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
|
||||||
|
<Input
|
||||||
|
className="h-11 bg-background/60 border-duck-dark/20"
|
||||||
|
value={name}
|
||||||
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => ev.key === 'Enter' && create()}
|
||||||
|
placeholder="iPhone, Music app, laptop CLI…"
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Button onClick={create} disabled={!name.trim() || isCreating} className="h-11">
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
No keys yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{keys.map((entry) => {
|
||||||
|
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
|
||||||
|
const dead = !!entry.revokedAt || expired;
|
||||||
|
const lastUsed = formatDate(entry.lastUsedAt);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`flex items-center gap-3 rounded-lg border p-3 ${
|
||||||
|
dead
|
||||||
|
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
|
||||||
|
: 'border-duck-dark/15 dark:border-foreground/15'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4 shrink-0 text-duck-teal/70" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-duck-dark dark:text-foreground">
|
||||||
|
{entry.name}
|
||||||
|
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>}
|
||||||
|
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
<code className="font-mono">{entry.prefix}…</code>
|
||||||
|
{' · '}
|
||||||
|
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
|
||||||
|
{lastUsed ? `last used ${lastUsed}` : 'never used'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!dead && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => revoke(entry.id)}
|
||||||
|
title="Revoke"
|
||||||
|
className="hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Ban className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { BrowserRelay } from './BrowserRelay';
|
|||||||
import { ApifyConfig } from './ApifyConfig';
|
import { ApifyConfig } from './ApifyConfig';
|
||||||
import { EmailAccounts } from './EmailAccounts';
|
import { EmailAccounts } from './EmailAccounts';
|
||||||
import { DavAppPasswords } from './DavAppPasswords';
|
import { DavAppPasswords } from './DavAppPasswords';
|
||||||
|
import { ApiKeys } from './ApiKeys';
|
||||||
|
|
||||||
const BASE_PATH = '/settings/integrations';
|
const BASE_PATH = '/settings/integrations';
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ const personalSections: SettingsSection[] = [
|
|||||||
description: 'Per-device passwords for CalDAV/CardDAV clients',
|
description: 'Per-device passwords for CalDAV/CardDAV clients',
|
||||||
content: <DavAppPasswords />,
|
content: <DavAppPasswords />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'api-keys',
|
||||||
|
icon: KeyRound,
|
||||||
|
title: 'API keys',
|
||||||
|
description: 'Per-app keys that sign in as you, revocable one at a time',
|
||||||
|
content: <ApiKeys />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'browser-relay',
|
key: 'browser-relay',
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback, type DragEvent } from 'react';
|
import { useMemo, useState, useCallback, type DragEvent } from 'react';
|
||||||
import { X, Plus, RotateCcw } from 'lucide-react';
|
import { X, Plus, RotateCcw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useDock } from 'officerdev';
|
import { useDock } from 'officerdev';
|
||||||
import { ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
|
import { useCapabilities } from 'hooks/useCapabilities';
|
||||||
|
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
|
||||||
|
|
||||||
type DockPillProps = {
|
type DockPillProps = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,14 +19,32 @@ type DockPillProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DockPill = ({
|
const DockPill = ({
|
||||||
label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill,
|
label,
|
||||||
|
path,
|
||||||
|
color,
|
||||||
|
visible,
|
||||||
|
onAction,
|
||||||
|
onDragStart,
|
||||||
|
onDropOnPill,
|
||||||
|
dropIndicator,
|
||||||
|
onDragOverPill,
|
||||||
|
onDragLeavePill,
|
||||||
}: DockPillProps) => (
|
}: DockPillProps) => (
|
||||||
<span
|
<span
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(ev) => onDragStart(ev, path)}
|
onDragStart={(ev) => onDragStart(ev, path)}
|
||||||
onDragOver={onDragOverPill}
|
onDragOver={onDragOverPill}
|
||||||
onDragLeave={onDragLeavePill}
|
onDragLeave={onDragLeavePill}
|
||||||
onDrop={onDropOnPill ? (ev) => { ev.preventDefault(); ev.stopPropagation(); const p = ev.dataTransfer.getData('text/plain'); if (p) onDropOnPill(p); } : undefined}
|
onDrop={
|
||||||
|
onDropOnPill
|
||||||
|
? (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
const p = ev.dataTransfer.getData('text/plain');
|
||||||
|
if (p) onDropOnPill(p);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`}
|
className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`}
|
||||||
>
|
>
|
||||||
{dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
{dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
|
||||||
@@ -69,7 +88,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span>
|
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -83,7 +104,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DockSettings = () => {
|
export const DockSettings = () => {
|
||||||
const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
|
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
|
||||||
|
// pin a tile that cannot appear, which reads as the setting being broken.
|
||||||
|
const { plugins } = useCapabilities();
|
||||||
|
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
|
||||||
|
const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS);
|
||||||
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
|
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
|
||||||
|
|
||||||
const visiblePaths = new Set(items.map((i) => i.to));
|
const visiblePaths = new Set(items.map((i) => i.to));
|
||||||
@@ -135,7 +160,10 @@ export const DockSettings = () => {
|
|||||||
|
|
||||||
const handlePillDrop = useCallback(
|
const handlePillDrop = useCallback(
|
||||||
(draggedPath: string, targetPath: string) => {
|
(draggedPath: string, targetPath: string) => {
|
||||||
if (draggedPath === targetPath) { setDropTarget(null); return; }
|
if (draggedPath === targetPath) {
|
||||||
|
setDropTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
|
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
|
||||||
insertAt(draggedPath, targetPath, side);
|
insertAt(draggedPath, targetPath, side);
|
||||||
},
|
},
|
||||||
@@ -163,7 +191,11 @@ export const DockSettings = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<DropZone label="Visible" onDrop={onDropVisible}>
|
<DropZone label="Visible" onDrop={onDropVisible}>
|
||||||
{items.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag items here to show in dock</span>}
|
{items.length === 0 && (
|
||||||
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
|
||||||
|
Drag items here to show in dock
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<DockPill
|
<DockPill
|
||||||
key={item.to}
|
key={item.to}
|
||||||
@@ -182,7 +214,9 @@ export const DockSettings = () => {
|
|||||||
</DropZone>
|
</DropZone>
|
||||||
|
|
||||||
<DropZone label="Hidden" onDrop={onDropHidden}>
|
<DropZone label="Hidden" onDrop={onDropHidden}>
|
||||||
{hiddenItems.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>}
|
{hiddenItems.length === 0 && (
|
||||||
|
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
|
||||||
|
)}
|
||||||
{hiddenItems.map((item) => (
|
{hiddenItems.map((item) => (
|
||||||
<DockPill
|
<DockPill
|
||||||
key={item.to}
|
key={item.to}
|
||||||
@@ -196,12 +230,7 @@ export const DockSettings = () => {
|
|||||||
))}
|
))}
|
||||||
</DropZone>
|
</DropZone>
|
||||||
|
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={reset}
|
|
||||||
className="w-full h-9 text-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
|
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Reset to defaults
|
Reset to defaults
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Loader2, UserPlus, Dices, Copy, X } from 'lucide-react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
|
||||||
|
//
|
||||||
|
// The password is visible, not masked, and that is the point: the owner has to be able to read it back
|
||||||
|
// to the person they are creating it for. Masking a value nobody has yet only makes it easy to typo
|
||||||
|
// twice. When there is an invite flow this whole field goes away.
|
||||||
|
|
||||||
|
type CreateUserFormProps = {
|
||||||
|
/** Roles the server will actually accept. Excludes the owner role — see manage-users.ts. */
|
||||||
|
roles: string[];
|
||||||
|
/** Invalidated on success so the list below refreshes. */
|
||||||
|
usersKey: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors validatePassword on the server: length, both cases, a digit and a symbol. Generated rather
|
||||||
|
// than demanded so the owner is not sitting there inventing one that passes.
|
||||||
|
function generatePassword(): string {
|
||||||
|
const lower = 'abcdefghijkmnopqrstuvwxyz';
|
||||||
|
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||||
|
const digits = '23456789';
|
||||||
|
const symbols = '!@#$%^&*-_=+';
|
||||||
|
const all = lower + upper + digits + symbols;
|
||||||
|
|
||||||
|
const pick = (set: string, count: number) =>
|
||||||
|
Array.from(crypto.getRandomValues(new Uint32Array(count)), (n) => set[n % set.length]!);
|
||||||
|
|
||||||
|
// One of each class first, so the result cannot fail the server's rules by chance, then filled out.
|
||||||
|
const chars = [...pick(lower, 4), ...pick(upper, 3), ...pick(digits, 3), ...pick(symbols, 2), ...pick(all, 8)];
|
||||||
|
|
||||||
|
// Shuffled so the classes are not in fixed positions. Fisher-Yates with crypto randomness.
|
||||||
|
const noise = crypto.getRandomValues(new Uint32Array(chars.length));
|
||||||
|
for (let i = chars.length - 1; i > 0; i--) {
|
||||||
|
const j = noise[i]! % (i + 1);
|
||||||
|
[chars[i], chars[j]] = [chars[j]!, chars[i]!];
|
||||||
|
}
|
||||||
|
return chars.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY = { email: '', name: '', username: '', password: '', role: 'Member', sshPublicKey: '' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the server did, shown after the fact.
|
||||||
|
*
|
||||||
|
* Kept on screen rather than announced in a toast because two of these values are only obtainable now: the
|
||||||
|
* password is stored as an argon2 hash, and the generated public key sits in a 700 home. A toast that
|
||||||
|
* carries something unrecoverable is a toast that gets dismissed by a stray click.
|
||||||
|
*/
|
||||||
|
type CreatedAccount = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
osUser: string | null;
|
||||||
|
osSshPublicKey: string | null;
|
||||||
|
osUserError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
|
||||||
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState(EMPTY);
|
||||||
|
const [created, setCreated] = useState<CreatedAccount | null>(null);
|
||||||
|
|
||||||
|
const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setForm(EMPTY);
|
||||||
|
setCreated(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (ev: React.FormEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await client.post<{
|
||||||
|
user: { osUser: string | null; osSshPublicKey: string | null };
|
||||||
|
osUserError: string | null;
|
||||||
|
}>('/users', form);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: usersKey });
|
||||||
|
setCreated({
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
osUser: res.user.osUser,
|
||||||
|
osSshPublicKey: res.user.osSshPublicKey,
|
||||||
|
osUserError: res.osUserError,
|
||||||
|
});
|
||||||
|
} catch (ex) {
|
||||||
|
// The server's message is the useful one here — which field, and why.
|
||||||
|
toast.error(ex instanceof Error ? ex.message : 'Could not create the account');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = (value: string, what: string) => {
|
||||||
|
void navigator.clipboard.writeText(value);
|
||||||
|
toast.success(`${what} copied`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── After creation ──
|
||||||
|
//
|
||||||
|
// Deliberately a wall you have to dismiss. Both values below are unrecoverable once this closes, and the
|
||||||
|
// public key has a job attached to it that nothing else will remind you to do.
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">{created.email} created</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Copy what you need before closing — none of it can be shown again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input readOnly value={created.password} className="font-mono" />
|
||||||
|
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Stored as a hash — this is the only time it exists in readable form. They can change it from their own
|
||||||
|
profile once signed in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{created.osUser && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Linux account</Label>
|
||||||
|
<Input readOnly value={created.osUser} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{created.osSshPublicKey && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Their SSH public key</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copy(created.osSshPublicKey!, 'Public key')}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* The one action this screen cannot do for you. Without it their pushes fail with a
|
||||||
|
permission error that says nothing about a missing key. */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Generated on the machine; the private half never leaves it.{' '}
|
||||||
|
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{created.osUserError && (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||||
|
<div className="font-medium">The account works, but its Linux side did not finish</div>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="button" onClick={close}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Add account
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium">New account</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Created active — they can sign in straight away. Tell them the password; it is not recoverable afterwards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="new-user-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="new-user-email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="off"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(ev) => set('email')(ev.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="new-user-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="new-user-name"
|
||||||
|
autoComplete="off"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(ev) => set('name')(ev.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="new-user-username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="new-user-username"
|
||||||
|
autoComplete="off"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(ev) => set('username')(ev.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="new-user-role">Role</Label>
|
||||||
|
<Select value={form.role} onValueChange={set('role')}>
|
||||||
|
<SelectTrigger id="new-user-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
What the role may reach is set under Permissions. A role with no grants can sign in and reach nothing but
|
||||||
|
its own profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="new-user-password">Password</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="new-user-password"
|
||||||
|
// Deliberately visible — see the note at the top of this file.
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="font-mono"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(ev) => set('password')(ev.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
|
||||||
|
<Dices className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Generate a password</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={!form.password}
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(form.password);
|
||||||
|
toast.success('Password copied');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Copy the password</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
At least 12 characters, with upper and lower case, a number and a symbol.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inbound only, and optional. The OUTBOUND key is generated either way — pasting one here does
|
||||||
|
not replace it, because a key on a laptop is no use to an agent running on the server. */}
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="new-user-ssh">
|
||||||
|
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="new-user-ssh"
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
value={form.sshPublicKey}
|
||||||
|
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Lets them SSH into this machine as their own Linux user. Leave empty for platform-only access — either way
|
||||||
|
they get a keypair of their own for pushing to Gitea, and you will be shown its public half next.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={close} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+34
-32
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Loader2, Lock } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
|
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -25,7 +25,9 @@ type CapabilityInfo = {
|
|||||||
type Grant = { role: string; capability: string; level: 'read' | 'write' };
|
type Grant = { role: string; capability: string; level: 'read' | 'write' };
|
||||||
|
|
||||||
type CapabilitiesResponse = {
|
type CapabilitiesResponse = {
|
||||||
|
/** Grantable AND installed. What this server can currently do. */
|
||||||
capabilities: CapabilityInfo[];
|
capabilities: CapabilityInfo[];
|
||||||
|
|
||||||
roles: string[];
|
roles: string[];
|
||||||
grants: Grant[];
|
grants: Grant[];
|
||||||
};
|
};
|
||||||
@@ -97,21 +99,27 @@ export const PermissionsSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 p-1">
|
<div className="flex flex-col gap-5 p-1">
|
||||||
|
{/* Tabs rather than a dropdown. There are three roles and they are the axis you move along — a select
|
||||||
|
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
|
||||||
|
buttons, because switching role mutates a draft rather than navigating. */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
|
||||||
<span className="text-sm text-muted-foreground">Role</span>
|
{data.roles.map((r) => (
|
||||||
<Select value={activeRole ?? undefined} onValueChange={(value) => setRole(value)}>
|
<button
|
||||||
<SelectTrigger className="w-44">
|
key={r}
|
||||||
<SelectValue />
|
type="button"
|
||||||
</SelectTrigger>
|
role="tab"
|
||||||
<SelectContent>
|
aria-selected={activeRole === r}
|
||||||
{data.roles.map((r) => (
|
onClick={() => setRole(r)}
|
||||||
<SelectItem key={r} value={r}>
|
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||||
{r}
|
activeRole === r
|
||||||
</SelectItem>
|
? 'bg-accent font-medium text-accent-foreground'
|
||||||
))}
|
: 'text-muted-foreground hover:bg-accent/50'
|
||||||
</SelectContent>
|
}`}
|
||||||
</Select>
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={save} disabled={!dirty || saving}>
|
<Button onClick={save} disabled={!dirty || saving}>
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
@@ -130,10 +138,11 @@ export const PermissionsSection = () => {
|
|||||||
const level = draft[capability.key] ?? 'none';
|
const level = draft[capability.key] ?? 'none';
|
||||||
return (
|
return (
|
||||||
<div key={capability.key} className="flex items-center justify-between gap-4 p-3">
|
<div key={capability.key} className="flex items-center justify-between gap-4 p-3">
|
||||||
<div className="min-w-0">
|
{/* Label only. The descriptions went because with three rows called Terminal, Chat and Files
|
||||||
<div className="text-sm font-medium">{capability.label}</div>
|
they explained nothing anyone needed — and the "needs a Linux account" line went with them:
|
||||||
<div className="text-xs text-muted-foreground">{capability.description}</div>
|
every account gets one at creation, so warning about it on every row was noise about a state
|
||||||
</div>
|
that no longer occurs on its own. */}
|
||||||
|
<div className="min-w-0 text-sm font-medium">{capability.label}</div>
|
||||||
<Select
|
<Select
|
||||||
value={level}
|
value={level}
|
||||||
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
|
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
|
||||||
@@ -152,19 +161,12 @@ export const PermissionsSection = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stated rather than silently omitted. An owner who cannot find the Terminal checkbox will assume
|
{/* Two explanatory blocks used to sit here: one naming every capability whose sidecar is not installed,
|
||||||
the screen is incomplete and go looking for it; saying why it does not exist is the difference
|
and one naming everything that can never be granted. Both are gone, and for the same reason — a
|
||||||
between a deliberate design and a missing feature. */}
|
server should not enumerate what it does not have. The first was a catalogue of uninstallable
|
||||||
<div className="flex gap-3 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
features presented as a permissions decision; the second described chat, tasks, the desktop and the
|
||||||
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
|
wallet to an owner who may have none of them installed. What is on this screen is what this server
|
||||||
<div>
|
can actually do. */}
|
||||||
<div className="font-medium text-foreground">Not listed, and not grantable</div>
|
|
||||||
The terminal, chat, tasks, files, the code editor, the desktop and the browser all run as the server owner, in
|
|
||||||
the server owner’s home directory, with full permissions. Granting one of them would hand over the
|
|
||||||
machine rather than a feature, so there is no level at which they can be shared. The wallet, Headscale and the
|
|
||||||
server settings stay with the owner for the same reason.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Crown, Trash2, Loader2 } from 'lucide-react';
|
import { Crown, Trash2, Loader2, KeyRound, SquareTerminal as TerminalIcon } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { CreateUserForm } from './CreateUserForm';
|
||||||
|
|
||||||
type ManagedUser = {
|
type ManagedUser = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,9 +26,20 @@ type ManagedUser = {
|
|||||||
role: string;
|
role: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
osUser: string | null;
|
||||||
|
osSshPublicKey: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UsersResponse = { users: ManagedUser[]; roles: string[]; ownerId: number };
|
type UsersResponse = {
|
||||||
|
users: ManagedUser[];
|
||||||
|
/** False on a host without per-user Linux accounts, where those controls would only ever refuse. */
|
||||||
|
osUsersEnabled: boolean;
|
||||||
|
/** Every role, for displaying the owner's own value. */
|
||||||
|
roles: string[];
|
||||||
|
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
|
||||||
|
assignableRoles: string[];
|
||||||
|
ownerId: number;
|
||||||
|
};
|
||||||
|
|
||||||
const USERS_KEY = ['MANAGED_USERS'];
|
const USERS_KEY = ['MANAGED_USERS'];
|
||||||
|
|
||||||
@@ -56,6 +68,45 @@ export const UsersSection = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or repair the account's Linux side in place.
|
||||||
|
*
|
||||||
|
* Prompts for a key rather than putting a whole form on the row: replacing it is the rarer of the two
|
||||||
|
* reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it".
|
||||||
|
*/
|
||||||
|
const provisionLinux = async (user: ManagedUser) => {
|
||||||
|
const key = window.prompt(
|
||||||
|
`Linux account for ${user.email}.\n\n` +
|
||||||
|
`Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
// Cancel is null; empty string is a deliberate "no change".
|
||||||
|
if (key === null) return;
|
||||||
|
|
||||||
|
setPendingId(user.id);
|
||||||
|
try {
|
||||||
|
const result = await client.post<{ osUser: string | null; sshPublicKey: string | null; error: string | null }>(
|
||||||
|
`/users/${user.id}/provision-linux`,
|
||||||
|
{ sshPublicKey: key.trim() },
|
||||||
|
);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
|
||||||
|
// Partial success is the interesting case and must not read as a clean win: the account can exist and
|
||||||
|
// be confined while the keys failed.
|
||||||
|
if (result.error) {
|
||||||
|
toast.warning(result.osUser ? `${result.osUser} created, but not finished` : 'Could not finish', {
|
||||||
|
description: result.error,
|
||||||
|
duration: 30_000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(`${result.osUser} is ready`);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
toast.error(ex instanceof Error ? ex.message : 'Could not provision the Linux account');
|
||||||
|
} finally {
|
||||||
|
setPendingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const remove = async (user: ManagedUser) => {
|
const remove = async (user: ManagedUser) => {
|
||||||
setPendingId(user.id);
|
setPendingId(user.id);
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
@@ -86,9 +137,11 @@ export const UsersSection = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Every account on this server. The owner is fixed — the database itself refuses to demote or remove it — so that
|
Every account on this server. The owner is fixed — the database itself refuses to demote or remove it — so that
|
||||||
row cannot be changed from here.
|
row cannot be changed from here, and no other account can be promoted into it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<CreateUserForm roles={data.assignableRoles} usersKey={USERS_KEY} />
|
||||||
|
|
||||||
<div className="rounded-lg border divide-y">
|
<div className="rounded-lg border divide-y">
|
||||||
{data.users.map((user) => {
|
{data.users.map((user) => {
|
||||||
const busy = pendingId === user.id;
|
const busy = pendingId === user.id;
|
||||||
@@ -102,6 +155,9 @@ export const UsersSection = () => {
|
|||||||
<div className="truncate text-xs text-muted-foreground">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{user.email}
|
{user.email}
|
||||||
{user.status !== 'Active' && ` · ${user.status}`}
|
{user.status !== 'Active' && ` · ${user.status}`}
|
||||||
|
{/* Shown because "does this person have a Linux account" is otherwise invisible, and it
|
||||||
|
decides whether their terminal and agent run as them or not at all. */}
|
||||||
|
{user.osUser && ` · ${user.osUser}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,7 +170,9 @@ export const UsersSection = () => {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{data.roles.map((role) => (
|
{/* The owner's row needs its own value present to render at all, and it is disabled
|
||||||
|
anyway. Every other row offers only what the server will accept. */}
|
||||||
|
{(user.isOwner ? data.roles : data.assignableRoles).map((role) => (
|
||||||
<SelectItem key={role} value={role}>
|
<SelectItem key={role} value={role}>
|
||||||
{role}
|
{role}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -122,6 +180,45 @@ export const UsersSection = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
|
||||||
|
anyone who has one (retry after fixing a host problem, or replace their key) — the
|
||||||
|
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
|
||||||
|
{data.osUsersEnabled && !user.isOwner && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`}
|
||||||
|
title={
|
||||||
|
user.osUser
|
||||||
|
? `Linux account: ${user.osUser} — click to repair or replace their SSH key`
|
||||||
|
: 'No Linux account — click to create one'
|
||||||
|
}
|
||||||
|
onClick={() => void provisionLinux(user)}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <TerminalIcon className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The errand the create screen promised would still be here: this key has to end up on
|
||||||
|
their Gitea account, and nothing else will remind anyone. */}
|
||||||
|
{user.osSshPublicKey && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
aria-label={`Copy ${user.email}'s SSH public key`}
|
||||||
|
title="Copy their SSH public key (add it to their Gitea account)"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(user.osSshPublicKey!);
|
||||||
|
toast.success('Public key copied');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
export * from './AppStore';
|
||||||
export * from './Layout';
|
export * from './Layout';
|
||||||
export * from './Home';
|
export * from './Home';
|
||||||
export * from './PasskeyGate';
|
export * from './PasskeyGate';
|
||||||
export * from './Plans';
|
|
||||||
export * from './Processes';
|
export * from './Processes';
|
||||||
export * from './CapabilityPage';
|
export * from './CapabilityPage';
|
||||||
export * from './Settings';
|
export * from './Settings';
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { usePlans } from 'state/usePlans';
|
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
import { useModels } from 'state/useModels';
|
import { useModels } from 'state/useModels';
|
||||||
import { useAccessPolicy } from 'state/useAccessPolicy';
|
import { useAccessPolicy } from 'state/useAccessPolicy';
|
||||||
import { useColorModeSync } from './useThemeSync';
|
import { useColorModeSync } from './useThemeSync';
|
||||||
|
|
||||||
|
// Caches the shell wants warm before anything asks for them. Called once from App.tsx for its effects —
|
||||||
|
// the return value has never been read.
|
||||||
export const useInitialData = () => {
|
export const useInitialData = () => {
|
||||||
const { plans } = usePlans();
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
useModels();
|
useModels();
|
||||||
useAccessPolicy();
|
useAccessPolicy();
|
||||||
useColorModeSync();
|
useColorModeSync();
|
||||||
|
|
||||||
return { plans, settings };
|
return { settings };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
import type { PageTitleOverride } from 'officerdev';
|
||||||
|
import { usePageTitleOverride } from 'officerdev';
|
||||||
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
||||||
|
|
||||||
type TitleRule = { match: (p: string) => boolean; title: string };
|
type TitleRule = { match: (p: string) => boolean; title: string };
|
||||||
@@ -19,6 +21,7 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
|
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
|
||||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||||
|
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
|
||||||
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
|
||||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
@@ -40,7 +43,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
|
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
|
||||||
{ match: (p) => p.startsWith('/browser'), title: 'Browser' },
|
{ match: (p) => p.startsWith('/browser'), title: 'Browser' },
|
||||||
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
|
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
|
||||||
{ match: (p) => p.startsWith('/plans'), title: 'Plans' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function titleForPath(pathname: string): string {
|
export function titleForPath(pathname: string): string {
|
||||||
@@ -150,16 +152,40 @@ claimTabIdentity();
|
|||||||
* refresh: you named this window to find it again among a dozen others, and wiping it because you opened
|
* refresh: you named this window to find it again among a dozen others, and wiping it because you opened
|
||||||
* a different screen would defeat the point. Clear the field to hand the tab back to the route's own
|
* a different screen would defeat the point. Clear the field to hand the tab back to the route's own
|
||||||
* name — that is the only way out, and there is no third state to get stuck in.
|
* name — that is the only way out, and there is no third state to get stuck in.
|
||||||
|
*
|
||||||
|
* Between the two sits a screen's own name for itself, published by whatever is showing (see
|
||||||
|
* `usePublishPageTitle`): on `/chat/<id>` that is the conversation's title. It ranks under a typed name
|
||||||
|
* for the reason above, and over the route default because "Chat" says less than the chat's name does.
|
||||||
*/
|
*/
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const override = usePageTitleOverride();
|
||||||
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
|
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
|
||||||
|
|
||||||
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
|
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rename outranks a tab name you typed earlier; opening a different chat does not.
|
||||||
|
*
|
||||||
|
* The precedence below makes a typed name permanent, which is right for navigation — you named this
|
||||||
|
* window to find it again — and wrong the moment you rename the conversation itself. That was a
|
||||||
|
* deliberate act on the same thing the tab is showing, and it appeared to do nothing: the tab kept the
|
||||||
|
* old name, and kept it across reloads, because the stale one is in sessionStorage.
|
||||||
|
*
|
||||||
|
* The two are told apart by the id, which is why the override carries one. Same id and a new title is
|
||||||
|
* a rename, and the newer act wins. A new id is navigation, and the tab name survives it.
|
||||||
|
*/
|
||||||
|
const seenRef = useRef<PageTitleOverride | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const seen = seenRef.current;
|
||||||
|
seenRef.current = override;
|
||||||
|
if (!override || !seen) return;
|
||||||
|
if (seen.id === override.id && seen.title !== override.title) setLabel(null);
|
||||||
|
}, [override, setLabel]);
|
||||||
|
|
||||||
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
||||||
|
|
||||||
return [label ?? titleForPath(pathname), rename] as const;
|
return [label ?? override?.title ?? titleForPath(pathname), rename] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export {
|
|||||||
getUsers,
|
getUsers,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
|
getUserByUsername,
|
||||||
getOwnerUser,
|
getOwnerUser,
|
||||||
getUserCount,
|
getUserCount,
|
||||||
createUser,
|
createUser,
|
||||||
@@ -19,6 +20,15 @@ export {
|
|||||||
cleanupExpiredTokens,
|
cleanupExpiredTokens,
|
||||||
} from './queries/auth';
|
} from './queries/auth';
|
||||||
|
|
||||||
|
export {
|
||||||
|
findLiveApiKeyByHash,
|
||||||
|
createApiKey,
|
||||||
|
listApiKeys,
|
||||||
|
revokeApiKey,
|
||||||
|
touchApiKey,
|
||||||
|
type ApiKeyIdentity,
|
||||||
|
} from './queries/api-keys';
|
||||||
|
|
||||||
export { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config';
|
export { readServerSettings, writeServerSettings, readConfigValue, writeConfigValue } from './queries/server-config';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -85,6 +95,19 @@ export {
|
|||||||
pruneChatEventsOlderThan,
|
pruneChatEventsOlderThan,
|
||||||
} from './queries/chat-events';
|
} from './queries/chat-events';
|
||||||
|
|
||||||
|
export {
|
||||||
|
listAgentPanels,
|
||||||
|
getAgentPanelByPanelId,
|
||||||
|
getAgentPanelByName,
|
||||||
|
getAgentPanelByHandoffToken,
|
||||||
|
createAgentPanel,
|
||||||
|
updateAgentPanel,
|
||||||
|
markAgentPanelIntroduced,
|
||||||
|
deleteAgentPanel,
|
||||||
|
toAgentPanelView,
|
||||||
|
} from './queries/agent-panels';
|
||||||
|
export type { AgentPanel, AgentPanelView, CreateAgentPanelInput, UpdateAgentPanelInput } from './queries/agent-panels';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getMusicFavorites,
|
getMusicFavorites,
|
||||||
addMusicFavorite,
|
addMusicFavorite,
|
||||||
@@ -258,3 +281,17 @@ export {
|
|||||||
markPushDeviceSeen,
|
markPushDeviceSeen,
|
||||||
} from './queries/notify';
|
} from './queries/notify';
|
||||||
export type { PushDeviceSelect, PushDeviceInsert } from './types';
|
export type { PushDeviceSelect, PushDeviceInsert } from './types';
|
||||||
|
|
||||||
|
// App store — what the owner has installed, and whether it should be running.
|
||||||
|
export {
|
||||||
|
listSidecarInstalls,
|
||||||
|
getSidecarInstall,
|
||||||
|
beginInstall,
|
||||||
|
recordSteps,
|
||||||
|
markInstalled,
|
||||||
|
markFailed,
|
||||||
|
markBlocked,
|
||||||
|
setEnabled,
|
||||||
|
removeInstall,
|
||||||
|
type SidecarInstall,
|
||||||
|
} from './queries/sidecar-installs';
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { and, asc, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { agentPanels } from '../schema';
|
||||||
|
import type { AgentPanelRow } from '../schema/agent-panels';
|
||||||
|
|
||||||
|
export type AgentPanel = AgentPanelRow;
|
||||||
|
|
||||||
|
/** What the browser is allowed to see. The handoff token is a bearer credential and never crosses. */
|
||||||
|
export type AgentPanelView = Omit<AgentPanel, 'handoffToken' | 'userId'>;
|
||||||
|
|
||||||
|
export const toAgentPanelView = ({ handoffToken: _t, userId: _u, ...rest }: AgentPanel): AgentPanelView => rest;
|
||||||
|
|
||||||
|
/** Every agent on one dashboard, in creation order — this is the address book. */
|
||||||
|
export function listAgentPanels(userId: number, dashboardId: string): Promise<AgentPanel[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentPanels)
|
||||||
|
.where(and(eq(agentPanels.userId, userId), eq(agentPanels.dashboardId, dashboardId)))
|
||||||
|
.orderBy(asc(agentPanels.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAgentPanelByPanelId(
|
||||||
|
userId: number,
|
||||||
|
dashboardId: string,
|
||||||
|
panelId: string,
|
||||||
|
): Promise<AgentPanel | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(agentPanels)
|
||||||
|
.where(
|
||||||
|
and(eq(agentPanels.userId, userId), eq(agentPanels.dashboardId, dashboardId), eq(agentPanels.panelId, panelId)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve an address. The whole point of the table. */
|
||||||
|
export async function getAgentPanelByName(
|
||||||
|
userId: number,
|
||||||
|
dashboardId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<AgentPanel | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(agentPanels)
|
||||||
|
.where(and(eq(agentPanels.userId, userId), eq(agentPanels.dashboardId, dashboardId), eq(agentPanels.name, name)))
|
||||||
|
.limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sender's identity, established by the bearer token alone — see the schema comment. */
|
||||||
|
export async function getAgentPanelByHandoffToken(token: string): Promise<AgentPanel | undefined> {
|
||||||
|
const [row] = await db.select().from(agentPanels).where(eq(agentPanels.handoffToken, token)).limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAgentPanelInput = {
|
||||||
|
userId: number;
|
||||||
|
dashboardId: string;
|
||||||
|
panelId: string;
|
||||||
|
name: string;
|
||||||
|
cwd?: string | null;
|
||||||
|
rolePrompt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint an agent. The sessionKey is generated here and only here: it is the one identifier that must
|
||||||
|
* never change for the life of the panel, because the sidecar's on-disk resume map and the durable
|
||||||
|
* event log are both keyed on it.
|
||||||
|
*/
|
||||||
|
export async function createAgentPanel(input: CreateAgentPanelInput): Promise<AgentPanel> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(agentPanels)
|
||||||
|
.values({
|
||||||
|
userId: input.userId,
|
||||||
|
dashboardId: input.dashboardId,
|
||||||
|
panelId: input.panelId,
|
||||||
|
name: input.name,
|
||||||
|
sessionKey: randomUUID(),
|
||||||
|
handoffToken: randomUUID(),
|
||||||
|
cwd: input.cwd ?? null,
|
||||||
|
rolePrompt: input.rolePrompt ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateAgentPanelInput = {
|
||||||
|
name?: string;
|
||||||
|
cwd?: string | null;
|
||||||
|
rolePrompt?: string | null;
|
||||||
|
panelId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rename, re-home or re-scope an agent. `sessionKey` is intentionally not updatable. */
|
||||||
|
export async function updateAgentPanel(
|
||||||
|
userId: number,
|
||||||
|
id: number,
|
||||||
|
patch: UpdateAgentPanelInput,
|
||||||
|
): Promise<AgentPanel | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(agentPanels)
|
||||||
|
.set({ ...patch, updatedAt: new Date() })
|
||||||
|
.where(and(eq(agentPanels.userId, userId), eq(agentPanels.id, id)))
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp the introduction as sent, so a reload cannot re-introduce an agent to itself. */
|
||||||
|
export async function markAgentPanelIntroduced(id: number): Promise<void> {
|
||||||
|
await db.update(agentPanels).set({ introducedAt: new Date() }).where(eq(agentPanels.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAgentPanel(userId: number, id: number): Promise<void> {
|
||||||
|
await db.delete(agentPanels).where(and(eq(agentPanels.userId, userId), eq(agentPanels.id, id)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { apiKeys, users } from '../schema';
|
||||||
|
import type { ApiKeySelect } from '../types';
|
||||||
|
|
||||||
|
// Every read here is scoped by userId except `findLiveApiKeyByHash`, which cannot be: authentication is
|
||||||
|
// the one call that does not yet know who is asking. That is what the unique index on key_hash is for.
|
||||||
|
|
||||||
|
/** What the resolver needs to turn a key into a caller. Joined so authentication is a single round trip. */
|
||||||
|
export type ApiKeyIdentity = {
|
||||||
|
keyId: number;
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look a key up by the hash of its secret, for authentication.
|
||||||
|
*
|
||||||
|
* Filters revoked and expired rows in SQL rather than returning them for the caller to check — a
|
||||||
|
* forgotten check at a call site is an accepted credential, so there is deliberately no way to ask this
|
||||||
|
* function for a dead key.
|
||||||
|
*/
|
||||||
|
export async function findLiveApiKeyByHash(keyHash: string): Promise<ApiKeyIdentity | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
keyId: apiKeys.id,
|
||||||
|
userId: apiKeys.userId,
|
||||||
|
email: users.email,
|
||||||
|
username: users.username,
|
||||||
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
|
})
|
||||||
|
.from(apiKeys)
|
||||||
|
.innerJoin(users, eq(users.id, apiKeys.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeys.keyHash, keyHash),
|
||||||
|
isNull(apiKeys.revokedAt),
|
||||||
|
sql`(${apiKeys.expiresAt} IS NULL OR ${apiKeys.expiresAt} > now())`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateApiKeyParams = {
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
keyHash: string;
|
||||||
|
prefix: string;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createApiKey(params: CreateApiKeyParams): Promise<ApiKeySelect> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(apiKeys)
|
||||||
|
.values({
|
||||||
|
userId: params.userId,
|
||||||
|
name: params.name,
|
||||||
|
keyHash: params.keyHash,
|
||||||
|
prefix: params.prefix,
|
||||||
|
expiresAt: params.expiresAt ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The caller's own keys, newest first. Never returns key_hash — nothing outside authentication needs it. */
|
||||||
|
export async function listApiKeys(userId: number): Promise<Omit<ApiKeySelect, 'keyHash'>[]> {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: apiKeys.id,
|
||||||
|
userId: apiKeys.userId,
|
||||||
|
name: apiKeys.name,
|
||||||
|
prefix: apiKeys.prefix,
|
||||||
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
|
expiresAt: apiKeys.expiresAt,
|
||||||
|
revokedAt: apiKeys.revokedAt,
|
||||||
|
createdAt: apiKeys.createdAt,
|
||||||
|
})
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.userId, userId))
|
||||||
|
.orderBy(sql`${apiKeys.createdAt} desc`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke one of the caller's own keys. Scoped by userId in the same statement as the id, so a caller
|
||||||
|
* naming someone else's key id revokes nothing and is told the same "not found" as for an id that never
|
||||||
|
* existed — the row count, not a separate ownership read that could drift from this one.
|
||||||
|
*/
|
||||||
|
export async function revokeApiKey(id: number, userId: number): Promise<boolean> {
|
||||||
|
const rows = await db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId), isNull(apiKeys.revokedAt)))
|
||||||
|
.returning({ id: apiKeys.id });
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How stale last_used_at is allowed to get. A write per request is not worth this column's value. */
|
||||||
|
const TOUCH_DEBOUNCE_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that a key was used, at most once a minute per key.
|
||||||
|
*
|
||||||
|
* Callers pass the `lastUsedAt` they already read during authentication, so the common case costs no
|
||||||
|
* extra query — and this deliberately does not await anything the request depends on.
|
||||||
|
*/
|
||||||
|
export async function touchApiKey(id: number, lastUsedAt: Date | null): Promise<void> {
|
||||||
|
if (lastUsedAt && Date.now() - lastUsedAt.getTime() < TOUCH_DEBOUNCE_MS) return;
|
||||||
|
await db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `username` is unique in the schema, so this exists to turn a would-be constraint violation into a
|
||||||
|
// sentence. Creating an account is a form someone fills in, and "duplicate key value violates unique
|
||||||
|
// constraint users_username_unique" is not an answer to give them.
|
||||||
|
export async function getUserByUsername(username: string): Promise<UserSelect | undefined> {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
// The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the
|
// The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the
|
||||||
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
|
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
|
||||||
// the main server.
|
// the main server.
|
||||||
@@ -27,8 +35,15 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
|
|||||||
// now makes, asserted a second way, and the two would part company the moment the owner was not user
|
// now makes, asserted a second way, and the two would part company the moment the owner was not user
|
||||||
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
||||||
// sidecar refusing to start beats it silently running as the wrong person.
|
// sidecar refusing to start beats it silently running as the wrong person.
|
||||||
|
//
|
||||||
|
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
|
||||||
|
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
|
||||||
|
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
|
||||||
|
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
|
||||||
|
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
|
||||||
|
// account the CHECK constraint already pins.
|
||||||
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
||||||
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).limit(1);
|
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,21 @@ export async function getPushDevices(userId: number, appSlug?: string): Promise<
|
|||||||
return db.select().from(pushDevices).where(where);
|
return db.select().from(pushDevices).where(where);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sign-out, or a hard rejection from Apple/Google (410 Unregistered, UNREGISTERED). */
|
/**
|
||||||
export async function deletePushDevice(token: string): Promise<void> {
|
* Sign-out, or a hard rejection from Apple/Google (410 Unregistered, UNREGISTERED).
|
||||||
await db.delete(pushDevices).where(eq(pushDevices.token, token));
|
*
|
||||||
|
* `userId` is optional because the two callers are genuinely different. The APNs/FCM paths delete a
|
||||||
|
* token the provider has just declared dead, which is true for whoever owns it — they pass nothing. The
|
||||||
|
* `DELETE /_officer/devices/:token` route is reached by a signed-in account naming a token in the URL,
|
||||||
|
* so it MUST pass its own id: a token is the address of a device, not a secret, and without the
|
||||||
|
* predicate any account could deregister another's device by guessing or replaying one.
|
||||||
|
*/
|
||||||
|
export async function deletePushDevice(token: string, userId?: number): Promise<void> {
|
||||||
|
const where =
|
||||||
|
userId === undefined
|
||||||
|
? eq(pushDevices.token, token)
|
||||||
|
: and(eq(pushDevices.token, token), eq(pushDevices.userId, userId));
|
||||||
|
await db.delete(pushDevices).where(where);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { sidecarInstalls } from '../schema';
|
||||||
|
|
||||||
|
// What the owner has installed from the app store. See ../schema/app-store.ts for why there is no
|
||||||
|
// userId and why `installed` and `enabled` are separate.
|
||||||
|
|
||||||
|
export type SidecarInstall = typeof sidecarInstalls.$inferSelect;
|
||||||
|
|
||||||
|
export async function listSidecarInstalls(): Promise<SidecarInstall[]> {
|
||||||
|
return db.select().from(sidecarInstalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSidecarInstall(sidecarId: string): Promise<SidecarInstall | null> {
|
||||||
|
const [row] = await db.select().from(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId));
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the row for an install that is about to start, or pick up the one a previous attempt left.
|
||||||
|
*
|
||||||
|
* Returning the existing row rather than replacing it is what makes a retry a RESUME: `completedSteps`
|
||||||
|
* is how the installer knows not to provision a second container, and starting fresh would throw that
|
||||||
|
* away every time someone pressed the button again after a failure.
|
||||||
|
*/
|
||||||
|
export async function beginInstall(sidecarId: string, mode: string): Promise<SidecarInstall> {
|
||||||
|
const existing = await getSidecarInstall(sidecarId);
|
||||||
|
if (existing) {
|
||||||
|
const [row] = await db
|
||||||
|
.update(sidecarInstalls)
|
||||||
|
// `lastError` cleared on the way in: it describes the PREVIOUS attempt, and leaving it visible
|
||||||
|
// while a new one runs is how a UI ends up showing a stale failure next to a working service.
|
||||||
|
.set({ status: 'installing', mode, lastError: null, updatedAt: new Date() })
|
||||||
|
.where(eq(sidecarInstalls.sidecarId, sidecarId))
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db.insert(sidecarInstalls).values({ sidecarId, mode, status: 'installing' }).returning();
|
||||||
|
return row!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record progress mid-install, so an interrupted run can be resumed rather than restarted. */
|
||||||
|
export async function recordSteps(sidecarId: string, completedSteps: string[], composeDir?: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(sidecarInstalls)
|
||||||
|
.set({ completedSteps, ...(composeDir ? { composeDir } : {}), updatedAt: new Date() })
|
||||||
|
.where(eq(sidecarInstalls.sidecarId, sidecarId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install finished. `enabled` goes true here because installing a thing is asking for it to run. */
|
||||||
|
export async function markInstalled(sidecarId: string, completedSteps: string[]): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(sidecarInstalls)
|
||||||
|
.set({
|
||||||
|
status: 'installed',
|
||||||
|
enabled: true,
|
||||||
|
completedSteps,
|
||||||
|
lastError: null,
|
||||||
|
installedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(sidecarInstalls.sidecarId, sidecarId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install stopped and cannot continue on its own.
|
||||||
|
*
|
||||||
|
* `completedSteps` is still written: what worked stays recorded, so resuming picks up rather than
|
||||||
|
* repeating. A failure that forgot its progress would re-provision on every retry.
|
||||||
|
*/
|
||||||
|
export async function markFailed(sidecarId: string, completedSteps: string[], error: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(sidecarInstalls)
|
||||||
|
.set({ status: 'failed', completedSteps, lastError: error, updatedAt: new Date() })
|
||||||
|
.where(eq(sidecarInstalls.sidecarId, sidecarId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waiting for a human — a token only the service's own UI can mint.
|
||||||
|
*
|
||||||
|
* Deliberately NOT `failed`. The container is up and healthy and everything so far worked; calling it a
|
||||||
|
* failure would make a normal install look broken and invite the user to tear down a working service.
|
||||||
|
* `lastError` carries the instruction instead of an error.
|
||||||
|
*/
|
||||||
|
export async function markBlocked(sidecarId: string, completedSteps: string[], reason: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(sidecarInstalls)
|
||||||
|
.set({ status: 'blocked', completedSteps, lastError: reason, updatedAt: new Date() })
|
||||||
|
.where(eq(sidecarInstalls.sidecarId, sidecarId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable or disable — the process and its container, without touching anything installed. */
|
||||||
|
export async function setEnabled(sidecarId: string, enabled: boolean): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(sidecarInstalls)
|
||||||
|
.set({ enabled, updatedAt: new Date() })
|
||||||
|
.where(eq(sidecarInstalls.sidecarId, sidecarId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget this install.
|
||||||
|
*
|
||||||
|
* Only this row. The sidecar's tables, the service directory and every byte of data under it survive —
|
||||||
|
* see the schema comment. Uninstalling is "stop running this", not "delete my library".
|
||||||
|
*/
|
||||||
|
export async function removeInstall(sidecarId: string): Promise<void> {
|
||||||
|
await db.delete(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { pgTable, serial, text, integer, timestamp, uniqueIndex, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One named agent living in one dashboard panel — the address book that lets two chat panels on the
|
||||||
|
* same page hand work to each other instead of routing every step through the human.
|
||||||
|
*
|
||||||
|
* See `docs/agent-coordination.md` for why this exists at all. The three columns that carry the design:
|
||||||
|
*
|
||||||
|
* `name` the ADDRESS. Human-assigned at dashboard setup ("frontend", "backend", "reviewer")
|
||||||
|
* and unique within a dashboard. Deliberately not the panel id: `movePanel` mints a
|
||||||
|
* fresh panel id when a panel is dragged, so an id-based address would break the first
|
||||||
|
* time the owner rearranged the layout. A name survives that; it is also what the
|
||||||
|
* agents themselves use in a prompt, which nothing else here can claim.
|
||||||
|
*
|
||||||
|
* `sessionKey` the CONTINUITY. Minted once, here, and never again — where the chat UI has always
|
||||||
|
* let the server mint a throwaway uuid per connection (`websocket.ts` does
|
||||||
|
* `msg.sessionId || randomUUID()`), a panel hands the same key back on every load.
|
||||||
|
* That is what makes an agent addressable across a reload, an officer restart and a
|
||||||
|
* night with no browser open: the claude sidecar keeps a write-through
|
||||||
|
* `sessionKey → claudeSessionId` map on disk, so a reaped session resumes its own
|
||||||
|
* transcript on the next turn, and `chat_session_events` replays under the same key.
|
||||||
|
* No heartbeat, no held process. Idle costs nothing.
|
||||||
|
*
|
||||||
|
* `handoffToken` the DOOR. A bearer token the agent presents to `POST /agent-handoff` to deliver a
|
||||||
|
* message to a peer. It authenticates the SENDER — which is why no `from` field is
|
||||||
|
* needed on the wire and why an agent cannot forge one. Its authority is deliberately
|
||||||
|
* tiny: deliver a prompt to a named peer *in the same dashboard*, nothing else. It
|
||||||
|
* cannot read, cannot create panels, and cannot name a raw sessionKey.
|
||||||
|
*
|
||||||
|
* `rolePrompt` is the introduction sent as the session's first turn ("you are the front-end developer;
|
||||||
|
* your peers are …"). Roles are prompts, not features — there is no role registry and there is not
|
||||||
|
* going to be one.
|
||||||
|
*/
|
||||||
|
export const agentPanels = pgTable(
|
||||||
|
'agent_panels',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
/** Dashboard slug as the frontend knows it. No FK: `dashboards.id` is a global PK today and is
|
||||||
|
* being reworked to a composite (see docs/workspace-panel-todo.md §3); coupling to it now would
|
||||||
|
* make that change harder for no benefit here. */
|
||||||
|
dashboardId: text('dashboard_id').notNull(),
|
||||||
|
/** Where this agent currently lives. Bookkeeping for the UI, NOT the address — see `name`. */
|
||||||
|
panelId: text('panel_id').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
sessionKey: text('session_key').notNull(),
|
||||||
|
handoffToken: text('handoff_token').notNull(),
|
||||||
|
/** Home-relative working directory for this agent's session, or null for the owner's home. */
|
||||||
|
cwd: text('cwd'),
|
||||||
|
/** Sent as the first turn when the session is created. Null once it has been sent. */
|
||||||
|
rolePrompt: text('role_prompt'),
|
||||||
|
/** Null until the introduction turn has actually been delivered, so a reload cannot re-send it. */
|
||||||
|
introducedAt: timestamp('introduced_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
// The address must resolve to exactly one agent, or a handoff is ambiguous.
|
||||||
|
uniqueIndex('uq_agent_panels_address').on(table.userId, table.dashboardId, table.name),
|
||||||
|
// One agent per panel, so a panel cannot end up with two sessions behind it.
|
||||||
|
uniqueIndex('uq_agent_panels_panel').on(table.userId, table.dashboardId, table.panelId),
|
||||||
|
uniqueIndex('uq_agent_panels_session_key').on(table.sessionKey),
|
||||||
|
uniqueIndex('uq_agent_panels_handoff_token').on(table.handoffToken),
|
||||||
|
index('idx_agent_panels_dashboard').on(table.userId, table.dashboardId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AgentPanelRow = typeof agentPanels.$inferSelect;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
// Long-lived credentials a user mints for themselves, so a native app can hold one instead of a password.
|
||||||
|
//
|
||||||
|
// The problem they solve: the mobile apps sign in and get a 30-day JWT, which means every app on every
|
||||||
|
// device is the same credential with the same expiry, and signing out of one is signing out of all of
|
||||||
|
// them. A key is per-app and per-device, revocable on its own, and does not expire underneath someone.
|
||||||
|
//
|
||||||
|
// ── What is stored ──
|
||||||
|
//
|
||||||
|
// `keyHash` is a SHA-256 of the secret and the secret itself is never written down — it is returned once,
|
||||||
|
// at creation, and is unrecoverable afterwards. SHA-256 rather than argon2 (which every password here
|
||||||
|
// uses) because the input is 32 bytes from a CSPRNG, not something a human chose: there is no dictionary
|
||||||
|
// to attack and nothing for a slow hash to buy, while a slow hash WOULD force a full table scan on every
|
||||||
|
// request, since we look a key up by its hash rather than by knowing whose it is. Same reasoning as
|
||||||
|
// GitHub's and Stripe's.
|
||||||
|
//
|
||||||
|
// `prefix` is the first few characters of the secret, kept in the clear purely so the UI can show which
|
||||||
|
// key is which. It is not enough to authenticate with.
|
||||||
|
//
|
||||||
|
// ── Authority ──
|
||||||
|
//
|
||||||
|
// A key currently carries its owner's full authority — deliberately, as the first cut. That is not a
|
||||||
|
// hole: it is exactly what the password that made it could already do, so a key is never an escalation.
|
||||||
|
// A `scopes` column is the intended next step, and the resolver is written so adding one touches the
|
||||||
|
// resolver and this table, nothing else.
|
||||||
|
export const apiKeys = pgTable(
|
||||||
|
'api_keys',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
/** What the user called it — "iPhone", "Music app on the iPad". Theirs to choose, shown back in lists. */
|
||||||
|
name: text('name').notNull(),
|
||||||
|
/** SHA-256 of the secret, hex. The lookup key: unique, so a collision is a constraint error not a mix-up. */
|
||||||
|
keyHash: text('key_hash').notNull(),
|
||||||
|
/** First characters of the secret, in the clear, for display only. */
|
||||||
|
prefix: text('prefix').notNull(),
|
||||||
|
/**
|
||||||
|
* Updated on use, but at most once a minute — see touchApiKey. Without the debounce every
|
||||||
|
* authenticated request would carry a write, which is a lot of churn to answer "is this still in use".
|
||||||
|
*/
|
||||||
|
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||||
|
/** Null means it does not expire. Set at creation and never edited. */
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
/** Set instead of deleting the row, so a revoked key's name and last use survive for the audit. */
|
||||||
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('uq_api_keys_key_hash').on(table.keyHash), index('idx_api_keys_user').on(table.userId)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { pgTable, serial, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// What the owner has installed from the app store, and whether it should be running.
|
||||||
|
//
|
||||||
|
// ── Why there is no userId ──
|
||||||
|
//
|
||||||
|
// A sidecar is ONE process serving the whole machine, so installing one is a server-level act, not a
|
||||||
|
// per-user one. This is the line that keeps the model coherent once several people share a server:
|
||||||
|
//
|
||||||
|
// installed server-level, owner-only — this row
|
||||||
|
// configured per user — service_connections
|
||||||
|
//
|
||||||
|
// Gitea is the worked example. The owner installs it once, and the row here says the process runs; each
|
||||||
|
// member then holds their own credential in `service_connections`, inheriting the instance URL from the
|
||||||
|
// owner's row. A member can therefore use a service without being able to install, uninstall or point it
|
||||||
|
// somewhere else — which is the same split `capabilities/registry.ts` already draws between `app` and
|
||||||
|
// `admin` kinds.
|
||||||
|
//
|
||||||
|
// ── Why `installed` and `enabled` are separate ──
|
||||||
|
//
|
||||||
|
// They answer different questions. `installed` means the thing EXISTS: a container was provisioned (or an
|
||||||
|
// existing instance was named), config was written, schema was applied. `enabled` means the process
|
||||||
|
// SHOULD BE RUNNING. Disabling is the reversible middle ground the owner asked for — stop the process,
|
||||||
|
// keep the container, the config, the tables and the data, and start again later at no cost.
|
||||||
|
//
|
||||||
|
// Uninstall stops the sidecar and removes the containers. It does NOT remove data, and there is no
|
||||||
|
// option that does: the service directory and everything under it survives. A user uninstalling a
|
||||||
|
// sidecar is saying "stop running this", not "delete my photo library", and the two are unrecoverably
|
||||||
|
// different for Immich and Jellyfin. Reclaiming disk is a separate, deliberate feature with the sizes
|
||||||
|
// shown — not a checkbox in an uninstall flow.
|
||||||
|
export const sidecarInstalls = pgTable(
|
||||||
|
'sidecar_installs',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
/**
|
||||||
|
* The catalogue id — 'gitea', 'photos'. Text rather than an enum for the same reason
|
||||||
|
* `service_connections.service` is: adding a sidecar must not be a schema change, and a third-party
|
||||||
|
* one cannot be in an enum we compile.
|
||||||
|
*/
|
||||||
|
sidecarId: text('sidecar_id').notNull(),
|
||||||
|
/**
|
||||||
|
* How this install was satisfied, which decides what uninstall has to undo:
|
||||||
|
*
|
||||||
|
* 'existing' — pointed at an instance the user already runs. We provisioned nothing.
|
||||||
|
* 'provisioned' — we rendered a compose file and started containers. Ours to offer to remove.
|
||||||
|
* 'config' — nothing to reach; credentials only (email, wallet).
|
||||||
|
*/
|
||||||
|
mode: text('mode').notNull(),
|
||||||
|
/**
|
||||||
|
* 'pending' | 'installing' | 'installed' | 'failed'.
|
||||||
|
*
|
||||||
|
* `installing` is a real, persisted state rather than a transient one, because install spans a
|
||||||
|
* container start and a health wait and can be interrupted by a restart in the middle. A row stuck in
|
||||||
|
* `installing` is the signal to resume, not evidence of a bug.
|
||||||
|
*/
|
||||||
|
status: text('status').notNull().default('pending'),
|
||||||
|
enabled: boolean('enabled').notNull().default(false),
|
||||||
|
/**
|
||||||
|
* Which install steps have completed, by name. This is what makes install RESUMABLE rather than
|
||||||
|
* merely retryable: re-running picks up after the last completed step instead of provisioning a
|
||||||
|
* second container or re-writing config that is already right.
|
||||||
|
*
|
||||||
|
* The failure mode being designed against is a half-installed service that neither works nor
|
||||||
|
* uninstalls — which is the one users cannot get themselves out of.
|
||||||
|
*/
|
||||||
|
completedSteps: jsonb('completed_steps').$type<string[]>().notNull().default([]),
|
||||||
|
/** Why the last attempt failed, shown in the UI. Cleared on the next successful step. */
|
||||||
|
lastError: text('last_error'),
|
||||||
|
/**
|
||||||
|
* Absolute path to the rendered compose directory, for `mode: 'provisioned'` only.
|
||||||
|
*
|
||||||
|
* Stored rather than derived because it is the user's directory and he may move it — and because
|
||||||
|
* uninstall must not guess at a path it is about to run `docker compose down` in.
|
||||||
|
*/
|
||||||
|
composeDir: text('compose_dir'),
|
||||||
|
installedAt: timestamp('installed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
// One row per sidecar, machine-wide. There is no second install of the same sidecar to disambiguate,
|
||||||
|
// which is what lets every other column be read without asking "which one".
|
||||||
|
(t) => [uniqueIndex('uq_sidecar_installs_sidecar').on(t.sidecarId)],
|
||||||
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, serial, text, integer, timestamp, index, check } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
|
// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
|
||||||
@@ -35,12 +35,40 @@ export const users = pgTable(
|
|||||||
role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
|
role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
|
||||||
name: text('name'),
|
name: text('name'),
|
||||||
username: text('username').unique(),
|
username: text('username').unique(),
|
||||||
|
/**
|
||||||
|
* The Linux account this platform account runs as, when per-user OS accounts are enabled.
|
||||||
|
*
|
||||||
|
* Stored rather than re-derived from `username`. `useradd` can adjust or refuse a name, and a derived
|
||||||
|
* value would let the platform's idea of who someone is drift from what is actually in `/etc/passwd`
|
||||||
|
* — which, for a field that decides whose uid executes a shell, is not a drift to discover later.
|
||||||
|
*
|
||||||
|
* NULL means no OS account: every account created before the feature, every account on a host where
|
||||||
|
* it is switched off, and the owner (who runs as the service user itself).
|
||||||
|
* See docs/per-user-linux-accounts.md.
|
||||||
|
*
|
||||||
|
* Uniqueness is a `uniqueIndex` below, NOT `.unique()` here. `.unique()` emits a named unique
|
||||||
|
* CONSTRAINT, and drizzle-kit responds to a new one on a populated table by asking whether to TRUNCATE
|
||||||
|
* — a prompt that cannot be answered in a non-interactive `db:push` and which stops the whole push.
|
||||||
|
* Hit and reverted on 2026-08-11; same trap as the composite keys in databases/CLAUDE.md.
|
||||||
|
*/
|
||||||
|
osUser: text('os_user'),
|
||||||
|
/**
|
||||||
|
* The PUBLIC half of the outbound SSH key generated in this account's home.
|
||||||
|
*
|
||||||
|
* Stored so the owner can retrieve it later — it has to be pasted into the member's Gitea account, and
|
||||||
|
* the home is 700 so nothing can read it back off disk without root. Public by definition; the private
|
||||||
|
* half never leaves the machine and is never in this database.
|
||||||
|
*/
|
||||||
|
osSshPublicKey: text('os_ssh_public_key'),
|
||||||
avatar: text('avatar'),
|
avatar: text('avatar'),
|
||||||
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
|
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
// Two accounts must not share one Linux user — that would make "whose uid is this" ambiguous for a
|
||||||
|
// shell. An index rather than a constraint: see the note on the column.
|
||||||
|
uniqueIndex('uq_users_os_user').on(table.osUser),
|
||||||
// The owner cannot be demoted. Enforced here rather than in application code because the whole
|
// The owner cannot be demoted. Enforced here rather than in application code because the whole
|
||||||
// point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql
|
// point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql
|
||||||
// prompt. Postgres rejects the write; there is no path around it.
|
// prompt. Postgres rejects the write; there is no path around it.
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export const emailAccounts = pgTable(
|
|||||||
enabled: boolean('enabled').notNull().default(true),
|
enabled: boolean('enabled').notNull().default(true),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
status: text('status').notNull().default('connected'),
|
status: text('status').notNull().default('connected'),
|
||||||
|
/**
|
||||||
|
* LEGACY, read-only. The sync position now lives in the account's own `emails.db`
|
||||||
|
* (`sidecar/email/sync-meta.ts`), so the file is self-describing and an account can be moved to
|
||||||
|
* another machine by copying it.
|
||||||
|
*
|
||||||
|
* Kept because it is the one-time backfill source for every account created before that move; the
|
||||||
|
* first sync after it migrates these keys into SQLite and nothing writes here again. Dropping it
|
||||||
|
* would strand any account that has not synced since.
|
||||||
|
*/
|
||||||
syncMeta: jsonb('sync_meta').notNull().default({}),
|
syncMeta: jsonb('sync_meta').notNull().default({}),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
export * from './app-store';
|
||||||
|
export * from './agent-panels';
|
||||||
|
export * from './api-keys';
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './capabilities';
|
export * from './capabilities';
|
||||||
export * from './chat-events';
|
export * from './chat-events';
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export type DockConfigInsert = typeof Schema.dockConfigs.$inferInsert;
|
|||||||
|
|
||||||
// ── Saved Sessions ──
|
// ── Saved Sessions ──
|
||||||
|
|
||||||
|
|
||||||
// ── Dashboards ──
|
// ── Dashboards ──
|
||||||
|
|
||||||
export type DashboardSelect = typeof Schema.dashboards.$inferSelect;
|
export type DashboardSelect = typeof Schema.dashboards.$inferSelect;
|
||||||
@@ -77,3 +76,8 @@ export type PipelineJobInsert = typeof Schema.pipelineJobs.$inferInsert;
|
|||||||
// Notifications
|
// Notifications
|
||||||
export type PushDeviceSelect = typeof Schema.pushDevices.$inferSelect;
|
export type PushDeviceSelect = typeof Schema.pushDevices.$inferSelect;
|
||||||
export type PushDeviceInsert = typeof Schema.pushDevices.$inferInsert;
|
export type PushDeviceInsert = typeof Schema.pushDevices.$inferInsert;
|
||||||
|
|
||||||
|
// ── API keys ──
|
||||||
|
|
||||||
|
export type ApiKeySelect = typeof Schema.apiKeys.$inferSelect;
|
||||||
|
export type ApiKeyInsert = typeof Schema.apiKeys.$inferInsert;
|
||||||
|
|||||||
+70
-3
@@ -3,9 +3,11 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import { serve } from 'bun';
|
import { serve } from 'bun';
|
||||||
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
||||||
import { assertCapabilityTotality } from './servers/capabilities/totality';
|
import { assertCapabilityTotality } from './servers/capabilities/totality';
|
||||||
import { verify } from './servers/jwt';
|
import { assertSecretsClosed } from './servers/os-user';
|
||||||
|
import { resolveHomeDir } from './servers/user-home';
|
||||||
|
import { resolveAuthToken } from './servers/auth-token';
|
||||||
import { isWsProviderAllowed } from './servers/capabilities/authorize';
|
import { isWsProviderAllowed } from './servers/capabilities/authorize';
|
||||||
import { isTokenBlacklisted } from 'officerdb';
|
import { isTokenBlacklisted, getUserById } from 'officerdb';
|
||||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||||
import { chatWebsocket } from './servers/api/chat/websocket';
|
import { chatWebsocket } from './servers/api/chat/websocket';
|
||||||
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
||||||
@@ -149,6 +151,11 @@ assertCapabilityTotality({
|
|||||||
wsProviders: Object.keys(handlers),
|
wsProviders: Object.keys(handlers),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// And, when members have real Linux accounts, that they cannot read the credentials that would make those
|
||||||
|
// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable
|
||||||
|
// JWT_SECRET is worse than no isolation, because the model looks intact. No-op while the feature is off.
|
||||||
|
await assertSecretsClosed(process.cwd());
|
||||||
|
|
||||||
async function upgradeWs(
|
async function upgradeWs(
|
||||||
req: Request,
|
req: Request,
|
||||||
server: any,
|
server: any,
|
||||||
@@ -158,7 +165,11 @@ async function upgradeWs(
|
|||||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await verify(token);
|
// Same resolver as the two HTTP doors, so a key that works against /api works here too — a music app
|
||||||
|
// holding one needs cliamp and cliamp-audio, and a socket that only understood JWTs would have made
|
||||||
|
// "signed in" and "can play audio" two different questions. `jti` is absent on a key, so the
|
||||||
|
// blacklist below simply does not apply to one; its revocation is a column, checked in the lookup.
|
||||||
|
const user = await resolveAuthToken(token);
|
||||||
if (!user) return new Response('Unauthorized', { status: 401 });
|
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
if (user.jti) {
|
if (user.jti) {
|
||||||
@@ -183,6 +194,36 @@ async function upgradeWs(
|
|||||||
const command = url.searchParams.get('command') ?? undefined;
|
const command = url.searchParams.get('command') ?? undefined;
|
||||||
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
|
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
|
||||||
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
|
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
|
||||||
|
|
||||||
|
// ── Whose shell is this ──
|
||||||
|
//
|
||||||
|
// The terminal bridge forwards this query string to the pty sidecar untouched, and the sidecar starts a
|
||||||
|
// shell from what it finds there. So `osUser` and `home` are resolved HERE, from the authenticated
|
||||||
|
// account, and any values the browser sent are deleted first. Trusting the client for either would let a
|
||||||
|
// member ask for the owner's uid in a query parameter.
|
||||||
|
//
|
||||||
|
// Absent for the owner: no `osUser` means the sidecar runs the shell as itself, which is the behaviour
|
||||||
|
// this has always had.
|
||||||
|
url.searchParams.delete('osUser');
|
||||||
|
url.searchParams.delete('home');
|
||||||
|
|
||||||
|
// The chat socket's owner-only refusal was removed on 2026-08-12, with `api/chat/chat.ts`'s in the same
|
||||||
|
// commit — they were always one guard in two places. A member's turn now runs as their own Linux account
|
||||||
|
// with their own credential and their own transcripts; the capability check above is what gates it.
|
||||||
|
|
||||||
|
if (provider === 'terminal') {
|
||||||
|
const resolved = await resolveHomeDir(user.id);
|
||||||
|
if (!resolved.ok) return new Response('Forbidden', { status: 403 });
|
||||||
|
if (!resolved.isOwner) {
|
||||||
|
const dbUser = await getUserById(user.id);
|
||||||
|
// A confined capability is only granted to an account with an OS user, so this should not happen —
|
||||||
|
// and if it ever does, refusing beats opening the owner's shell.
|
||||||
|
if (!dbUser?.osUser) return new Response('Forbidden', { status: 403 });
|
||||||
|
url.searchParams.set('osUser', dbUser.osUser);
|
||||||
|
url.searchParams.set('home', resolved.home);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ok = server.upgrade(req, {
|
const ok = server.upgrade(req, {
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -217,6 +258,20 @@ const server = serve({
|
|||||||
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
||||||
return new Response(file);
|
return new Response(file);
|
||||||
},
|
},
|
||||||
|
// Icons and assets belonging to installed sidecars, copied to public/plugins/<id>/ by the installer.
|
||||||
|
//
|
||||||
|
// Served by this dynamic route rather than by `publicRoutes` above, which is a snapshot taken by
|
||||||
|
// globbing ./public at BOOT. A plugin installed while the server is running would not be in that map,
|
||||||
|
// so its icon would 404 until the next restart — and "install it, then restart the server to see the
|
||||||
|
// icon" is not an install.
|
||||||
|
'/plugins/*': async (req) => {
|
||||||
|
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
||||||
|
// 404 rather than letting a missing file surface as a 500. An icon that has not been published
|
||||||
|
// yet is an ordinary state — the plugin is not installed — and a 500 would put a red line in the
|
||||||
|
// log for every dock render on a fresh machine.
|
||||||
|
if (!(await file.exists())) return new Response('Not found', { status: 404 });
|
||||||
|
return new Response(file);
|
||||||
|
},
|
||||||
// Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
|
// Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
|
||||||
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
|
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
|
||||||
'/api/vault/notifications/*': (req, server) => {
|
'/api/vault/notifications/*': (req, server) => {
|
||||||
@@ -244,6 +299,18 @@ const server = serve({
|
|||||||
'/.well-known/carddav': honoServer.fetch,
|
'/.well-known/carddav': honoServer.fetch,
|
||||||
'/dav': honoServer.fetch,
|
'/dav': honoServer.fetch,
|
||||||
'/dav/*': honoServer.fetch,
|
'/dav/*': honoServer.fetch,
|
||||||
|
// Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own
|
||||||
|
// entry for the same reason /dav does: only the paths listed here reach hono, and anything else
|
||||||
|
// falls through to the SPA — which answers 200 with the React shell, so a missing line here looks
|
||||||
|
// like a working endpoint returning nonsense rather than a 404.
|
||||||
|
'/vaultwarden/*': honoServer.fetch,
|
||||||
|
// The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes
|
||||||
|
// are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing.
|
||||||
|
// `/api/*` already reaches hono below, where a Bitwarden client header diverts it.
|
||||||
|
'/identity/*': honoServer.fetch,
|
||||||
|
'/notifications/*': honoServer.fetch,
|
||||||
|
'/icons/*': honoServer.fetch,
|
||||||
|
'/events/*': honoServer.fetch,
|
||||||
'/': officerWeb,
|
'/': officerWeb,
|
||||||
'/*': officerWeb,
|
'/*': officerWeb,
|
||||||
'/api': honoServer.fetch,
|
'/api': honoServer.fetch,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { MiddlewareHandler } from 'hono';
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import * as errors from '../custom-errors';
|
import * as errors from '../custom-errors';
|
||||||
import { IS_DEV_BUILD } from '../build-env';
|
import { IS_DEV_BUILD } from '../build-env';
|
||||||
import { verify } from '../jwt';
|
import { resolveAuthToken } from '../auth-token';
|
||||||
import { isSuperAdmin } from '../super-admin';
|
import { isSuperAdmin } from '../super-admin';
|
||||||
import { isApiRequestAllowed } from '../capabilities/authorize';
|
import { isApiRequestAllowed } from '../capabilities/authorize';
|
||||||
import { isExemptApiPath } from '../capabilities/totality';
|
import { isExemptApiPath } from '../capabilities/totality';
|
||||||
@@ -104,8 +104,9 @@ if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = {};
|
|||||||
// because an app has to reach /api/auth to sign in before it ever calls its own feature.
|
// because an app has to reach /api/auth to sign in before it ever calls its own feature.
|
||||||
//
|
//
|
||||||
// What still holds with these on: every protected route requires a valid token (userMiddleware), and the
|
// What still holds with these on: every protected route requires a valid token (userMiddleware), and the
|
||||||
// account backstop below still confines a non-owner account to /api/auth + /api/music whatever Origin it
|
// capability backstop below still confines a non-owner account to what its ROLE has been granted,
|
||||||
// claims — that one is deliberately NOT disabled, since it is account-based, not origin-based.
|
// whatever Origin it claims — that one is deliberately NOT disabled, since it is account-based, not
|
||||||
|
// origin-based.
|
||||||
//
|
//
|
||||||
// What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>`
|
// What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>`
|
||||||
// is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary.
|
// is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary.
|
||||||
@@ -172,29 +173,24 @@ function resolveOrigin(headerOrigin: string | undefined, referer: string | undef
|
|||||||
// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
|
// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
|
||||||
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
|
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
|
||||||
// alike — regardless of which routers mount originMiddleware. Two layers:
|
// alike — regardless of which routers mount originMiddleware. Two layers:
|
||||||
// 1. Account backstop (origin-INDEPENDENT): a valid NON-owner token may reach only /api/auth +
|
// 1. Capability backstop (origin-INDEPENDENT): a valid NON-owner token may reach only what its ROLE
|
||||||
// /api/music, no matter the origin. This is the airtight rule — it holds even if a client omits
|
// has been granted, no matter the origin. This is the airtight rule — it holds even if a client
|
||||||
// or forges the Origin header — and is what confines the music accounts to the music app.
|
// omits or forges the Origin header. It replaced a hardcoded "/api/auth + /api/music" list on
|
||||||
// 2. Per-origin rules (ORIGIN_RULES): path scoping (music app) and super-admin-only origins (web +
|
// 2026-08-07; that list was why a Member could not reach /api/gitea and no UI could change it.
|
||||||
// mobile). Redundant with the backstop for the account dimension, but keeps owner-only origins
|
// 2. Per-origin rules (ORIGIN_RULES): path scoping for the single-feature apps. Redundant with the
|
||||||
// fully off-limits to non-owners (incl. /api/music) and blocks unknown-path access there.
|
// backstop for the account dimension, but blocks unknown-path access from an app origin.
|
||||||
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
|
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
|
||||||
// protected routes). Only a VALID non-owner token is constrained.
|
// protected routes). Only a VALID non-owner token is constrained.
|
||||||
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
|
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||||
const path = ctx.req.path;
|
const path = ctx.req.path;
|
||||||
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
|
const origin = resolveOrigin(ctx.req.header('origin'), ctx.req.header('referer'));
|
||||||
|
|
||||||
// Verify the caller's token once (if any); a missing/invalid token stays null.
|
// Resolve the caller once (if any); a missing/invalid/revoked credential stays null. This goes through
|
||||||
|
// the shared resolver rather than verifying a JWT here, so an API key is the same caller at this door as
|
||||||
|
// it is at userMiddleware — the two must never disagree about who someone is. See auth-token.ts.
|
||||||
const authorization = ctx.req.header('authorization');
|
const authorization = ctx.req.header('authorization');
|
||||||
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
|
const token = authorization ? authorization.split(' ')[1] : ctx.req.query('token') || undefined;
|
||||||
let payload: { id?: number } | null = null;
|
const payload = token ? await resolveAuthToken(token) : null;
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
payload = await verify(token);
|
|
||||||
} catch {
|
|
||||||
payload = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const isOwner = payload ? await isSuperAdmin(payload) : false;
|
const isOwner = payload ? await isSuperAdmin(payload) : false;
|
||||||
|
|
||||||
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
|
// 1. Capability backstop — origin-INDEPENDENT, and the airtight half of this middleware. A valid
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from 'hono';
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import { verify } from '@@/jwt';
|
import { resolveAuthToken } from '@@/auth-token';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
|
import { isOriginAllowed, isMusicOriginExempt } from './origin-validation';
|
||||||
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
||||||
@@ -36,21 +36,39 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await verify(token);
|
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
|
||||||
|
// an API key's revocation is a column checked during that lookup, so there is no blacklist to consult
|
||||||
|
// and no issued-at to compare. A password change deliberately does NOT kill a user's API keys; they
|
||||||
|
// are the credential you rotate independently, which is the whole reason they exist.
|
||||||
|
const user = await resolveAuthToken(token);
|
||||||
if (!user) throw errors.UNAUTHORIZED();
|
if (!user) throw errors.UNAUTHORIZED();
|
||||||
|
|
||||||
// Check if token is blacklisted (explicit signout)
|
if (user.via === 'jwt') {
|
||||||
if (user.jti) {
|
// Check if token is blacklisted (explicit signout)
|
||||||
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
|
if (user.jti) {
|
||||||
}
|
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
|
||||||
|
}
|
||||||
|
|
||||||
// Check if token was issued before password change
|
// The account still has to exist, and still has to be allowed in.
|
||||||
if (user.iat && user.id) {
|
//
|
||||||
const dbUser = await getUserById(user.id);
|
// This lookup used to happen only for the password-change comparison below, and its result was read
|
||||||
if (dbUser?.passwordChangedAt) {
|
// as `dbUser?.passwordChangedAt` — so a DELETED account fell straight through the optional chain and
|
||||||
// iat is in seconds, passwordChangedAt is a Date
|
// kept working on a token that is still cryptographically valid, for up to the full 30 days. Observed
|
||||||
const tokenIssuedAt = user.iat * 1000;
|
// 2026-08-11: an account deleted from the dashboard survived a page refresh in another window.
|
||||||
if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) {
|
//
|
||||||
|
// `status` is the same shape of hole. signin.ts refuses anything that is not 'Active', but nothing
|
||||||
|
// re-checked it afterwards, so marking someone Blocked or Banned did not end the session they already
|
||||||
|
// had — which is precisely when you would be doing it.
|
||||||
|
//
|
||||||
|
// Re-read per request rather than trusted as a claim, for the reason the role is not a claim either:
|
||||||
|
// a revocation has to take effect on the next request, not at next sign-in.
|
||||||
|
if (user.id) {
|
||||||
|
const dbUser = await getUserById(user.id);
|
||||||
|
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||||
|
if (dbUser.status !== 'Active') throw errors.UNAUTHORIZED();
|
||||||
|
|
||||||
|
// Token issued before the password changed → refuse. iat is seconds, passwordChangedAt is a Date.
|
||||||
|
if (user.iat && dbUser.passwordChangedAt && user.iat * 1000 < dbUser.passwordChangedAt.getTime()) {
|
||||||
throw errors.UNAUTHORIZED();
|
throw errors.UNAUTHORIZED();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { getUserById, markAgentPanelIntroduced, type AgentPanel } from 'officerdb';
|
||||||
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
|
import { resolveBaseCwd } from '../chat/websocket';
|
||||||
|
import { logger } from '../chat/logger';
|
||||||
|
import { getOwnerHomeDir } from '@@/data-path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a turn into an agent panel's session — with or without a browser attached.
|
||||||
|
*
|
||||||
|
* This is the whole delivery mechanism, and it is four lines because the hard parts already existed:
|
||||||
|
*
|
||||||
|
* - `spawnClaudeStreaming` on an EXISTING sessionKey adopts the live session and queues the prompt
|
||||||
|
* (`claude/claude-manager.ts:379-391`). On a reaped one it re-creates the session and resumes the
|
||||||
|
* same Claude transcript from the sidecar's write-through `sessionKey → claudeSessionId` map
|
||||||
|
* (`claude-manager.ts:258`). So we never have to know, or care, whether the target is warm.
|
||||||
|
* - the sidecar commits the resulting output to `chat_session_events` itself, keyed by sessionKey
|
||||||
|
* (`claude/session-log.ts`, `durable` defaults to true). A browser that opens the dashboard later
|
||||||
|
* replays it from its cursor via `resume-cursor`.
|
||||||
|
*
|
||||||
|
* Which is why no browser needs to be open, and why nothing here subscribes to the output: the point
|
||||||
|
* of the durable log is that officer is not on the delivery path.
|
||||||
|
*
|
||||||
|
* NOT awaited to completion — `spawnClaudeStreaming` resolves when the turn has been *accepted*, not
|
||||||
|
* when it finishes (`sidecar/user-instance.ts:169` acks before doing the work). A handoff is fire-and-
|
||||||
|
* observe: the sender learns nothing about the outcome except by being told in turn.
|
||||||
|
*/
|
||||||
|
export async function deliverToAgentPanel(target: AgentPanel, prompt: string): Promise<void> {
|
||||||
|
const user = await getUserById(target.userId);
|
||||||
|
if (!user) throw new Error(`agent panel ${target.id} belongs to a user that no longer exists`);
|
||||||
|
|
||||||
|
await sidecar.spawnClaudeStreaming({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.name ?? user.email,
|
||||||
|
prompt,
|
||||||
|
sessionKey: target.sessionKey,
|
||||||
|
cwd: resolveBaseCwd(getOwnerHomeDir(user.email), target.cwd ?? undefined),
|
||||||
|
// Deliberately no `model`: a live session ignores it anyway, and a resumed one keeps whatever the
|
||||||
|
// panel started with. Passing one here would only look like it worked.
|
||||||
|
durable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Delivered a turn to an agent panel', {
|
||||||
|
dashboardId: target.dashboardId,
|
||||||
|
to: target.name,
|
||||||
|
sessionKey: target.sessionKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The envelope. The receiving agent is a language model reading its own conversation, so the sender
|
||||||
|
* has to be *in the text* — there is no out-of-band "from" header it could see. Fenced so a message
|
||||||
|
* containing its own markdown cannot be mistaken for the frame around it.
|
||||||
|
*/
|
||||||
|
export function composeHandoff(fromName: string, message: string): string {
|
||||||
|
return [
|
||||||
|
`[handoff from \`${fromName}\`]`,
|
||||||
|
'',
|
||||||
|
message.trim(),
|
||||||
|
'',
|
||||||
|
`---`,
|
||||||
|
`That message came from another agent on this dashboard, not from the human. Reply to it by doing`,
|
||||||
|
`the work, and hand back when you are done.`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the role prompt as the session's very first turn, once. Roles are prompts, not features. */
|
||||||
|
export async function introduceAgentPanel(panel: AgentPanel, peers: AgentPanel[], apiOrigin: string): Promise<void> {
|
||||||
|
if (panel.introducedAt) return;
|
||||||
|
const prompt = composeIntroduction(panel, peers, apiOrigin);
|
||||||
|
await deliverToAgentPanel(panel, prompt);
|
||||||
|
await markAgentPanelIntroduced(panel.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an agent is told about the world it lives in, verbatim, at creation.
|
||||||
|
*
|
||||||
|
* The handoff token is inlined into a runnable curl. That is a bearer credential sitting in a
|
||||||
|
* transcript, and it is a deliberate trade for this MVP on a single-owner machine: its authority is
|
||||||
|
* "deliver a prompt to a named peer on this one dashboard" and nothing else. Revisit before this
|
||||||
|
* platform has a second human on it — see docs/agent-coordination.md.
|
||||||
|
*/
|
||||||
|
export function composeIntroduction(panel: AgentPanel, peers: AgentPanel[], apiOrigin: string): string {
|
||||||
|
const others = peers.filter((p) => p.id !== panel.id);
|
||||||
|
const peerList = others.length
|
||||||
|
? others.map((p) => ` - \`${p.name}\`${p.cwd ? ` (working in ${p.cwd})` : ''}`).join('\n')
|
||||||
|
: ' (none yet — the human may add more panels to this dashboard later)';
|
||||||
|
|
||||||
|
return [
|
||||||
|
`You are \`${panel.name}\`, one of several agents working together on the dashboard`,
|
||||||
|
`\`${panel.dashboardId}\`. Each of us is a separate Claude session in its own panel, on the same`,
|
||||||
|
`page, with our own working directory and our own transcript.`,
|
||||||
|
'',
|
||||||
|
...(panel.rolePrompt?.trim() ? [`Your role, as the human described it:`, '', panel.rolePrompt.trim(), ''] : []),
|
||||||
|
`The other agents on this dashboard:`,
|
||||||
|
peerList,
|
||||||
|
'',
|
||||||
|
`## Handing work to another agent`,
|
||||||
|
'',
|
||||||
|
`When you finish something a peer needs to act on, tell them. Run:`,
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
`curl -sS -X POST ${apiOrigin}/api/agent-handoff \\`,
|
||||||
|
` -H 'content-type: application/json' \\`,
|
||||||
|
` -H 'x-officer-agent-token: ${panel.handoffToken}' \\`,
|
||||||
|
` -d "$(jq -n --arg to 'PEER_NAME' --arg message "$(cat HANDOFF.md)" '{to:$to, message:$message}')"`,
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
`Replace \`PEER_NAME\` with a name from the list above. The message is delivered to that agent as a`,
|
||||||
|
`turn in their own session, labelled as coming from you. For anything longer than a sentence, write`,
|
||||||
|
`it to a file first and pass the file as above — that is what the human expects to be able to read`,
|
||||||
|
`afterwards.`,
|
||||||
|
'',
|
||||||
|
`To see who is currently on this dashboard:`,
|
||||||
|
'',
|
||||||
|
'```bash',
|
||||||
|
`curl -sS ${apiOrigin}/api/agent-handoff/peers -H 'x-officer-agent-token: ${panel.handoffToken}'`,
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
`An unknown name is an error, not a silent no-op: the response tells you which names exist.`,
|
||||||
|
'',
|
||||||
|
`## What to expect back`,
|
||||||
|
'',
|
||||||
|
`A message from a peer arrives as a new turn in this conversation, prefixed \`[handoff from ...]\`.`,
|
||||||
|
`It may arrive minutes or hours later, and the human may not be watching when it does. Treat it as`,
|
||||||
|
`a real instruction and continue working; do not wait for the human to confirm it.`,
|
||||||
|
'',
|
||||||
|
`Acknowledge this message briefly and then wait.`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { getAgentPanelByHandoffToken, getAgentPanelByName, listAgentPanels, type AgentPanel } from 'officerdb';
|
||||||
|
import { composeHandoff, deliverToAgentPanel } from './deliver';
|
||||||
|
import { logger } from '../chat/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The door an AGENT knocks on — not a human, and not a browser.
|
||||||
|
*
|
||||||
|
* This is mounted above the account gate, so it does not carry a platform JWT and does not go through
|
||||||
|
* `userMiddleware`. That exemption is declared and justified in `capabilities/totality.ts`; the claim
|
||||||
|
* it makes is that this surface is authenticated by a per-panel bearer token instead, and that the
|
||||||
|
* token's authority is tiny by construction:
|
||||||
|
*
|
||||||
|
* - it identifies exactly one agent panel, so the SENDER cannot be forged and needs no `from` field
|
||||||
|
* - it can address only peers in that panel's own dashboard, by name
|
||||||
|
* - it can do one thing: deliver a prompt. No reads of anything else, no writes, no session keys.
|
||||||
|
*
|
||||||
|
* Deliberately its own router rather than a route inside `/chat`: the two have different callers,
|
||||||
|
* different credentials and different blast radii, and folding one into the other would have hidden
|
||||||
|
* that. See docs/agent-coordination.md.
|
||||||
|
*
|
||||||
|
* Note this is a bare `Hono` and not `createRouter()` — createRouter's helpers assume the `user` and
|
||||||
|
* `body` context values that `userMiddleware`/`bodyParser` set, and neither runs here.
|
||||||
|
*/
|
||||||
|
export const agentHandoffRouter = new Hono();
|
||||||
|
|
||||||
|
const TOKEN_HEADER = 'x-officer-agent-token';
|
||||||
|
|
||||||
|
async function senderFrom(header: string | undefined): Promise<AgentPanel | undefined> {
|
||||||
|
const token = header?.trim();
|
||||||
|
if (!token) return undefined;
|
||||||
|
return getAgentPanelByHandoffToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Errors here are read by a language model, so they say what to do instead of just what went wrong. */
|
||||||
|
const plain = (text: string, status: 400 | 401 | 404 | 500) => new Response(`${text}\n`, { status });
|
||||||
|
|
||||||
|
// GET /agent-handoff/peers — who else is on my dashboard. Discovery, so a wrong name is recoverable.
|
||||||
|
agentHandoffRouter.get('/peers', async (ctx) => {
|
||||||
|
const sender = await senderFrom(ctx.req.header(TOKEN_HEADER));
|
||||||
|
if (!sender) return plain(`Unknown or missing ${TOKEN_HEADER}.`, 401);
|
||||||
|
|
||||||
|
const peers = await listAgentPanels(sender.userId, sender.dashboardId);
|
||||||
|
return ctx.json({
|
||||||
|
you: sender.name,
|
||||||
|
dashboardId: sender.dashboardId,
|
||||||
|
peers: peers.filter((p) => p.id !== sender.id).map((p) => ({ name: p.name, cwd: p.cwd, role: p.rolePrompt })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /agent-handoff — deliver a message to a named peer as a turn in their own session.
|
||||||
|
agentHandoffRouter.post('/', async (ctx) => {
|
||||||
|
const sender = await senderFrom(ctx.req.header(TOKEN_HEADER));
|
||||||
|
if (!sender) return plain(`Unknown or missing ${TOKEN_HEADER}.`, 401);
|
||||||
|
|
||||||
|
let body: { to?: unknown; message?: unknown };
|
||||||
|
try {
|
||||||
|
body = await ctx.req.json();
|
||||||
|
} catch {
|
||||||
|
return plain('Body must be JSON: {"to": "<peer name>", "message": "<text>"}', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = typeof body.to === 'string' ? body.to.trim() : '';
|
||||||
|
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
||||||
|
if (!to) return plain('Missing "to". It is the name of a peer on your dashboard.', 400);
|
||||||
|
if (!message) return plain('Missing "message". An empty handoff would tell the other agent nothing.', 400);
|
||||||
|
|
||||||
|
if (to === sender.name) return plain(`You are \`${sender.name}\`. An agent cannot hand off to itself.`, 400);
|
||||||
|
|
||||||
|
const target = await getAgentPanelByName(sender.userId, sender.dashboardId, to);
|
||||||
|
if (!target) {
|
||||||
|
// Loud, and useful: a typo is the likeliest cause and the fix is in the error.
|
||||||
|
const peers = await listAgentPanels(sender.userId, sender.dashboardId);
|
||||||
|
const names = peers
|
||||||
|
.filter((p) => p.id !== sender.id)
|
||||||
|
.map((p) => `\`${p.name}\``)
|
||||||
|
.join(', ');
|
||||||
|
return plain(
|
||||||
|
`No agent named \`${to}\` on dashboard \`${sender.dashboardId}\`.\n` +
|
||||||
|
(names ? `Known peers: ${names}` : 'There are no other agents on this dashboard yet.'),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deliverToAgentPanel(target, composeHandoff(sender.name, message));
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.error('Handoff delivery failed', { from: sender.name, to: target.name, error: detail });
|
||||||
|
// Never swallow this. A handoff that silently vanished is the exact failure this whole mechanism
|
||||||
|
// exists to avoid — the sender would sit waiting for a reply that was never going to come.
|
||||||
|
return plain(`Could not deliver to \`${target.name}\`: ${detail}`, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ delivered: true, from: sender.name, to: target.name });
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { getUserById } from 'officerdb';
|
||||||
|
import { claudeLoginState } from '@@/os-user-claude';
|
||||||
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
|
||||||
|
// Can this account actually run an agent, and if not, what does it need to do about it?
|
||||||
|
//
|
||||||
|
// ── Why this is not in `api/chat` ──
|
||||||
|
//
|
||||||
|
// It would be the obvious home, and it cannot go there: `chat.ts:49` refuses every non-owner wholesale, so an
|
||||||
|
// endpoint on that router could not be read by the accounts that most need it. The refusal is right — reads
|
||||||
|
// there leak the owner's project directory names — but it means the answer to "why is my agent not working"
|
||||||
|
// has to live somewhere a member can reach.
|
||||||
|
//
|
||||||
|
// So this is its own router under the `chat` capability. Same grant, no owner gate, and nothing here reports
|
||||||
|
// on anyone but the caller: two booleans about their own home. Denying it would not restrict an account, it
|
||||||
|
// would just replace an explanation with a silence.
|
||||||
|
//
|
||||||
|
// ── Why the platform cannot fix `loggedIn` for them ──
|
||||||
|
//
|
||||||
|
// `claude` authenticates interactively against an Anthropic account, so logging in is the member's own act.
|
||||||
|
// The alternative — pointing them at the owner's credential proxy — spends the owner's subscription on their
|
||||||
|
// turns, which is the thing the whole per-user design exists to avoid. What the UI does with a `false` here is
|
||||||
|
// render a terminal and tell them to run `claude` once themselves.
|
||||||
|
|
||||||
|
export const agentStatusRouter = createRouter();
|
||||||
|
|
||||||
|
type AgentStatus = {
|
||||||
|
/** Their own `claude` is installed in their home. */
|
||||||
|
installed: boolean;
|
||||||
|
/** They have logged in, so a turn can actually run. */
|
||||||
|
loggedIn: boolean;
|
||||||
|
/** True for the owner, whose agent has always worked and who needs no instructions. */
|
||||||
|
isOwner: boolean;
|
||||||
|
/** Null unless something is missing — the one thing to tell them, already resolved to their case. */
|
||||||
|
instruction: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
agentStatusRouter.get('/', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
|
||||||
|
const resolved = await resolveHomeDir(user.id);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
// No Linux account means nothing is confined and nothing can run — the same refusal the file browser
|
||||||
|
// gives, reported rather than thrown because this endpoint exists to explain, not to gate.
|
||||||
|
return ctx.json<AgentStatus>({
|
||||||
|
installed: false,
|
||||||
|
loggedIn: false,
|
||||||
|
isOwner: false,
|
||||||
|
instruction: resolved.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner runs in their real login home with the credential the proxy already holds. There is no member
|
||||||
|
// account to `runAs`, and nothing to instruct.
|
||||||
|
if (resolved.isOwner) {
|
||||||
|
return ctx.json<AgentStatus>({ installed: true, loggedIn: true, isOwner: true, instruction: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await getUserById(user.id);
|
||||||
|
if (!row?.osUser) {
|
||||||
|
return ctx.json<AgentStatus>({
|
||||||
|
installed: false,
|
||||||
|
loggedIn: false,
|
||||||
|
isOwner: false,
|
||||||
|
instruction: 'This account has no Linux user on this machine yet — ask the server owner to provision it.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await claudeLoginState({ email: row.email, osUser: row.osUser });
|
||||||
|
|
||||||
|
const instruction = !state.installed
|
||||||
|
? 'Claude is not installed in your home yet — ask the server owner to reprovision your account.'
|
||||||
|
: !state.loggedIn
|
||||||
|
? 'Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return ctx.json<AgentStatus>({ ...state, isOwner: false, instruction });
|
||||||
|
});
|
||||||
@@ -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.
|
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
|
||||||
*/
|
*/
|
||||||
function titleRun(
|
function titleRun(
|
||||||
email: string,
|
who: { email: string; home: string },
|
||||||
cwd: string,
|
cwd: string,
|
||||||
claudeSessionId: string,
|
claudeSessionId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
@@ -99,7 +99,7 @@ function titleRun(
|
|||||||
const title = [agentName, subject, when].filter(Boolean).join(' · ');
|
const title = [agentName, subject, when].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!renameClaudeSession(email, cwd, claudeSessionId, title)) {
|
if (!renameClaudeSession(who, cwd, claudeSessionId, title)) {
|
||||||
logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
|
logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -171,7 +171,13 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
|||||||
run.finishedAt = Date.now();
|
run.finishedAt = Date.now();
|
||||||
if (msg.claudeSessionId) {
|
if (msg.claudeSessionId) {
|
||||||
run.claudeSessionId = msg.claudeSessionId;
|
run.claudeSessionId = msg.claudeSessionId;
|
||||||
titleRun(params.user.email, cwd, msg.claudeSessionId, agent.name || agent.dirName, inputs);
|
titleRun(
|
||||||
|
{ email: params.user.email, home: homeDir },
|
||||||
|
cwd,
|
||||||
|
msg.claudeSessionId,
|
||||||
|
agent.name || agent.dirName,
|
||||||
|
inputs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
run.status = 'failed';
|
run.status = 'failed';
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { createApiKey, listApiKeys, revokeApiKey } from 'officerdb';
|
||||||
|
import { mintApiKey } from '../../auth-token';
|
||||||
|
import * as errors from '../../custom-errors';
|
||||||
|
|
||||||
|
// A user's own API keys: mint, list, revoke. Every route acts on `ctx.get('user').id` and nothing accepts
|
||||||
|
// a userId from the caller, so there is no shape of request here that reaches another account's keys.
|
||||||
|
//
|
||||||
|
// The plaintext secret exists for exactly one response. There is deliberately no "show it to me again"
|
||||||
|
// endpoint — if a key is lost, revoke it and mint another. Same reasoning as the DAV app passwords beside
|
||||||
|
// this, and the same reason the column holds a hash.
|
||||||
|
//
|
||||||
|
// A key currently carries its holder's FULL authority: it resolves to the user, and authorization then
|
||||||
|
// asks the same questions of that user it would have asked of a browser session. That is not an
|
||||||
|
// escalation — it can do exactly what the password could already do — but it does mean a leaked key is a
|
||||||
|
// leaked account, which is why revocation is one call and last-used is recorded. Narrowing a key to a
|
||||||
|
// subset of its holder's capabilities is the next step and wants a `scopes` column, not a change here.
|
||||||
|
|
||||||
|
export const apiKeysRouter = createRouter();
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 100;
|
||||||
|
|
||||||
|
apiKeysRouter.get('/', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
return ctx.json({ keys: await listApiKeys(user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
apiKeysRouter.post('/', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const body = ctx.get('body') as { name?: string; expiresInDays?: number } | undefined;
|
||||||
|
|
||||||
|
const name = body?.name?.trim();
|
||||||
|
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||||
|
if (name.length > MAX_NAME_LENGTH) throw errors.BAD_REQUEST(`name must be ${MAX_NAME_LENGTH} characters or fewer`);
|
||||||
|
|
||||||
|
// Absent means "does not expire" — the common case is a key living in a phone the owner keeps. An
|
||||||
|
// expiry, when asked for, is enforced in SQL during lookup rather than by anything that has to run.
|
||||||
|
let expiresAt: Date | null = null;
|
||||||
|
const days = body?.expiresInDays;
|
||||||
|
if (days !== undefined && days !== null) {
|
||||||
|
if (!Number.isInteger(days) || days < 1) throw errors.BAD_REQUEST('expiresInDays must be a positive integer');
|
||||||
|
expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { secret, keyHash, prefix } = mintApiKey();
|
||||||
|
const entry = await createApiKey({ userId: user.id, name, keyHash, prefix, expiresAt });
|
||||||
|
|
||||||
|
const { keyHash: _hash, ...safe } = entry;
|
||||||
|
// `key` is returned here and nowhere else. Nothing in the system can produce it again.
|
||||||
|
return ctx.json({ entry: safe, key: secret });
|
||||||
|
});
|
||||||
|
|
||||||
|
apiKeysRouter.delete('/:id', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const id = Number(ctx.req.param('id'));
|
||||||
|
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
|
||||||
|
|
||||||
|
// Scoped by user inside the statement: naming someone else's key id revokes nothing and is answered
|
||||||
|
// with the same 404 as an id that never existed.
|
||||||
|
if (!(await revokeApiKey(id, user.id))) throw errors.NOT_FOUND('no such api key');
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import * as errors from '../../custom-errors';
|
||||||
|
import { isSuperAdmin } from '../../super-admin';
|
||||||
|
import { listStore, install, setEnabled, uninstall } from '../../app-store/service';
|
||||||
|
import { byId, type InstallMode } from '../../app-store/catalogue';
|
||||||
|
|
||||||
|
// /api/app-store — what can be installed, what is installed, and the four verbs that change it.
|
||||||
|
//
|
||||||
|
// ── Owner only, explicitly ──
|
||||||
|
//
|
||||||
|
// Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is
|
||||||
|
// an administrative act however many members share the server, so this router gates on the owner in its
|
||||||
|
// own right rather than relying on the capability layer alone. `server-admin` already covers it, and
|
||||||
|
// this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a
|
||||||
|
// mistake here is not a leak of data, it is arbitrary process control.
|
||||||
|
//
|
||||||
|
// The per-user half lives elsewhere: `service_connections` is where a member's own credential goes, and
|
||||||
|
// members.ts is how they get one.
|
||||||
|
|
||||||
|
export const appStoreRouter = createRouter();
|
||||||
|
|
||||||
|
appStoreRouter.use(async (ctx, next) => {
|
||||||
|
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The app store is owner-only');
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/app-store — the catalogue joined to what has happened to each entry. */
|
||||||
|
appStoreRouter.get('/', async (ctx) => {
|
||||||
|
return ctx.json({ items: await listStore() });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/app-store/:id/install — install, or resume one that stopped.
|
||||||
|
*
|
||||||
|
* Resume is the same call deliberately: pressing the button after a failure and pressing it after
|
||||||
|
* supplying the API key it was waiting for are one action to the user. What re-runs is decided by the
|
||||||
|
* steps already recorded, not by which endpoint was hit.
|
||||||
|
*
|
||||||
|
* Answers with the outcome rather than a bare 200 — `blocked` is a normal result that the UI has to
|
||||||
|
* render differently from success, and flattening it to "ok" would lose the reason and the link.
|
||||||
|
*/
|
||||||
|
appStoreRouter.post('/:id/install', async (ctx) => {
|
||||||
|
const id = ctx.req.param('id');
|
||||||
|
const entry = byId(id);
|
||||||
|
if (!entry) throw errors.NOT_FOUND(`Unknown sidecar: ${id}`);
|
||||||
|
|
||||||
|
const body = (ctx.get('body') ?? {}) as { mode?: string; values?: Record<string, string> };
|
||||||
|
const mode = body.mode as InstallMode | undefined;
|
||||||
|
if (!mode) throw errors.BAD_REQUEST('mode is required');
|
||||||
|
if (!entry.modes.includes(mode)) {
|
||||||
|
throw errors.BAD_REQUEST(`${entry.label} cannot be installed as '${mode}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The install log is collected rather than streamed for now. Streaming it into a terminal panel is
|
||||||
|
// the intended shape — the lines are already produced one at a time — and needs a channel this route
|
||||||
|
// does not have yet.
|
||||||
|
const lines: string[] = [];
|
||||||
|
const outcome = await install({ sidecarId: id, mode, values: body.values ?? {}, log: (l) => lines.push(l) });
|
||||||
|
|
||||||
|
return ctx.json({ outcome, log: lines });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/app-store/:id/enable — start the sidecar (and, later, its container). */
|
||||||
|
appStoreRouter.post('/:id/enable', async (ctx) => {
|
||||||
|
await setEnabled(ctx.req.param('id'), true);
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/app-store/:id/disable — stop it, keeping everything installed. */
|
||||||
|
appStoreRouter.post('/:id/disable', async (ctx) => {
|
||||||
|
await setEnabled(ctx.req.param('id'), false);
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/app-store/:id/uninstall — stop running this.
|
||||||
|
*
|
||||||
|
* A POST rather than a DELETE, because it is not a deletion: the sidecar's tables and the service
|
||||||
|
* directory survive it. Calling it DELETE would suggest otherwise to the next person reading the route
|
||||||
|
* table.
|
||||||
|
*/
|
||||||
|
appStoreRouter.post('/:id/uninstall', async (ctx) => {
|
||||||
|
await uninstall(ctx.req.param('id'));
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { getUserCount, createUser } from 'officerdb';
|
import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||||
|
import { DEFAULT_ROLE_CAPABILITIES } from '@@/capabilities/registry';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { rememberUser } from '@@/_middlewares';
|
import { rememberUser } from '@@/_middlewares';
|
||||||
@@ -44,6 +45,23 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
role: 'Super Admin',
|
role: 'Super Admin',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Every other role starts with the baseline: terminal, chat and files at write. Done here because
|
||||||
|
// bootstrap is the one moment that happens exactly once per install, so seeding cannot fight a later
|
||||||
|
// revocation — take one of these away and nothing puts it back.
|
||||||
|
//
|
||||||
|
// Non-fatal. An owner who exists but whose roles hold nothing is a working server with a one-click fix;
|
||||||
|
// failing bootstrap over it would leave a platform with no account at all.
|
||||||
|
try {
|
||||||
|
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
|
||||||
|
await replaceRoleGrants(
|
||||||
|
role,
|
||||||
|
DEFAULT_ROLE_CAPABILITIES.map((capability) => ({ capability, level: 'write' as const })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
console.warn('[bootstrap] could not seed default role capabilities', ex);
|
||||||
|
}
|
||||||
|
|
||||||
// The launch-time snapshot was taken while the user table was still empty. Without this the owner's
|
// The launch-time snapshot was taken while the user table was still empty. Without this the owner's
|
||||||
// very first sign-in would be filed as an unknown identity.
|
// very first sign-in would be filed as an unknown identity.
|
||||||
rememberUser(user);
|
rememberUser(user);
|
||||||
|
|||||||
@@ -6,11 +6,18 @@ import argon2 from 'argon2';
|
|||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { validatePassword } from './validate-password';
|
import { validatePassword } from './validate-password';
|
||||||
|
|
||||||
|
// The token must be a reset-password token and nothing else. `verifyToken` — which only renders the form
|
||||||
|
// — has always checked `purpose`; this handler, which actually rewrites the password, did not, so any
|
||||||
|
// valid signed JWT was accepted here. A 30-day session token is a valid signed JWT, which made this a
|
||||||
|
// route from "holds a session token" to "owns the account": it rewrites the password (locking the real
|
||||||
|
// owner out, since passwordChangedAt invalidates their other tokens) and forces `status: 'Active'`,
|
||||||
|
// re-enabling an account somebody had deliberately suspended. The two handlers now agree.
|
||||||
export const resetPasswordHandler: Handler = async function (ctx) {
|
export const resetPasswordHandler: Handler = async function (ctx) {
|
||||||
const { password, verificationCode } = ctx.get('body');
|
const { password, verificationCode } = ctx.get('body');
|
||||||
validatePassword(password);
|
validatePassword(password);
|
||||||
const userInfo = (await verify(verificationCode)) as User;
|
const userInfo = (await verify(verificationCode)) as User & { purpose?: string };
|
||||||
if (!userInfo) throw errors.UNAUTHORIZED();
|
if (!userInfo?.id) throw errors.UNAUTHORIZED();
|
||||||
|
if (userInfo.purpose !== 'reset-password') throw errors.UNAUTHORIZED();
|
||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: new Date() });
|
await updateUser(userInfo.id, { password: passwordHash, status: 'Active', passwordChangedAt: new Date() });
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
|
import type { AuthIdentity } from '@@/auth-token';
|
||||||
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
|
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
|
||||||
|
|
||||||
export const signoutHandler: Handler = async (ctx) => {
|
export const signoutHandler: Handler = async (ctx) => {
|
||||||
const user = ctx.get('user') as { id: number; jti: string; exp: number };
|
const user = ctx.get('user') as AuthIdentity;
|
||||||
|
|
||||||
await blacklistToken(user.jti, user.exp);
|
// Only a session has something to blacklist. An API key cannot be signed out — it is revoked by id in
|
||||||
|
// the keys API, which is the point of holding one instead of a session.
|
||||||
|
if (user.jti && user.exp) await blacklistToken(user.jti, user.exp);
|
||||||
|
|
||||||
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
|
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
|
||||||
clearVaultTokens(user.id).catch(() => {});
|
clearVaultTokens(user.id).catch(() => {});
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import { getUserById } from 'officerdb';
|
|||||||
import { verify } from '@@/jwt';
|
import { verify } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
|
||||||
// Validates a password-reset link before the reset form is rendered. Officer is single-user, so the
|
// Validates a password-reset link before the reset form is rendered. The account-verification and
|
||||||
// account-verification and invitation flows this used to serve no longer exist — the sole account is
|
// invitation flows this used to serve no longer exist — the owner is created by /auth/bootstrap and
|
||||||
// created directly by /auth/bootstrap.
|
// every other account was inserted by hand, there being no signup route yet.
|
||||||
|
//
|
||||||
|
// Deliberately `verify` and not `resolveAuthToken`: this is not an authentication door. It reads a
|
||||||
|
// purpose-scoped, short-lived reset token, and an API key must never be spendable as one.
|
||||||
export const verifyTokenHandler: Handler = async function (ctx) {
|
export const verifyTokenHandler: Handler = async function (ctx) {
|
||||||
const { verificationCode } = ctx.get('body');
|
const { verificationCode } = ctx.get('body');
|
||||||
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type { Hono } from 'hono';
|
||||||
|
import {
|
||||||
|
createAgentPanel,
|
||||||
|
deleteAgentPanel,
|
||||||
|
getAgentPanelByPanelId,
|
||||||
|
listAgentPanels,
|
||||||
|
toAgentPanelView,
|
||||||
|
updateAgentPanel,
|
||||||
|
} from 'officerdb';
|
||||||
|
import { introduceAgentPanel } from '../agent-handoff/deliver';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser's half of the address book: name a panel, look up what a panel is, rename, remove.
|
||||||
|
*
|
||||||
|
* Mounted on the chat router rather than given its own prefix, because it is the same capability —
|
||||||
|
* these routes create and name Claude sessions, which is what `chat` already grants. A new top-level
|
||||||
|
* mount would have meant a new capability entry claiming the same authority under a second name.
|
||||||
|
*
|
||||||
|
* The agent-facing door is separate and deliberately so: `servers/api/agent-handoff/router.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A name has to survive being typed into a prompt and into a shell, so keep it boring. */
|
||||||
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$/;
|
||||||
|
|
||||||
|
const API_ORIGIN = `http://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||||
|
|
||||||
|
export function registerAgentPanelRoutes(router: Hono<any>): void {
|
||||||
|
// GET /chat/agent-panels?dashboardId=… — the address book for one dashboard.
|
||||||
|
router.get('/agent-panels', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const dashboardId = ctx.req.query('dashboardId')?.trim();
|
||||||
|
if (!dashboardId) return ctx.text('dashboardId is required', 400);
|
||||||
|
const panels = await listAgentPanels(user.id, dashboardId);
|
||||||
|
return ctx.json({ agents: panels.map(toAgentPanelView) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /chat/agent-panels — name a panel, minting its permanent sessionKey.
|
||||||
|
//
|
||||||
|
// Idempotent on (dashboardId, panelId): a double-submit or a remount returns the existing agent
|
||||||
|
// rather than a second one. It has to be — the sessionKey is the panel's whole continuity, and
|
||||||
|
// minting a second one would abandon the first session with no way back to it.
|
||||||
|
router.post('/agent-panels', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const dashboardId = typeof body.dashboardId === 'string' ? body.dashboardId.trim() : '';
|
||||||
|
const panelId = typeof body.panelId === 'string' ? body.panelId.trim() : '';
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim().toLowerCase() : '';
|
||||||
|
const cwd = typeof body.cwd === 'string' && body.cwd.trim() ? body.cwd.trim() : null;
|
||||||
|
const rolePrompt = typeof body.rolePrompt === 'string' && body.rolePrompt.trim() ? body.rolePrompt.trim() : null;
|
||||||
|
|
||||||
|
if (!dashboardId || !panelId) return ctx.text('dashboardId and panelId are required', 400);
|
||||||
|
if (!NAME_RE.test(name)) {
|
||||||
|
return ctx.text('Name must be lowercase letters, digits and dashes — e.g. "frontend", "code-reviewer"', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await getAgentPanelByPanelId(user.id, dashboardId, panelId);
|
||||||
|
if (existing) return ctx.json({ agent: toAgentPanelView(existing), created: false });
|
||||||
|
|
||||||
|
let created;
|
||||||
|
try {
|
||||||
|
created = await createAgentPanel({ userId: user.id, dashboardId, panelId, name, cwd, rolePrompt });
|
||||||
|
} catch (err) {
|
||||||
|
// uq_agent_panels_address — the address must resolve to one agent or a handoff is ambiguous.
|
||||||
|
if (String(err).includes('uq_agent_panels_address')) {
|
||||||
|
return ctx.text(`There is already an agent named "${name}" on this dashboard`, 409);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The introduction is the session's first turn: it is how the agent learns its own name, its peers
|
||||||
|
// and the command it uses to reach them. Fire-and-forget on purpose — the panel should render
|
||||||
|
// immediately, and if this fails the agent is still addressable, just uninformed. Logged, not
|
||||||
|
// swallowed, so "it never introduced itself" is diagnosable.
|
||||||
|
void listAgentPanels(user.id, dashboardId)
|
||||||
|
.then((peers) => introduceAgentPanel(created, peers, API_ORIGIN))
|
||||||
|
.catch((err) => logger.error('Failed to introduce agent panel', { name, dashboardId, error: String(err) }));
|
||||||
|
|
||||||
|
return ctx.json({ agent: toAgentPanelView(created), created: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /chat/agent-panels/:id — rename, re-scope, or record that the panel moved.
|
||||||
|
router.patch('/agent-panels/:id', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const id = Number(ctx.req.param('id'));
|
||||||
|
if (!Number.isFinite(id)) return ctx.text('Bad id', 400);
|
||||||
|
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
if (typeof body.name === 'string') {
|
||||||
|
const name = body.name.trim().toLowerCase();
|
||||||
|
if (!NAME_RE.test(name)) return ctx.text('Name must be lowercase letters, digits and dashes', 400);
|
||||||
|
patch.name = name;
|
||||||
|
}
|
||||||
|
if ('cwd' in body) patch.cwd = typeof body.cwd === 'string' && body.cwd.trim() ? body.cwd.trim() : null;
|
||||||
|
if ('rolePrompt' in body) patch.rolePrompt = typeof body.rolePrompt === 'string' ? body.rolePrompt : null;
|
||||||
|
if (typeof body.panelId === 'string' && body.panelId.trim()) patch.panelId = body.panelId.trim();
|
||||||
|
|
||||||
|
const updated = await updateAgentPanel(user.id, id, patch);
|
||||||
|
if (!updated) return ctx.text('No such agent', 404);
|
||||||
|
return ctx.json({ agent: toAgentPanelView(updated) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /chat/agent-panels/:id — forget the agent. The Claude transcript is NOT deleted: it lives
|
||||||
|
// in ~/.claude/projects and is readable from /chat like any other conversation. Only the address
|
||||||
|
// and the panel's claim on that session go away.
|
||||||
|
router.delete('/agent-panels/:id', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const id = Number(ctx.req.param('id'));
|
||||||
|
if (!Number.isFinite(id)) return ctx.text('Bad id', 400);
|
||||||
|
await deleteAgentPanel(user.id, id);
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
+143
-23
@@ -1,8 +1,9 @@
|
|||||||
import type { Context } from 'hono';
|
import type { Context } from 'hono';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
|
import * as errors from '@@/custom-errors';
|
||||||
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { getUserSettings } from 'officerdb';
|
import { getUserSettings } from 'officerdb';
|
||||||
import {
|
import {
|
||||||
getGeneralChatSessionsCwd,
|
|
||||||
listClaudePwds,
|
listClaudePwds,
|
||||||
listClaudeSessions,
|
listClaudeSessions,
|
||||||
loadClaudeSession,
|
loadClaudeSession,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
renameClaudeSession,
|
renameClaudeSession,
|
||||||
loadBackgroundTask,
|
loadBackgroundTask,
|
||||||
claudeSessionContext,
|
claudeSessionContext,
|
||||||
|
liveSessionTitle,
|
||||||
} from './claude-sessions';
|
} from './claude-sessions';
|
||||||
import {
|
import {
|
||||||
listOpenCodeSessions,
|
listOpenCodeSessions,
|
||||||
@@ -19,30 +21,72 @@ import {
|
|||||||
renameOpenCodeSession,
|
renameOpenCodeSession,
|
||||||
isOpenCodeSessionId,
|
isOpenCodeSessionId,
|
||||||
} from './opencode-sessions';
|
} from './opencode-sessions';
|
||||||
|
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
|
||||||
import { listChatModels } from './list-models';
|
import { listChatModels } from './list-models';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
|
import type { ChatIdentity } from './claude-sessions';
|
||||||
import { readSttConfig } from '../server-settings/stt';
|
import { readSttConfig } from '../server-settings/stt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whose transcripts a request may read.
|
||||||
|
*
|
||||||
|
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` — that one ignores its argument whenever
|
||||||
|
* HOME_DIR is set, which is how every read in this router used to resolve to the owner's `~/.claude` no matter
|
||||||
|
* who asked. Throws rather than falling back, for the same reason `resolveTurnIdentity` refuses: there is no
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
import { transcribeAudio } from '../stt/transcribe';
|
import { transcribeAudio } from '../stt/transcribe';
|
||||||
|
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||||
|
|
||||||
export const chatRouter = createRouter();
|
export const chatRouter = createRouter();
|
||||||
|
|
||||||
|
// ── Chat reached members on 2026-08-12 ──
|
||||||
|
//
|
||||||
|
// A wholesale `isSuperAdmin` refusal stood here from the day `chat` became grantable until tonight. It said
|
||||||
|
// the machinery was not ready, and it was right: a turn spawned `claude` as the OWNER, and every transcript
|
||||||
|
// path resolved through the owner's home, so a granted member would have read the owner's sessions and run an
|
||||||
|
// agent as them.
|
||||||
|
//
|
||||||
|
// What replaced it, rather than what deleted it:
|
||||||
|
//
|
||||||
|
// - the turn runs as the member — `spawnClaudeAsMember` through `sudo setpriv`, proven against a real
|
||||||
|
// account by `spawn-as-member.live.test.ts` reading file ownership rather than trusting the process
|
||||||
|
// - the credential is theirs — `--reset-env` plus an allowlist, so the owner's proxy variables cannot cross
|
||||||
|
// - the transcripts are theirs — `ChatIdentity` carries a home resolved from `resolveHomeDir`, and this file
|
||||||
|
// no longer knows how to invent one
|
||||||
|
// - the sessions are theirs — every session records its owner, and all six sidecar commands refuse a
|
||||||
|
// mismatch rather than acting on whoever matched
|
||||||
|
//
|
||||||
|
// Each of those is a separate commit with its own reasoning, and each was found wanting at least once by a
|
||||||
|
// reviewer who had not written it. If you are reverting this, revert to a refusal — not to a narrower one.
|
||||||
|
|
||||||
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||||
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
|
// caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
|
||||||
// (OpenCode sessions all live in the one fixed server and ignore cwd.)
|
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
|
||||||
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email);
|
// selects there too.
|
||||||
|
const cwdOf = (ctx: Context, home: string): string => ctx.req.query('cwd')?.trim() || home;
|
||||||
|
|
||||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||||
chatRouter.get('/pwds', (ctx) => {
|
chatRouter.get('/pwds', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
return ctx.json({ pwds: listClaudePwds(email), default: getGeneralChatSessionsCwd(email) });
|
return ctx.json({ pwds: listClaudePwds(who), default: who.home });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
|
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
|
||||||
// (Claude transcripts + OpenCode's session store), newest first.
|
// (Claude transcripts + OpenCode's session store), newest first.
|
||||||
chatRouter.get('/sessions', async (ctx) => {
|
chatRouter.get('/sessions', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, who.home);
|
||||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||||
const opencode = await listOpenCodeSessions(cwd);
|
const opencode = await listOpenCodeSessions(cwd);
|
||||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
return ctx.json({ sessions });
|
return ctx.json({ sessions });
|
||||||
@@ -55,14 +99,14 @@ chatRouter.get('/sessions', async (ctx) => {
|
|||||||
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
|
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
|
||||||
// where the window sits and whether older messages remain above it.
|
// where the window sits and whether older messages remain above it.
|
||||||
chatRouter.get('/sessions/:id', async (ctx) => {
|
chatRouter.get('/sessions/:id', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
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
|
// 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.
|
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
|
||||||
const detail = isOpenCodeSessionId(id)
|
const detail = isOpenCodeSessionId(id)
|
||||||
? await loadOpenCodeSession(id)
|
? await loadOpenCodeSession(id)
|
||||||
: (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id));
|
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
|
||||||
if (!detail) return ctx.text('Not found', 404);
|
if (!detail) return ctx.text('Not found', 404);
|
||||||
|
|
||||||
const total = detail.messages.length;
|
const total = detail.messages.length;
|
||||||
@@ -78,7 +122,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
|
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
|
||||||
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its
|
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its
|
||||||
// own, so it gets neither rather than a fabricated answer.
|
// own, so it gets neither rather than a fabricated answer.
|
||||||
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(email, detail.cwd, id);
|
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(who, detail.cwd, id);
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
...detail,
|
...detail,
|
||||||
@@ -90,28 +134,99 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /chat/live — the sessions the agent has a process behind RIGHT NOW, as opposed to the transcripts
|
||||||
|
// on disk that `/chat/sessions` lists. Asked over the wire because only the agent can answer: officer's
|
||||||
|
// own session records are in memory and die with `pm2 restart officer`, while the agent is a PM2 peer
|
||||||
|
// and keeps running. Without this a surviving session is invisible until a browser reconnects to it by
|
||||||
|
// id, which is a thing you can only do if you already knew the id.
|
||||||
|
//
|
||||||
|
// `pendingTasks` is background work started but not yet notified — with `isGenerating` it is what the
|
||||||
|
// agent's own idle GC consults, so a caller can tell "busy" from "merely open" the same way it does.
|
||||||
|
// Titles are resolved here rather than in the client, which can only name the sessions in the group it
|
||||||
|
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
|
||||||
|
chatRouter.get('/live', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const who = await chatIdentity(user);
|
||||||
|
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 [].
|
||||||
|
const [live, liveOpenCode] = await Promise.all([
|
||||||
|
sidecar.listLiveClaudeSessions(user.id),
|
||||||
|
sidecar.listLiveOpenCodeSessions(),
|
||||||
|
]);
|
||||||
|
const sessions = live.map((session) => {
|
||||||
|
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
|
||||||
|
// is named after Claude's. Null until the first turn reports one, which is a conversation that has
|
||||||
|
// genuinely not been written yet.
|
||||||
|
const transcriptId = session.claudeSessionId;
|
||||||
|
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null;
|
||||||
|
return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenCode rows carry less, and the shape says so rather than faking parity. `isGenerating` is always
|
||||||
|
// true because a subprocess exists only while it generates, and `pendingTasks` is 0 because
|
||||||
|
// `opencode run` has no background-task concept — a number there would imply one.
|
||||||
|
//
|
||||||
|
// Naming them needs a second hop. The sidecar reports only its own `sessionKey`, because that is all it
|
||||||
|
// has; OpenCode's store is keyed on `ses_…`, which the runner reports separately over `opencode:session`
|
||||||
|
// and officer records in `opencode/state.ts`. So the correlation lives HERE, and it is the reason these
|
||||||
|
// rows used to be permanently unnamed — the two halves existed and nothing joined them.
|
||||||
|
const withIds = liveOpenCode.map((session) => ({
|
||||||
|
sessionKey: session.sessionKey,
|
||||||
|
openCodeId: getOpenCodeSession(session.sessionKey) ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// One list call names every row, rather than one transcript load each — `loadOpenCodeSession` rebuilds
|
||||||
|
// a whole conversation to read its title. Skipped entirely when nothing is running or nothing has
|
||||||
|
// reported an id yet, so an idle Live panel never touches the serve. Failure degrades to unnamed:
|
||||||
|
// `listOpenCodeSessions` already swallows and returns [], which matches how the rest of this route
|
||||||
|
// fails.
|
||||||
|
const named = withIds.some((r) => r.openCodeId) ? await listOpenCodeSessions() : [];
|
||||||
|
const byId = new Map(named.map((s) => [s.id, s]));
|
||||||
|
|
||||||
|
const openCodeSessions = withIds.map(({ sessionKey, openCodeId }) => {
|
||||||
|
const meta = openCodeId ? byId.get(openCodeId) : undefined;
|
||||||
|
return {
|
||||||
|
sessionKey,
|
||||||
|
claudeSessionId: openCodeId,
|
||||||
|
isGenerating: true,
|
||||||
|
pendingTasks: 0,
|
||||||
|
harness: 'opencode' as const,
|
||||||
|
// OpenCode's own title wins as soon as it exists — it is derived from the conversation and is
|
||||||
|
// better than anything we would compose. Until then (and it titles asynchronously, so "until
|
||||||
|
// then" covers the whole time a turn is RUNNING, which is exactly what this panel shows) fall
|
||||||
|
// back to the prompt that started the session. Same shape as the Claude side, which has never
|
||||||
|
// shown a live row without a name.
|
||||||
|
title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null),
|
||||||
|
cwd: meta?.cwd || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctx.json({ sessions: [...sessions, ...openCodeSessions] });
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
|
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
|
||||||
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
|
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
|
||||||
// deleting it deletes one conversation.
|
// deleting it deletes one conversation.
|
||||||
chatRouter.delete('/sessions/:id', async (ctx) => {
|
chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, who.home);
|
||||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id);
|
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
|
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
|
||||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const id = ctx.req.param('id');
|
const id = ctx.req.param('id');
|
||||||
const cwd = cwdOf(ctx, email);
|
const cwd = cwdOf(ctx, who.home);
|
||||||
const { title } = await ctx.req.json<{ title?: string }>();
|
const { title } = await ctx.req.json<{ title?: string }>();
|
||||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||||
const ok = isOpenCodeSessionId(id)
|
const ok = isOpenCodeSessionId(id)
|
||||||
? await renameOpenCodeSession(id, title.trim())
|
? await renameOpenCodeSession(id, title.trim())
|
||||||
: renameClaudeSession(email, cwd, id, title.trim());
|
: renameClaudeSession(who, cwd, id, title.trim());
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -122,9 +237,9 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
|||||||
// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks
|
// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks
|
||||||
// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be
|
// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be
|
||||||
// an error state for the most ordinary thing that can happen.
|
// an error state for the most ordinary thing that can happen.
|
||||||
chatRouter.get('/tasks/:id', (ctx) => {
|
chatRouter.get('/tasks/:id', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const who = await chatIdentity(ctx.get('user'));
|
||||||
const detail = loadBackgroundTask(email, ctx.req.param('id'));
|
const detail = loadBackgroundTask(who, ctx.req.param('id'));
|
||||||
return ctx.json(detail ?? { kind: 'pending' });
|
return ctx.json(detail ?? { kind: 'pending' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,3 +280,8 @@ chatRouter.post('/stt', async (ctx: Context) => {
|
|||||||
return ctx.json({ error: err instanceof Error ? err.message : 'Failed to reach Whisper server' }, 502);
|
return ctx.json({ error: err instanceof Error ? err.message : 'Failed to reach Whisper server' }, 502);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The agent address book — naming a chat panel so other agents on the same dashboard can reach it.
|
||||||
|
// Lives on this router because it is the same authority `chat` already grants: creating and naming
|
||||||
|
// Claude sessions. See servers/api/chat/agent-panels-routes.ts and docs/agent-coordination.md.
|
||||||
|
registerAgentPanelRoutes(chatRouter);
|
||||||
|
|||||||
@@ -18,23 +18,32 @@ import { DATA_PATH } from '../../data-path';
|
|||||||
// The `claude` CLI persists every session as a JSONL transcript at
|
// The `claude` CLI persists every session as a JSONL transcript at
|
||||||
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
||||||
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
|
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
|
||||||
// Single-user platform: Claude runs with no isolation — HOME is the real home
|
// Claude runs with no isolation for the OWNER — HOME is their real home — so its transcripts are the same
|
||||||
// (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our
|
// store their terminal `claude` uses. We never keep our own copy; Claude's files are authoritative.
|
||||||
// own copy; Claude's files are authoritative.
|
//
|
||||||
|
// ── Why this takes a home instead of an email ──
|
||||||
const claudeHome = (email: string): string => process.env.HOME_DIR ?? join(DATA_PATH, email, 'home');
|
//
|
||||||
|
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever
|
||||||
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
|
// HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S
|
||||||
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions');
|
// transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
|
||||||
|
// that were a property rather than an assumption. A member reaching these functions would have been handed the
|
||||||
/** Same, but create the directory if it doesn't exist (call before spawning a /chat session). */
|
// owner's conversation list.
|
||||||
export const ensureGeneralChatSessionsCwd = (email: string): string => {
|
//
|
||||||
const dir = getGeneralChatSessionsCwd(email);
|
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one.
|
||||||
mkdirSync(dir, { recursive: true });
|
//
|
||||||
return dir;
|
// The email travels alongside it rather than being derived from it, because the two answer different
|
||||||
|
// questions: the email says WHO, the home says WHERE. They were briefly conflated in the other direction —
|
||||||
|
// `general_chat_sessions` was an email-derived path under DATA_PATH used as a chat's working directory, and
|
||||||
|
// because `confineUserTree` makes every sibling of a home the platform's at 0700, a member's turn started in
|
||||||
|
// a directory it could not enter. That default is now the caller's own home; the pairing survives because the
|
||||||
|
// distinction it encodes is real.
|
||||||
|
export type ChatIdentity = {
|
||||||
|
email: string;
|
||||||
|
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
|
||||||
|
home: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const claudeProjectsDir = (email: string): string => join(claudeHome(email), '.claude', 'projects');
|
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
|
||||||
|
|
||||||
/** Claude's folder name for a working directory. */
|
/** Claude's folder name for a working directory. */
|
||||||
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
@@ -178,7 +187,11 @@ function summarizeTranscript(filePath: string, id: string): TranscriptSummary |
|
|||||||
sawFirstUserEntry = true;
|
sawFirstUserEntry = true;
|
||||||
bornFromClear = text.startsWith('<command-name>/clear</command-name>');
|
bornFromClear = text.startsWith('<command-name>/clear</command-name>');
|
||||||
}
|
}
|
||||||
if (text && !firstUserText && !text.startsWith('<command-name>')) firstUserText = text;
|
// A resumed session can open with a background task reporting in, which is no more a title than a
|
||||||
|
// slash command is — see `isTaskNotification`.
|
||||||
|
if (text && !firstUserText && !text.startsWith('<command-name>') && !isTaskNotification(text)) {
|
||||||
|
firstUserText = text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,8 +397,23 @@ function blockText(content: unknown): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOrInterruption = (text: string): ClaudeChatMessage =>
|
/**
|
||||||
INTERRUPTION_MARKERS.has(text.trim()) ? { role: 'interrupted' } : { role: 'user', text };
|
* A background task reporting in. The harness delivers these to the agent by writing them as the *user's*
|
||||||
|
* next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally,
|
||||||
|
* the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never
|
||||||
|
* typed. Live it never appears, because a finished task travels as its own `task:notification` event; it
|
||||||
|
* surfaces only when a refresh rebuilds the conversation from Claude's file.
|
||||||
|
*
|
||||||
|
* Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real
|
||||||
|
* message — this file's own conversation does exactly that — stays yours.
|
||||||
|
*/
|
||||||
|
const isTaskNotification = (text: string) => text.trimStart().startsWith('<task-notification>');
|
||||||
|
|
||||||
|
function userMessageFrom(text: string): ClaudeChatMessage | null {
|
||||||
|
if (isTaskNotification(text)) return null;
|
||||||
|
if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' };
|
||||||
|
return { role: 'user', text };
|
||||||
|
}
|
||||||
|
|
||||||
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
|
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
|
||||||
|
|
||||||
@@ -430,13 +458,15 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
|
|||||||
|
|
||||||
if (entry.type === 'user' && !entry.isMeta) {
|
if (entry.type === 'user' && !entry.isMeta) {
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
if (content.trim()) messages.push(userOrInterruption(content));
|
const message = content.trim() ? userMessageFrom(content) : null;
|
||||||
|
if (message) messages.push(message);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
for (const block of content as ContentBlock[]) {
|
for (const block of content as ContentBlock[]) {
|
||||||
if (block.type === 'text' && block.text?.trim()) {
|
if (block.type === 'text' && block.text?.trim()) {
|
||||||
messages.push(userOrInterruption(block.text));
|
const message = userMessageFrom(block.text);
|
||||||
|
if (message) messages.push(message);
|
||||||
} else if (block.type === 'tool_result') {
|
} else if (block.type === 'tool_result') {
|
||||||
const tool = toolById.get(block.tool_use_id);
|
const tool = toolById.get(block.tool_use_id);
|
||||||
if (tool) {
|
if (tool) {
|
||||||
@@ -485,15 +515,15 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
|
|||||||
* Pagination needs nothing: the client asks for index windows into whatever the server calls the
|
* Pagination needs nothing: the client asks for index windows into whatever the server calls the
|
||||||
* transcript, so a longer one simply pages further back.
|
* transcript, so a longer one simply pages further back.
|
||||||
*/
|
*/
|
||||||
function loadChainTranscript(email: string, detail: ClaudeSessionDetail): ClaudeSessionDetail {
|
function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): ClaudeSessionDetail {
|
||||||
const parts = (() => {
|
const parts = (() => {
|
||||||
const group = scanGroup(email, detail.cwd);
|
const group = scanGroup(who, detail.cwd);
|
||||||
const head = group.find((session) => session.id === detail.id);
|
const head = group.find((session) => session.id === detail.id);
|
||||||
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
|
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
|
||||||
})();
|
})();
|
||||||
if (parts.length < 2) return detail;
|
if (parts.length < 2) return detail;
|
||||||
|
|
||||||
const dir = join(claudeProjectsDir(email), projectSlug(detail.cwd));
|
const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
|
||||||
const earlier: ClaudeChatMessage[] = [];
|
const earlier: ClaudeChatMessage[] = [];
|
||||||
for (const part of parts.slice(0, -1)) {
|
for (const part of parts.slice(0, -1)) {
|
||||||
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
|
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
|
||||||
@@ -505,20 +535,20 @@ function loadChainTranscript(email: string, detail: ClaudeSessionDetail): Claude
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Load a session when its cwd (project group) is known. */
|
/** Load a session when its cwd (project group) is known. */
|
||||||
export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null {
|
export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
|
||||||
const detail = parseClaudeTranscript(
|
const detail = parseClaudeTranscript(
|
||||||
join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`),
|
join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
|
||||||
sessionId,
|
sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
);
|
);
|
||||||
return detail && loadChainTranscript(email, detail);
|
return detail && loadChainTranscript(who, detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
|
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
|
||||||
* refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
|
* 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. */
|
* caller uses to scope the list + cwd picker. */
|
||||||
export function loadClaudeSessionById(email: string, sessionId: string): ClaudeSessionDetail | null {
|
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
|
||||||
const projectsDir = claudeProjectsDir(email);
|
const projectsDir = claudeProjectsDir(who.home);
|
||||||
let slugs: string[];
|
let slugs: string[];
|
||||||
try {
|
try {
|
||||||
slugs = readdirSync(projectsDir);
|
slugs = readdirSync(projectsDir);
|
||||||
@@ -529,7 +559,7 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
|
|||||||
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
||||||
if (!existsSync(filePath)) continue;
|
if (!existsSync(filePath)) continue;
|
||||||
const detail = parseClaudeTranscript(filePath, sessionId);
|
const detail = parseClaudeTranscript(filePath, sessionId);
|
||||||
return detail && loadChainTranscript(email, detail);
|
return detail && loadChainTranscript(who, detail);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -541,11 +571,11 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
|
|||||||
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
|
* 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.
|
* not, so delete and rename returned "not found" for a session that was plainly on screen.
|
||||||
*/
|
*/
|
||||||
function findTranscript(email: string, cwd: string, sessionId: string): string | null {
|
function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null {
|
||||||
const preferred = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
|
const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`);
|
||||||
if (existsSync(preferred)) return preferred;
|
if (existsSync(preferred)) return preferred;
|
||||||
|
|
||||||
const projectsDir = claudeProjectsDir(email);
|
const projectsDir = claudeProjectsDir(who.home);
|
||||||
let slugs: string[];
|
let slugs: string[];
|
||||||
try {
|
try {
|
||||||
slugs = readdirSync(projectsDir);
|
slugs = readdirSync(projectsDir);
|
||||||
@@ -567,12 +597,12 @@ function findTranscript(email: string, cwd: string, sessionId: string): string |
|
|||||||
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
|
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
|
||||||
* part of several.
|
* part of several.
|
||||||
*/
|
*/
|
||||||
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
|
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
|
||||||
const filePath = findTranscript(email, cwd, sessionId);
|
const filePath = findTranscript(who, cwd, sessionId);
|
||||||
if (!filePath) return false;
|
if (!filePath) return false;
|
||||||
|
|
||||||
const ownCwd = firstCwd(filePath);
|
const ownCwd = firstCwd(filePath);
|
||||||
const ids = ownCwd ? chainFileIds(email, ownCwd, sessionId) : [sessionId];
|
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
|
||||||
const dir = dirname(filePath);
|
const dir = dirname(filePath);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const partPath = join(dir, `${id}.jsonl`);
|
const partPath = join(dir, `${id}.jsonl`);
|
||||||
@@ -586,8 +616,8 @@ export function deleteClaudeSession(email: string, cwd: string, sessionId: strin
|
|||||||
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
|
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
|
||||||
* timestamp is written so the rename doesn't reorder the list.
|
* timestamp is written so the rename doesn't reorder the list.
|
||||||
*/
|
*/
|
||||||
export function renameClaudeSession(email: string, cwd: string, sessionId: string, title: string): boolean {
|
export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
|
||||||
const filePath = findTranscript(email, cwd, sessionId);
|
const filePath = findTranscript(who, cwd, sessionId);
|
||||||
if (!filePath) return false;
|
if (!filePath) return false;
|
||||||
|
|
||||||
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
|
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
|
||||||
@@ -640,11 +670,11 @@ export type BackgroundTaskDetail =
|
|||||||
| { kind: 'agent'; messages: ClaudeChatMessage[] }
|
| { kind: 'agent'; messages: ClaudeChatMessage[] }
|
||||||
| { kind: 'log'; text: string; truncated: boolean };
|
| { kind: 'log'; text: string; truncated: boolean };
|
||||||
|
|
||||||
function findTaskOutput(email: string, taskId: string): string | null {
|
function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
|
||||||
const tmpRoot = process.env.TMPDIR ?? '/tmp';
|
const tmpRoot = process.env.TMPDIR ?? '/tmp';
|
||||||
const candidates: [string, string][] = [
|
const candidates: [string, string][] = [
|
||||||
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
|
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
|
||||||
[claudeProjectsDir(email), `*/*/subagents/agent-${taskId}.jsonl`],
|
[claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`],
|
||||||
];
|
];
|
||||||
for (const [root, pattern] of candidates) {
|
for (const [root, pattern] of candidates) {
|
||||||
try {
|
try {
|
||||||
@@ -680,9 +710,9 @@ function tailFile(filePath: string, bytes: number): { text: string; truncated: b
|
|||||||
* What a background task is doing right now. Returns null when nothing has been written yet — which is
|
* What a background task is doing right now. Returns null when nothing has been written yet — which is
|
||||||
* the normal state for the first second or two of a task's life, not an error.
|
* the normal state for the first second or two of a task's life, not an error.
|
||||||
*/
|
*/
|
||||||
export function loadBackgroundTask(email: string, taskId: string): BackgroundTaskDetail | null {
|
export function loadBackgroundTask(who: ChatIdentity, taskId: string): BackgroundTaskDetail | null {
|
||||||
if (!TASK_ID_RE.test(taskId)) return null;
|
if (!TASK_ID_RE.test(taskId)) return null;
|
||||||
const found = findTaskOutput(email, taskId);
|
const found = findTaskOutput(who, taskId);
|
||||||
if (!found) return null;
|
if (!found) return null;
|
||||||
|
|
||||||
let target = found;
|
let target = found;
|
||||||
@@ -732,10 +762,10 @@ function firstCwd(filePath: string): string {
|
|||||||
|
|
||||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||||
|
|
||||||
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
|
/** All working directories that have Claude sessions, plus the caller's home. Newest first. */
|
||||||
export function listClaudePwds(email: string): ClaudePwd[] {
|
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
|
||||||
const projectsDir = claudeProjectsDir(email);
|
const projectsDir = claudeProjectsDir(who.home);
|
||||||
const defaultCwd = getGeneralChatSessionsCwd(email);
|
const defaultCwd = who.home;
|
||||||
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
||||||
|
|
||||||
if (existsSync(projectsDir)) {
|
if (existsSync(projectsDir)) {
|
||||||
@@ -778,8 +808,8 @@ export function listClaudePwds(email: string): ClaudePwd[] {
|
|||||||
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
|
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
|
||||||
* mtime-cached, which is what makes calling this on every request cheap.
|
* mtime-cached, which is what makes calling this on every request cheap.
|
||||||
*/
|
*/
|
||||||
function scanGroup(email: string, cwd: string): TranscriptSummary[] {
|
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
||||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
|
||||||
if (!existsSync(dir)) return [];
|
if (!existsSync(dir)) return [];
|
||||||
|
|
||||||
const sessions: TranscriptSummary[] = [];
|
const sessions: TranscriptSummary[] = [];
|
||||||
@@ -792,8 +822,8 @@ function scanGroup(email: string, cwd: string): TranscriptSummary[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
|
||||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
||||||
return mergeChains(scanGroup(email, cwd))
|
return mergeChains(scanGroup(who, cwd))
|
||||||
.map(publish)
|
.map(publish)
|
||||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||||
}
|
}
|
||||||
@@ -810,24 +840,74 @@ export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSum
|
|||||||
* still opens a real transcript, so answer for the part itself rather than 404 the title.
|
* still opens a real transcript, so answer for the part itself rather than 404 the title.
|
||||||
*/
|
*/
|
||||||
export function claudeSessionContext(
|
export function claudeSessionContext(
|
||||||
email: string,
|
who: ChatIdentity,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): { title: string; partCount: number } | null {
|
): { title: string; partCount: number } | null {
|
||||||
const merged = listClaudeSessions(email, cwd).find((entry) => entry.id === sessionId);
|
const merged = listClaudeSessions(who, cwd).find((entry) => entry.id === sessionId);
|
||||||
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
|
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
|
||||||
const part = scanGroup(email, cwd).find((entry) => entry.id === sessionId);
|
const part = scanGroup(who, cwd).find((entry) => entry.id === sessionId);
|
||||||
return part ? { title: part.title, partCount: 1 } : null;
|
return part ? { title: part.title, partCount: 1 } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display title for a session known only by id — what `/chat/live` needs.
|
||||||
|
*
|
||||||
|
* `claudeSessionContext` is the real answer but has to be told the group, and a live session can be in
|
||||||
|
* any of them; the agent reports session keys and nothing else. So find the transcript by scanning the
|
||||||
|
* slugs, take the cwd off its own first entry, and hand that to the normal path — which means the Live
|
||||||
|
* panel shows exactly the title the list shows, `/clear` chains merged and all, rather than a second
|
||||||
|
* opinion about naming.
|
||||||
|
*
|
||||||
|
* Reads the file to answer. That is a few milliseconds against a poll every ten seconds over a handful
|
||||||
|
* of live sessions, so it is not worth a cache yet — but it is worth knowing before this is called from
|
||||||
|
* anywhere hotter.
|
||||||
|
*/
|
||||||
|
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every transcript that has to go when this conversation is deleted: itself and everything it
|
* Every transcript that has to go when this conversation is deleted: itself and everything it
|
||||||
* continues. The row stands for the whole chain, so deleting it has to mean the whole chain — leaving
|
* continues. The row stands for the whole chain, so deleting it has to mean the whole chain — leaving
|
||||||
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which
|
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which
|
||||||
* reads as the delete having half worked.
|
* reads as the delete having half worked.
|
||||||
*/
|
*/
|
||||||
function chainFileIds(email: string, cwd: string, sessionId: string): string[] {
|
function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] {
|
||||||
const group = scanGroup(email, cwd);
|
const group = scanGroup(who, cwd);
|
||||||
const head = group.find((session) => session.id === sessionId);
|
const head = group.find((session) => session.id === sessionId);
|
||||||
if (!head) return [sessionId];
|
if (!head) return [sessionId];
|
||||||
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
|
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
|
||||||
|
|||||||
@@ -3,9 +3,33 @@ import { getOpenCodeServerUrl } from './opencode/sidecar-server';
|
|||||||
|
|
||||||
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
|
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
|
||||||
const CLAUDE_CODE_MODELS: ModelInfo[] = [
|
const CLAUDE_CODE_MODELS: ModelInfo[] = [
|
||||||
{ id: 'claude-code/opus', name: 'opus', provider: 'claude-code', contextWindow: 200000, maxTokens: 16000, reasoning: true, images: true },
|
{
|
||||||
{ id: 'claude-code/sonnet', name: 'sonnet', provider: 'claude-code', contextWindow: 200000, maxTokens: 16000, reasoning: true, images: true },
|
id: 'claude-code/opus',
|
||||||
{ id: 'claude-code/haiku', name: 'haiku', provider: 'claude-code', contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true },
|
name: 'opus',
|
||||||
|
provider: 'claude-code',
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 16000,
|
||||||
|
reasoning: true,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-code/sonnet',
|
||||||
|
name: 'sonnet',
|
||||||
|
provider: 'claude-code',
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 16000,
|
||||||
|
reasoning: true,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-code/haiku',
|
||||||
|
name: 'haiku',
|
||||||
|
provider: 'claude-code',
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
reasoning: false,
|
||||||
|
images: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Cache the OpenCode catalog; it's stable for a session.
|
// Cache the OpenCode catalog; it's stable for a session.
|
||||||
@@ -15,8 +39,12 @@ export function invalidateModelCache(): void {
|
|||||||
openCodeCache = null;
|
openCodeCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenCodeModel = {
|
||||||
|
capabilities?: { input?: { image?: boolean }; reasoning?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
type ProvidersResponse = {
|
type ProvidersResponse = {
|
||||||
providers?: Array<{ id?: string; models?: Record<string, unknown> }>;
|
providers?: Array<{ id?: string; models?: Record<string, OpenCodeModel> }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess).
|
// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess).
|
||||||
@@ -34,15 +62,20 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
|
|||||||
const models: ModelInfo[] = [];
|
const models: ModelInfo[] = [];
|
||||||
for (const provider of data.providers ?? []) {
|
for (const provider of data.providers ?? []) {
|
||||||
const providerId = provider.id ?? '';
|
const providerId = provider.id ?? '';
|
||||||
for (const modelId of Object.keys(provider.models ?? {})) {
|
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
||||||
models.push({
|
models.push({
|
||||||
id: `${providerId}/${modelId}`,
|
id: `${providerId}/${modelId}`,
|
||||||
name: modelId,
|
name: modelId,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 8192,
|
maxTokens: 8192,
|
||||||
reasoning: false,
|
reasoning: model?.capabilities?.reasoning ?? false,
|
||||||
images: true,
|
// Was hardcoded `false`, correctly, while nothing carried images — the composer gates on this
|
||||||
|
// flag, so advertising `true` offered a capability that did not exist. Images are now plumbed
|
||||||
|
// through `OpenCodeRunParams` to `opencode run --file`, so the honest answer is the model's
|
||||||
|
// own: OpenCode publishes it per model and we had never read it. Defaults to false, so a model
|
||||||
|
// that does not declare the capability keeps the affordance hidden rather than offering it.
|
||||||
|
images: model?.capabilities?.input?.image ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { isPlaceholderOpenCodeTitle } from './opencode-sessions';
|
||||||
|
|
||||||
|
// OpenCode names a session `New session - <ISO>` until it has titled it from the conversation, which it
|
||||||
|
// does asynchronously. The strings below are real ones observed on 1.18.16 — both the placeholder and
|
||||||
|
// the good title it was replaced by.
|
||||||
|
|
||||||
|
describe('isPlaceholderOpenCodeTitle', () => {
|
||||||
|
it('recognises the placeholder OpenCode uses before it has titled a session', () => {
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T15:44:17.178Z')).toBe(true);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T12:41:02.757Z')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an absent or empty title as unnamed too', () => {
|
||||||
|
expect(isPlaceholderOpenCodeTitle(undefined)).toBe(true);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('')).toBe(true);
|
||||||
|
expect(isPlaceholderOpenCodeTitle(' ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a real title, including one that merely mentions a session', () => {
|
||||||
|
// The title OpenCode actually gave a turn of ours, and the reason this is a pattern match rather
|
||||||
|
// than a `startsWith('New session')`: a genuine title is allowed to begin with those words.
|
||||||
|
expect(isPlaceholderOpenCodeTitle('Single color in oc-red2.png')).toBe(false);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session handling in the runner')).toBe(false);
|
||||||
|
expect(isPlaceholderOpenCodeTitle('New session - what should it be called?')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,31 +1,58 @@
|
|||||||
import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions';
|
import type { ClaudeSessionSummary, ClaudeSessionDetail, ClaudeChatMessage } from './claude-sessions';
|
||||||
import { ensureServer } from './opencode/server-manager';
|
import { ensureServer } from './opencode/server-manager';
|
||||||
import { getConnection, officerMeta } from './opencode/client';
|
import { getConnection } from './opencode/client';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
// The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the
|
// The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the
|
||||||
// fixed pm2-managed server's HTTP API (never the DB directly). All OpenCode /chat sessions live in that
|
// fixed pm2-managed server's HTTP API (never the DB directly). Returns the same shapes as the Claude
|
||||||
// one server's project, so listing is just GET /session. Returns the same shapes as the Claude reader,
|
// reader, tagged harness:'opencode', so chat.ts can merge both harnesses transparently.
|
||||||
// tagged harness:'opencode', so chat.ts can merge both harnesses transparently.
|
//
|
||||||
|
// Sessions do NOT all live in one project. Each records the directory its turn ran in — the serve takes
|
||||||
|
// it per request as `x-opencode-directory` — and one serve holds sessions for many. That is why the cwd
|
||||||
|
// filter has to read `directory` rather than assume, and why the list has to come from a read that spans
|
||||||
|
// projects (`client.listSessions`, which is where the project-scoping trap is written up).
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List OpenCode sessions tagged with the given logical cwd (via `metadata.officer.cwd`), so each
|
* List OpenCode sessions for a working directory, so each context (/chat pwd, email account, project)
|
||||||
* context (/chat pwd, email account, project) sees only its own. With no cwd, returns all. Never
|
* sees only its own. With no cwd, returns all. Never throws — returns [] if the server is unavailable.
|
||||||
* throws — returns [] if the server is unavailable.
|
*
|
||||||
|
* Filters on the session's own `directory`, which is what OpenCode records from the directory the turn
|
||||||
|
* ran in. It used to filter on `metadata.officer.cwd`, a tag whose only writer
|
||||||
|
* (`client.createSession`) has no callers — so the comparison was against `undefined` for every session
|
||||||
|
* and the list was ALWAYS empty. `cwdOf` in chat.ts substitutes a default when no `?cwd=` is given, so
|
||||||
|
* the `!cwd` escape never fired either and there was no configuration in which an OpenCode session
|
||||||
|
* appeared in /chat. Verified against the live server: 7 sessions present, 0 returned.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* OpenCode's own placeholder name for a session it has not titled yet.
|
||||||
|
*
|
||||||
|
* It titles a session from the conversation, but ASYNCHRONOUSLY and after there is something to read —
|
||||||
|
* a finished turn ends up called "Single color in oc-red2.png", which is better than anything we would
|
||||||
|
* generate. Until then the session is literally named `New session - 2026-08-10T15:44:17.178Z`.
|
||||||
|
*
|
||||||
|
* That window is exactly when a session is most visible: `/chat/live` shows turns that are RUNNING, so
|
||||||
|
* the placeholder is what the panel catches. Treating it as "not yet titled" lets the good name arrive
|
||||||
|
* on its own. Forcing `--title` on the run instead would fix the transient case by permanently
|
||||||
|
* replacing opencode's title with a truncated prompt — worse where it lasts longest.
|
||||||
|
*/
|
||||||
|
export const isPlaceholderOpenCodeTitle = (title: string | undefined): boolean => {
|
||||||
|
const trimmed = title?.trim();
|
||||||
|
return !trimmed || /^New session - \d{4}-\d{2}-\d{2}T[\d:.]+Z?$/.test(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionSummary[]> {
|
export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionSummary[]> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer();
|
const { baseUrl } = await ensureServer();
|
||||||
const sessions = await getConnection(baseUrl).listSessions();
|
const sessions = await getConnection(baseUrl).listSessions();
|
||||||
return sessions
|
return sessions
|
||||||
.filter((s) => !cwd || officerMeta(s.metadata).cwd === cwd)
|
.filter((s) => !cwd || s.directory === cwd)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const created = s.time?.created ?? Date.now();
|
const created = s.time?.created ?? Date.now();
|
||||||
const updated = s.time?.updated ?? created;
|
const updated = s.time?.updated ?? created;
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.title || '(untitled)',
|
title: isPlaceholderOpenCodeTitle(s.title) ? '(untitled)' : (s.title ?? '(untitled)'),
|
||||||
cwd: s.location?.directory ?? '',
|
cwd: s.directory ?? '',
|
||||||
createdAt: new Date(created).toISOString(),
|
createdAt: new Date(created).toISOString(),
|
||||||
updatedAt: new Date(updated).toISOString(),
|
updatedAt: new Date(updated).toISOString(),
|
||||||
messageCount: 0, // the session list endpoint doesn't include a turn count
|
messageCount: 0, // the session list endpoint doesn't include a turn count
|
||||||
@@ -42,7 +69,11 @@ export async function listOpenCodeSessions(cwd?: string): Promise<ClaudeSessionS
|
|||||||
export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSessionDetail | null> {
|
export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSessionDetail | null> {
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = await ensureServer();
|
const { baseUrl } = await ensureServer();
|
||||||
const stored = await getConnection(baseUrl).getMessages(sessionId);
|
const conn = getConnection(baseUrl);
|
||||||
|
// Both reads, together: the transcript, and the session record that carries its directory. OpenCode
|
||||||
|
// needs the cwd on EVERY turn (it rebuilds its working-directory system prompt each time), so a
|
||||||
|
// resume that reports '' silently relocates the conversation to the default chat dir.
|
||||||
|
const [stored, info] = await Promise.all([conn.getMessages(sessionId), conn.getSession(sessionId)]);
|
||||||
|
|
||||||
const messages: ClaudeChatMessage[] = [];
|
const messages: ClaudeChatMessage[] = [];
|
||||||
let modelId = '';
|
let modelId = '';
|
||||||
@@ -81,7 +112,12 @@ export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd: '', messages };
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
model: modelId ? `opencode/${modelId}` : 'opencode',
|
||||||
|
cwd: info?.directory ?? '',
|
||||||
|
messages,
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
|
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,149 +1,50 @@
|
|||||||
import type { OpenCodeEvent } from './event-mapper';
|
// One connection per `opencode serve` base URL, for the REST reads the chat list and transcript need.
|
||||||
import { logger } from '../logger';
|
// The SDK-style `/session/*` route family is used for the per-id reads and writes (feature-complete,
|
||||||
|
// incl. DELETE) — those answer for any session regardless of project, verified 200 with and without a
|
||||||
// One connection per `opencode serve` base URL: a single shared SSE subscription on `GET /event`
|
// directory header. The LIST is the exception and goes through `/api/session`; see `listSessions`.
|
||||||
// demultiplexed to per-session listeners, plus the REST calls a turn needs. The SDK-style
|
//
|
||||||
// `/session/*` route family is used (feature-complete, incl. DELETE).
|
// This used to also hold a shared SSE subscription (`GET /event`) demultiplexed per session, plus
|
||||||
|
// `createSession`, `postMessage`, `abort` and `isServerHealthy` — the client half of a serve-based turn
|
||||||
type Listener = (event: OpenCodeEvent) => void;
|
// path that was replaced by `opencode run --dir` subprocesses and had no callers left. Removed
|
||||||
|
// 2026-08-10; `docs/opencode-serve-path.md` records what it did and what a rebuild would want back,
|
||||||
|
// because Phase 2 may want exactly this shape again.
|
||||||
|
|
||||||
class ServerConnection {
|
class ServerConnection {
|
||||||
private listeners = new Map<string, Set<Listener>>();
|
|
||||||
private sseRunning = false;
|
|
||||||
|
|
||||||
constructor(private readonly baseUrl: string) {}
|
constructor(private readonly baseUrl: string) {}
|
||||||
|
|
||||||
subscribe(sessionId: string, listener: Listener): () => void {
|
|
||||||
let set = this.listeners.get(sessionId);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
this.listeners.set(sessionId, set);
|
|
||||||
}
|
|
||||||
set.add(listener);
|
|
||||||
void this.ensureSse();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const current = this.listeners.get(sessionId);
|
|
||||||
if (!current) return;
|
|
||||||
current.delete(listener);
|
|
||||||
if (current.size === 0) this.listeners.delete(sessionId);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatch(event: OpenCodeEvent): void {
|
|
||||||
const sessionId = event.properties?.sessionID;
|
|
||||||
if (typeof sessionId !== 'string') return;
|
|
||||||
const set = this.listeners.get(sessionId);
|
|
||||||
if (!set) return;
|
|
||||||
for (const listener of set) {
|
|
||||||
try {
|
|
||||||
listener(event);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('opencode SSE listener threw', { error: String(err) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep a single `/event` SSE stream open while any session has listeners; reconnect on drop. */
|
|
||||||
private async ensureSse(): Promise<void> {
|
|
||||||
if (this.sseRunning) return;
|
|
||||||
this.sseRunning = true;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
while (this.listeners.size > 0) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${this.baseUrl}/event`, { headers: { accept: 'text/event-stream' } });
|
|
||||||
if (!res.body) throw new Error('no SSE body');
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = '';
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
|
|
||||||
// SSE frames are separated by a blank line.
|
|
||||||
let sep = buffer.indexOf('\n\n');
|
|
||||||
while (sep !== -1) {
|
|
||||||
const frame = buffer.slice(0, sep);
|
|
||||||
buffer = buffer.slice(sep + 2);
|
|
||||||
this.handleFrame(frame);
|
|
||||||
sep = buffer.indexOf('\n\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('opencode SSE stream error; will reconnect', { baseUrl: this.baseUrl, error: String(err) });
|
|
||||||
}
|
|
||||||
if (this.listeners.size > 0) await new Promise((r) => setTimeout(r, 500));
|
|
||||||
}
|
|
||||||
this.sseRunning = false;
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleFrame(frame: string): void {
|
|
||||||
const data = frame
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => line.startsWith('data:'))
|
|
||||||
.map((line) => line.slice(5).trim())
|
|
||||||
.join('');
|
|
||||||
if (!data) return;
|
|
||||||
try {
|
|
||||||
this.dispatch(JSON.parse(data) as OpenCodeEvent);
|
|
||||||
} catch {
|
|
||||||
/* non-JSON keep-alive or partial frame */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async postJson<T>(path: string, body: unknown): Promise<T> {
|
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`opencode POST ${path} → ${res.status} ${await res.text().catch(() => '')}`);
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSession(metadata?: Record<string, unknown>, title?: string): Promise<string> {
|
|
||||||
// OpenCode has no per-session `directory` (a session inherits the server's cwd). We instead tag
|
|
||||||
// the session with our own free-form `metadata` (e.g. { officer: { cwd } }) — round-trips on the
|
|
||||||
// list + detail endpoints and is never touched by opencode core — to know where it belongs.
|
|
||||||
const body: Record<string, unknown> = {};
|
|
||||||
if (metadata) body.metadata = metadata;
|
|
||||||
if (title) body.title = title;
|
|
||||||
const session = await this.postJson<{ id?: string }>('/session', body);
|
|
||||||
if (!session.id) throw new Error('opencode POST /session returned no id');
|
|
||||||
return session.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async postMessage(
|
|
||||||
sessionId: string,
|
|
||||||
providerID: string,
|
|
||||||
modelID: string,
|
|
||||||
text: string,
|
|
||||||
system?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
// `system` is appended to OpenCode's built-in system prompt (additive, not an override) and is
|
|
||||||
// sent to the LLM as a system message — it never appears as a visible chat part.
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
model: { providerID, modelID },
|
|
||||||
parts: [{ type: 'text', text }],
|
|
||||||
};
|
|
||||||
if (system) body.system = system;
|
|
||||||
await this.postJson(`/session/${sessionId}/message`, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async abort(sessionId: string): Promise<void> {
|
|
||||||
await this.postJson(`/session/${sessionId}/abort`, {}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
|
// ── Session history (REST; OpenCode's SQLite store is the source of truth) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every session the serve knows, across every project.
|
||||||
|
*
|
||||||
|
* `GET /session` — the endpoint this used — answers for ONE project: the one the request's directory
|
||||||
|
* resolves to, which with no `x-opencode-directory` header is the serve's own cwd
|
||||||
|
* (`DATA_PATH/opencode_server`). That directory is not a git checkout, so it resolves to the catch-all
|
||||||
|
* project `global`, and so does every other non-git directory — which is why the general chat dir
|
||||||
|
* listed fine and nothing looked wrong. A cwd that IS a git checkout gets its own project and its
|
||||||
|
* sessions were simply never returned. Measured on the live serve: `/session` 8, `/api/session` 13,
|
||||||
|
* and a session created in a git directory came back 0 times from `/session` and 1 from `/api/session`.
|
||||||
|
*
|
||||||
|
* Chat pwds are project directories, so that is the ordinary case, not the edge one.
|
||||||
|
*
|
||||||
|
* The two surfaces disagree about the shape, and the difference is silent: `/session` carries the
|
||||||
|
* working directory as top-level `directory`, `/api/session` as `location.directory` with no top-level
|
||||||
|
* field at all, wrapped in `{data: …}`. Normalised here so callers keep reading `directory` — reading
|
||||||
|
* the wrong one yields `undefined` for every session, which filters the list down to nothing.
|
||||||
|
*/
|
||||||
async listSessions(): Promise<OpenCodeSessionInfo[]> {
|
async listSessions(): Promise<OpenCodeSessionInfo[]> {
|
||||||
const res = await fetch(`${this.baseUrl}/session`);
|
const res = await fetch(`${this.baseUrl}/api/session`);
|
||||||
if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`);
|
if (!res.ok) throw new Error(`opencode GET /api/session → ${res.status}`);
|
||||||
return (await res.json()) as OpenCodeSessionInfo[];
|
const body = (await res.json()) as { data?: ApiSessionInfo[] } | ApiSessionInfo[];
|
||||||
|
const sessions = Array.isArray(body) ? body : (body.data ?? []);
|
||||||
|
return sessions.map((s) => ({ ...s, directory: s.directory ?? s.location?.directory ?? null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One session's own record. The only place its working directory can be read on resume. */
|
||||||
|
async getSession(sessionId: string): Promise<OpenCodeSessionInfo | null> {
|
||||||
|
const res = await fetch(`${this.baseUrl}/session/${sessionId}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as OpenCodeSessionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
|
async getMessages(sessionId: string): Promise<OpenCodeStoredMessage[]> {
|
||||||
@@ -167,19 +68,34 @@ class ServerConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shapes returned by the `/session/*` read endpoints (verified against opencode 1.17.9).
|
// Shapes returned by the `/session/*` read endpoints.
|
||||||
|
//
|
||||||
|
// Re-verified against the running opencode 1.17.9 on 2026-08-10 by reading `GET /session` directly. The
|
||||||
|
// keys it actually returns are:
|
||||||
|
//
|
||||||
|
// id, slug, projectID, directory, path, summary, cost, tokens, title, agent, model, version, time,
|
||||||
|
// permission
|
||||||
|
//
|
||||||
|
// Two fields this type used to declare are NOT among them, and both were load-bearing:
|
||||||
|
//
|
||||||
|
// - `location.directory` — there is no `location` object. The working directory is top-level
|
||||||
|
// `directory`. Every read of `location?.directory ?? ''` therefore produced '', which is why a
|
||||||
|
// resumed OpenCode session lost its cwd.
|
||||||
|
// - `metadata` — never returned, and nothing writes it. The session list filtered on
|
||||||
|
// `metadata.officer.cwd`, so it matched nothing and no OpenCode session was ever listed.
|
||||||
|
//
|
||||||
|
// Kept deliberately narrow: only the fields we read. Adding one means confirming it against a live
|
||||||
|
// server first — the previous pair were plausible and wrong, and cost two user-visible defects.
|
||||||
export type OpenCodeSessionInfo = {
|
export type OpenCodeSessionInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
time?: { created?: number; updated?: number };
|
time?: { created?: number; updated?: number };
|
||||||
location?: { directory?: string | null };
|
/** Absolute working directory the session belongs to. Top-level; there is no `location` wrapper. */
|
||||||
metadata?: Record<string, unknown>;
|
directory?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Our own namespaced session metadata (stored under the free-form `metadata.officer` key).
|
/** `/api/session`'s shape: the same record, with the directory one level down. Normalised by `listSessions`. */
|
||||||
export type OfficerSessionMeta = { cwd?: string };
|
type ApiSessionInfo = OpenCodeSessionInfo & { location?: { directory?: string | null } };
|
||||||
export const officerMeta = (m?: Record<string, unknown>): OfficerSessionMeta =>
|
|
||||||
(m?.officer as OfficerSessionMeta) ?? {};
|
|
||||||
|
|
||||||
export type OpenCodeStoredPart = {
|
export type OpenCodeStoredPart = {
|
||||||
type?: string;
|
type?: string;
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import type { ChatEvent, MessageCost } from '../types';
|
|
||||||
|
|
||||||
// Translates OpenCode's SSE events into our internal ChatEvent union — the OpenCode analog of
|
|
||||||
// sidecar/claude/stream-parser.ts. Shapes verified live against opencode 1.17.9's `GET /event`:
|
|
||||||
//
|
|
||||||
// message.part.updated { part:{ id, type } } → learn partID→type
|
|
||||||
// message.part.delta { partID, delta } → delta (only if partID is a text part)
|
|
||||||
// message.part.updated { part:{ type:'tool', callID, tool, state } } → tool:start / tool:result
|
|
||||||
// message.updated { info:{ role:'assistant', cost, tokens } } → stash final cost
|
|
||||||
// session.idle → result (turn complete)
|
|
||||||
// session.error → error
|
|
||||||
//
|
|
||||||
// IMPORTANT: deltas always carry `field:'text'` even for the model's *reasoning* — reasoning and answer
|
|
||||||
// are only distinguishable by the delta's part TYPE (a message.part.updated declaring the part as
|
|
||||||
// `reasoning` vs `text` always precedes that part's deltas). So we gate deltas on partID being a `text`
|
|
||||||
// part; reasoning-part deltas are dropped (parity with the Claude harness, which hides thinking).
|
|
||||||
// The turn translator (`sidecar/claude/turn-stream.ts`, shared by both harnesses) flushes the assistant
|
|
||||||
// text buffer on tool:start and result, so no explicit `text` event is needed — the deltas are enough.
|
|
||||||
|
|
||||||
export type OpenCodeEvent = {
|
|
||||||
id?: string;
|
|
||||||
type: string;
|
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToolState = {
|
|
||||||
status?: string;
|
|
||||||
input?: Record<string, unknown>;
|
|
||||||
output?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OpenCodePart = {
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
callID?: string;
|
|
||||||
tool?: string;
|
|
||||||
field?: string;
|
|
||||||
state?: ToolState;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AssistantInfo = {
|
|
||||||
role?: string;
|
|
||||||
cost?: number;
|
|
||||||
tokens?: { input?: number; output?: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a stateful handler that maps raw OpenCode events to ChatEvents, invoking `onEvent` for each.
|
|
||||||
* State tracks per-callID tool progress (to emit start/result exactly once) and the latest assistant
|
|
||||||
* cost/tokens (emitted with the terminal `result`).
|
|
||||||
*/
|
|
||||||
export function createEventMapper(onEvent: (event: ChatEvent) => void) {
|
|
||||||
const toolStarted = new Set<string>();
|
|
||||||
const toolFinished = new Set<string>();
|
|
||||||
const partTypes = new Map<string, string>(); // partID → type (declared before that part's deltas)
|
|
||||||
let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
|
||||||
let done = false;
|
|
||||||
|
|
||||||
const finish = (event: ChatEvent) => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
onEvent(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
return function handle(evt: OpenCodeEvent): void {
|
|
||||||
const p = evt.properties ?? {};
|
|
||||||
|
|
||||||
switch (evt.type) {
|
|
||||||
case 'message.part.delta': {
|
|
||||||
// Only stream deltas belonging to a `text` part — reasoning parts also emit field:'text' deltas.
|
|
||||||
const partID = p.partID as string | undefined;
|
|
||||||
if (partID && partTypes.get(partID) === 'text' && typeof p.delta === 'string' && p.delta.length > 0) {
|
|
||||||
onEvent({ type: 'delta', text: p.delta });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'message.part.updated': {
|
|
||||||
const part = p.part as OpenCodePart | undefined;
|
|
||||||
if (!part) return;
|
|
||||||
if (part.id && part.type) partTypes.set(part.id, part.type);
|
|
||||||
if (part.type !== 'tool' || !part.callID) return;
|
|
||||||
const callID = part.callID;
|
|
||||||
const status = part.state?.status;
|
|
||||||
|
|
||||||
if ((status === 'running' || status === 'completed' || status === 'error') && !toolStarted.has(callID)) {
|
|
||||||
toolStarted.add(callID);
|
|
||||||
onEvent({
|
|
||||||
type: 'tool:start',
|
|
||||||
toolCallId: callID,
|
|
||||||
toolName: part.tool ?? 'tool',
|
|
||||||
toolInput: part.state?.input ?? {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'completed' && !toolFinished.has(callID)) {
|
|
||||||
toolFinished.add(callID);
|
|
||||||
onEvent({
|
|
||||||
type: 'tool:result',
|
|
||||||
toolCallId: callID,
|
|
||||||
output: String(part.state?.output ?? ''),
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
} else if (status === 'error' && !toolFinished.has(callID)) {
|
|
||||||
toolFinished.add(callID);
|
|
||||||
onEvent({
|
|
||||||
type: 'tool:result',
|
|
||||||
toolCallId: callID,
|
|
||||||
output: String(part.state?.error ?? 'Tool failed'),
|
|
||||||
isError: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'message.updated': {
|
|
||||||
const info = (p.info as AssistantInfo | undefined) ?? (p as AssistantInfo) ?? {};
|
|
||||||
if (info.role === 'assistant' && info.tokens) {
|
|
||||||
cost = {
|
|
||||||
inputTokens: info.tokens.input ?? 0,
|
|
||||||
outputTokens: info.tokens.output ?? 0,
|
|
||||||
totalUSD: info.cost ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'session.idle': {
|
|
||||||
finish({ type: 'result', cost });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'session.error': {
|
|
||||||
const error = p.error;
|
|
||||||
const message =
|
|
||||||
typeof error === 'string' ? error : ((error as { message?: string })?.message ?? 'OpenCode error');
|
|
||||||
finish({ type: 'error', message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
import { getOpenCodeServerUrl } from './sidecar-server';
|
import { getOpenCodeServerUrl } from './sidecar-server';
|
||||||
|
|
||||||
// The OpenCode server is owned by the officer-opencode sidecar, which starts `opencode serve` on a
|
// The OpenCode server is owned by the officer-opencode sidecar, which starts `opencode serve` on a
|
||||||
// random port (cwd = DATA_PATH/opencode-sidecar) and reports it to the API (getOpenCodeServerUrl).
|
// random port (cwd = DATA_PATH/opencode_server) and reports it to the API (getOpenCodeServerUrl).
|
||||||
// All OpenCode HTTP traffic routes to whatever port the sidecar last reported.
|
// All OpenCode HTTP traffic routes to whatever port the sidecar last reported.
|
||||||
|
|
||||||
export async function isServerHealthy(): Promise<boolean> {
|
// `isServerHealthy` lived here and had no callers — removed 2026-08-10 with the rest of the dead
|
||||||
const baseUrl = getOpenCodeServerUrl();
|
// serve-turn client. It polled `/api/health`, which is a real route and answers `{"healthy":true}`, so
|
||||||
if (!baseUrl) return false;
|
// it worked; it simply had nothing asking. `/global/health` is the richer one (it also reports the
|
||||||
try {
|
// version) if a health check is ever wanted back.
|
||||||
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The base URL of the OpenCode server the sidecar is running. Throws if it hasn't reported in yet. */
|
/** The base URL of the OpenCode server the sidecar is running. Throws if it hasn't reported in yet. */
|
||||||
export async function ensureServer(): Promise<{ baseUrl: string }> {
|
export async function ensureServer(): Promise<{ baseUrl: string }> {
|
||||||
|
|||||||
@@ -12,4 +12,25 @@ export const setOpenCodeSession = (sessionKey: string, opencodeSessionId: string
|
|||||||
|
|
||||||
export const clearOpenCodeSession = (sessionKey: string): void => {
|
export const clearOpenCodeSession = (sessionKey: string): void => {
|
||||||
sessionKeyToOpenCode.delete(sessionKey);
|
sessionKeyToOpenCode.delete(sessionKey);
|
||||||
|
sessionKeyToPrompt.delete(sessionKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What the user asked, kept only to name a session OpenCode has not named yet.
|
||||||
|
//
|
||||||
|
// OpenCode titles a session from the conversation, and does it well — but asynchronously, so a RUNNING
|
||||||
|
// turn is called `New session - <ISO>`, which is precisely when `/chat/live` displays it. This is the
|
||||||
|
// stand-in for that window: shown while the real title is still the placeholder, and dropped the moment
|
||||||
|
// OpenCode publishes its own, which is always the better one.
|
||||||
|
//
|
||||||
|
// Set on the FIRST turn of a session only, so it stays the name of the conversation rather than
|
||||||
|
// following whatever was asked most recently.
|
||||||
|
const sessionKeyToPrompt = new Map<string, string>();
|
||||||
|
|
||||||
|
export const rememberOpenCodePrompt = (sessionKey: string, prompt: string): void => {
|
||||||
|
if (sessionKeyToPrompt.has(sessionKey)) return;
|
||||||
|
const oneLine = prompt.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!oneLine) return;
|
||||||
|
sessionKeyToPrompt.set(sessionKey, oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOpenCodePrompt = (sessionKey: string): string | undefined => sessionKeyToPrompt.get(sessionKey);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { UserSession } from "./types";
|
import type { UserSession } from './types';
|
||||||
import { logger } from "./logger";
|
import { logger } from './logger';
|
||||||
|
import { clearOpenCodeSession } from './opencode/state';
|
||||||
|
|
||||||
class SessionManager {
|
class SessionManager {
|
||||||
private sessions = new Map<string, UserSession>();
|
private sessions = new Map<string, UserSession>();
|
||||||
@@ -23,16 +24,16 @@ class SessionManager {
|
|||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
piProcess: null,
|
piProcess: null,
|
||||||
ws: null,
|
sockets: new Set(),
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
idleTimer: null,
|
idleTimer: null,
|
||||||
streamBuffer: "",
|
streamBuffer: '',
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
systemContextSent: false,
|
systemContextSent: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
meta: {
|
meta: {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
title: "",
|
title: '',
|
||||||
model,
|
model,
|
||||||
cwd,
|
cwd,
|
||||||
groupSlug: groupSlug || null,
|
groupSlug: groupSlug || null,
|
||||||
@@ -66,19 +67,36 @@ class SessionManager {
|
|||||||
|
|
||||||
getUserSessions(email: string): UserSession[] {
|
getUserSessions(email: string): UserSession[] {
|
||||||
const sessionIds = this.userSessions.get(email) || [];
|
const sessionIds = this.userSessions.get(email) || [];
|
||||||
return sessionIds
|
return sessionIds.map((id) => this.sessions.get(id)).filter((s): s is UserSession => s !== undefined);
|
||||||
.map((id) => this.sessions.get(id))
|
|
||||||
.filter((s): s is UserSession => s !== undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSession(sessionId: string): void {
|
/** Drop this process's record of a session. Shared by `deleteSession` and `releaseSession`. */
|
||||||
const session = this.sessions.get(sessionId);
|
private forget(session: UserSession): void {
|
||||||
if (!session) return;
|
|
||||||
|
|
||||||
if (session.idleTimer) {
|
if (session.idleTimer) {
|
||||||
clearTimeout(session.idleTimer);
|
clearTimeout(session.idleTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.sessions.delete(session.sessionId);
|
||||||
|
|
||||||
|
const userSessionIds = this.userSessions.get(session.email);
|
||||||
|
if (userSessionIds) {
|
||||||
|
const filtered = userSessionIds.filter((id) => id !== session.sessionId);
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
this.userSessions.set(session.email, filtered);
|
||||||
|
} else {
|
||||||
|
this.userSessions.delete(session.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the session for good: kill the agent upstream, drop the subscription, forget the record. This is
|
||||||
|
* what an explicit "disconnect" means — the user said stop.
|
||||||
|
*/
|
||||||
|
deleteSession(sessionId: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
// Clean up sidecar subscriptions
|
// Clean up sidecar subscriptions
|
||||||
if (session._sidecarUnsub) {
|
if (session._sidecarUnsub) {
|
||||||
session._sidecarUnsub();
|
session._sidecarUnsub();
|
||||||
@@ -87,25 +105,47 @@ class SessionManager {
|
|||||||
session._claudeKill();
|
session._claudeKill();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessions.delete(sessionId);
|
// The OpenCode sessionKey → `ses_…` map is keyed on our session id and nothing else cleared it, so
|
||||||
|
// it grew for the process lifetime and a REUSED key silently resumed a stale OpenCode session.
|
||||||
|
// Only on deleteSession, never on releaseSession: releasing is "let go, leave it running", and a
|
||||||
|
// returning browser has to be able to find the same `ses_…` again.
|
||||||
|
clearOpenCodeSession(sessionId);
|
||||||
|
|
||||||
const userSessionIds = this.userSessions.get(session.email);
|
this.forget(session);
|
||||||
if (userSessionIds) {
|
}
|
||||||
const filtered = userSessionIds.filter(
|
|
||||||
(id) => id !== sessionId
|
/**
|
||||||
);
|
* Let go WITHOUT killing: unsubscribe, forget the record, leave the agent running.
|
||||||
if (filtered.length > 0) {
|
*
|
||||||
this.userSessions.set(session.email, filtered);
|
* This is what the idle GC should always have done. Officer's timer was doing two unrelated jobs —
|
||||||
} else {
|
* collecting its own in-memory binding, which is its business, and terminating the agent, which is the
|
||||||
this.userSessions.delete(session.email);
|
* sidecar's. The sidecar already refuses to collect a session that is mid-turn or holding background
|
||||||
}
|
* tasks (`claude-manager.ts` → `pendingTasks`, and an `armIdle` that re-checks rather than firing
|
||||||
|
* once); officer knew none of that and killed anyway. A `run_in_background` job outliving the browser
|
||||||
|
* — a laptop that ran out of battery — died an hour later for no reason.
|
||||||
|
*
|
||||||
|
* Nothing is stranded by forgetting the record. The sidecar keeps committing to `chat_session_events`,
|
||||||
|
* and a returning browser goes through `adoptOrphanedSession`, which rebuilds the record and a fresh
|
||||||
|
* subscription. That is the same path every `pm2 restart officer` already takes.
|
||||||
|
*/
|
||||||
|
releaseSession(sessionId: string): void {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
// Only the listener goes. `_claudeKill` is deliberately NOT called — and must not be left behind
|
||||||
|
// either: the record is being dropped, so the next turn starts from `adoptOrphanedSession`, which
|
||||||
|
// installs its own kill and subscription.
|
||||||
|
if (session._sidecarUnsub) {
|
||||||
|
session._sidecarUnsub();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.forget(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
attachWs(sessionId: string, ws: any): void {
|
attachWs(sessionId: string, ws: any): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.ws = ws;
|
session.sockets.add(ws);
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
|
|
||||||
if (session.idleTimer) {
|
if (session.idleTimer) {
|
||||||
@@ -115,14 +155,24 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
detachWs(sessionId: string): void {
|
/**
|
||||||
|
* Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the
|
||||||
|
* session's only socket field, so a stale client's close event silenced whichever client had attached
|
||||||
|
* after it. A close is only the end of the conversation when nothing else is still watching.
|
||||||
|
*/
|
||||||
|
detachWs(sessionId: string, ws: any): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.ws = null;
|
session.sockets.delete(ws);
|
||||||
session.lastActivity = Date.now();
|
session.lastActivity = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether anything is still watching — the idle GC must not start while another client is attached. */
|
||||||
|
hasSockets(sessionId: string): boolean {
|
||||||
|
return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
setIdleTimeout(sessionId: string, timeoutMs: number): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -132,8 +182,11 @@ class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.idleTimer = setTimeout(() => {
|
session.idleTimer = setTimeout(() => {
|
||||||
logger.info('Session idle timeout reached, cleaning up', { sessionId, timeoutMs });
|
logger.info('Session idle timeout reached, releasing binding (agent left running)', {
|
||||||
this.deleteSession(sessionId);
|
sessionId,
|
||||||
|
timeoutMs,
|
||||||
|
});
|
||||||
|
this.releaseSession(sessionId);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export type ClientMessage =
|
|||||||
// inside one bubble. Attributing them lets the UI file them under the Task row that spawned them.
|
// inside one bubble. Attributing them lets the UI file them under the Task row that spawned them.
|
||||||
type Parented = { parentToolUseId?: string };
|
type Parented = { parentToolUseId?: string };
|
||||||
|
|
||||||
|
/** A background task with no terminal notification yet. Same fields as `task:started`, by construction. */
|
||||||
|
export type RunningTask = { taskId: string; description: string; taskType?: string };
|
||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| {
|
| {
|
||||||
type: 'session:init';
|
type: 'session:init';
|
||||||
@@ -195,6 +198,16 @@ export type ServerMessage =
|
|||||||
* conversation.
|
* conversation.
|
||||||
*/
|
*/
|
||||||
cursor: number;
|
cursor: number;
|
||||||
|
/**
|
||||||
|
* Background tasks that were started and have not reported a terminal status. The one exception to
|
||||||
|
* "carries no messages", and for a concrete reason: a task row is officer's own invention — it is
|
||||||
|
* synthesised from the harness's `system.task_started`, and nothing corresponding to it is ever
|
||||||
|
* written to Claude's transcript. So unlike every other message, rebuilding the transcript cannot
|
||||||
|
* produce it, and a refresh mid-task left the tray empty while the work was still running.
|
||||||
|
* Finished tasks are deliberately excluded: replaying those would resurrect rows the user has
|
||||||
|
* already seen resolve, and only the running ones are still telling you anything.
|
||||||
|
*/
|
||||||
|
runningTasks: RunningTask[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'error';
|
type: 'error';
|
||||||
@@ -286,7 +299,13 @@ export type UserSession = {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
model: string;
|
model: string;
|
||||||
piProcess: any | null;
|
piProcess: any | null;
|
||||||
ws: any | null;
|
/**
|
||||||
|
* Every socket watching this conversation, not the most recent one. Two panes in one window, or a
|
||||||
|
* laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a
|
||||||
|
* single `ws` field meant the newest attach silently stole the turn from everyone else, while any one
|
||||||
|
* of them closing set it to null and killed delivery for the rest.
|
||||||
|
*/
|
||||||
|
sockets: Set<any>;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
idleTimer: Timer | null;
|
idleTimer: Timer | null;
|
||||||
streamBuffer: string;
|
streamBuffer: string;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { decideResume } from './websocket';
|
||||||
|
|
||||||
|
// The first test in api/chat, which had none.
|
||||||
|
//
|
||||||
|
// It covers the harness decision on reconnect — the one place where guessing wrong writes something
|
||||||
|
// permanent. `endTurnIfAgentIsGone` appends a durable `cut-off` row, so a session misidentified here
|
||||||
|
// gets "the agent went away" written into a turn that is running normally, and it survives every reload
|
||||||
|
// because surviving reloads is what that row is for.
|
||||||
|
//
|
||||||
|
// The old code was `msg.model || DEFAULT_MODEL` with `DEFAULT_MODEL = 'claude-code'`, and it was masked
|
||||||
|
// only by the client always happening to send `model`. These tests exist so the default cannot come back.
|
||||||
|
|
||||||
|
describe('decideResume', () => {
|
||||||
|
it("takes the server's own record over the client's claim", () => {
|
||||||
|
// The socket does not get to re-declare a session's harness. This is the direction that matters:
|
||||||
|
// believing the claim is how an OpenCode session ends up subscribed to the Claude sidecar.
|
||||||
|
expect(decideResume('opencode/big-pickle', 'claude-code')).toEqual({
|
||||||
|
kind: 'known',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts on the claim only when this process has no record of the session', () => {
|
||||||
|
// An officer restart drops the in-memory session while the agent carries on, so the client is the
|
||||||
|
// only one left who knows. Trusting it here is not the same as trusting it above.
|
||||||
|
expect(decideResume(undefined, 'opencode/big-pickle')).toEqual({ kind: 'adopt', model: 'opencode/big-pickle' });
|
||||||
|
expect(decideResume(undefined, 'claude-code')).toEqual({ kind: 'adopt', model: 'claude-code' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a total unknown as an assumption instead of passing it off as knowledge', () => {
|
||||||
|
// No record and no claim. This still adopts — see below — but the caller can tell it apart, which is
|
||||||
|
// what keeps the durable cut-off row off a session nobody has identified.
|
||||||
|
expect(decideResume(undefined, undefined)).toEqual({ kind: 'assume', model: 'claude-code' });
|
||||||
|
expect(decideResume(undefined, '')).toEqual({ kind: 'assume', model: 'claude-code' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always yields a model to adopt with, because an unbound socket misses the whole turn', () => {
|
||||||
|
// Regression guard. Refusing to adopt when the harness was unknown looked principled and broke the
|
||||||
|
// app inside an hour: `useChat.ts` only sends `model` `if (modelRef.current)`, so a reconnect
|
||||||
|
// without one is routine. The socket never re-bound, the live turn's output went nowhere, and the
|
||||||
|
// transcript collapsed to "turn completed without output" until a refresh rebuilt it from the log.
|
||||||
|
for (const d of [
|
||||||
|
decideResume('claude-code', undefined),
|
||||||
|
decideResume(undefined, 'opencode/x'),
|
||||||
|
decideResume(undefined, undefined),
|
||||||
|
]) {
|
||||||
|
expect(d.model).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,24 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { ClientMessage, ServerMessage, Message, PromptImage, TurnMessage, UserSession } from './types';
|
import type {
|
||||||
|
ClientMessage,
|
||||||
|
ServerMessage,
|
||||||
|
Message,
|
||||||
|
PromptImage,
|
||||||
|
RunningTask,
|
||||||
|
TurnMessage,
|
||||||
|
UserSession,
|
||||||
|
} from './types';
|
||||||
import { sessionManager } from './session-manager';
|
import { sessionManager } from './session-manager';
|
||||||
|
import { rememberOpenCodePrompt } from './opencode/state';
|
||||||
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||||
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
|
||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
||||||
import { getUserSettings, getEmailAccounts, getChatEventsSince, getLastChatEventSeq, appendChatEvent } from 'officerdb';
|
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
|
||||||
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
|
import { claudeLoginState } from '@@/os-user-claude';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
@@ -19,6 +29,40 @@ const DEFAULT_MODEL = 'claude-code';
|
|||||||
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
|
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
|
||||||
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
|
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whose identity a turn runs as.
|
||||||
|
*
|
||||||
|
* A three-way answer rather than a nullable one, because `undefined` downstream means **the owner** — their
|
||||||
|
* binary, their `~/.claude` credential, their HOME, and their MCP config carrying `OFFICER_AUTH_TOKEN`. A
|
||||||
|
* nullable return collapsed three inputs into that: the caller genuinely being the owner, `resolveHomeDir`
|
||||||
|
* failing, and a member whose `osUser` is null. The last two mean "I could not determine whose this is", and
|
||||||
|
* answering them with the owner's identity is the one wrong answer this whole feature exists to prevent.
|
||||||
|
*
|
||||||
|
* Case three is not hypothetical: `provisionOsAccount` is non-fatal at every stage and records the account
|
||||||
|
* either way, so a member whose Linux provisioning failed exists as a row with no `osUser`. On the night this
|
||||||
|
* was written, provisioning failed three separate ways on a real member while the account continued to exist.
|
||||||
|
*
|
||||||
|
* The property: the owner's identity is reachable only by positively establishing that the caller IS the
|
||||||
|
* owner, never by failing to establish anything else. `resolveHomeDir` already reports `isOwner` as a positive
|
||||||
|
* fact — the old funnel through `undefined` was the only thing throwing it away.
|
||||||
|
*/
|
||||||
|
type TurnIdentity =
|
||||||
|
| { kind: 'owner' }
|
||||||
|
| { kind: 'member'; run: { osUser: string; home: string } }
|
||||||
|
| { kind: 'refuse'; reason: string };
|
||||||
|
|
||||||
|
async function resolveTurnIdentity(userId: number): Promise<TurnIdentity> {
|
||||||
|
const resolved = await resolveHomeDir(userId);
|
||||||
|
if (!resolved.ok) return { kind: 'refuse', reason: resolved.reason };
|
||||||
|
if (resolved.isOwner) return { kind: 'owner' };
|
||||||
|
|
||||||
|
const row = await getUserById(userId);
|
||||||
|
if (!row?.osUser) {
|
||||||
|
return { kind: 'refuse', reason: 'your Linux account is not provisioned yet, so an agent cannot run as you' };
|
||||||
|
}
|
||||||
|
return { kind: 'member', run: { osUser: row.osUser, home: resolved.home } };
|
||||||
|
}
|
||||||
|
|
||||||
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
async function getUserDefaultModel(userId: number): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const settings = await getUserSettings(userId);
|
const settings = await getUserSettings(userId);
|
||||||
@@ -39,16 +83,33 @@ type WSData = {
|
|||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
const resolveCwd = (email: string, cwd?: string) => {
|
/**
|
||||||
const root = getOwnerHomeDir(email);
|
* Where a turn runs, relative to the caller's own home.
|
||||||
if (!cwd || cwd === '~') return root;
|
*
|
||||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set — so every
|
||||||
// The server owner is the only account — absolute paths are theirs to use.
|
* `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is
|
||||||
|
* the only account" as though that were a property rather than an assumption.
|
||||||
|
*
|
||||||
|
* An absolute path is still passed through unchanged. That is not a hole: a member's turn runs as their Linux
|
||||||
|
* account, so the kernel decides what it can open, and containment is `resolveUserPath`'s job in the file
|
||||||
|
* browser rather than a string check here. But it is worth knowing it is the kernel doing the work.
|
||||||
|
*/
|
||||||
|
const resolveCwd = (home: string, cwd?: string) => {
|
||||||
|
if (!cwd || cwd === '~') return home;
|
||||||
|
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||||
if (cwd.startsWith('/')) return cwd;
|
if (cwd.startsWith('/')) return cwd;
|
||||||
return join(root, cwd);
|
return join(home, cwd);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd);
|
/**
|
||||||
|
* @param home an absolute filesystem path — NOT an email.
|
||||||
|
*
|
||||||
|
* It took an email until `95951fb`, resolved its own root, and both parameters are `string`, so the change of
|
||||||
|
* meaning was invisible to the compiler and to every caller outside that diff. Three of them kept passing an
|
||||||
|
* email and silently began building relative paths out of an address. If a fourth caller ever appears, this
|
||||||
|
* line is the warning it gets.
|
||||||
|
*/
|
||||||
|
export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, cwd);
|
||||||
|
|
||||||
// The email chat runs from the selected account's storage dir:
|
// The email chat runs from the selected account's storage dir:
|
||||||
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
||||||
@@ -70,15 +131,31 @@ async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
||||||
// pwd or the default general_chat_sessions dir; everything else (browser/project/dashboard) → the given cwd.
|
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
|
||||||
async function resolveChatCwd(
|
async function resolveChatCwd(
|
||||||
msg: { context?: string; contextId?: string; cwd?: string },
|
msg: { context?: string; contextId?: string; cwd?: string },
|
||||||
email: string,
|
email: string,
|
||||||
userId: number,
|
userId: number,
|
||||||
|
home: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
||||||
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
if (msg.context === 'chat') {
|
||||||
return resolveCwd(email, msg.cwd);
|
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
|
||||||
|
// The caller's own home, for everyone.
|
||||||
|
//
|
||||||
|
// This used to be `DATA_PATH/<email>/general_chat_sessions`, a dedicated directory so /chat sessions
|
||||||
|
// formed their own Claude project group and did not clutter the home. That is a sibling of the home,
|
||||||
|
// and `confineUserTree` makes every sibling the platform's at 0700 because the others are `attachments`
|
||||||
|
// and `email_accounts` — so it was unreachable for a member. The first live member turn ran there and
|
||||||
|
// every Bash call failed on its own working directory before doing anything.
|
||||||
|
//
|
||||||
|
// A per-member copy inside each home would have worked and would have left two rules to remember. The
|
||||||
|
// owner chose one: a chat with no chosen directory runs in the account's own home, whoever they are.
|
||||||
|
// The cost is that /chat sessions now share a project group with anything else run from that home,
|
||||||
|
// which was the reason the dedicated directory existed and is a trade the owner made knowingly.
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
return resolveCwd(home, msg.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsToSessionMap = new WeakMap<any, string>();
|
const wsToSessionMap = new WeakMap<any, string>();
|
||||||
@@ -149,8 +226,10 @@ export function close(ws: ServerWebSocket<WSData>): void {
|
|||||||
|
|
||||||
const sessionId = wsToSessionMap.get(ws);
|
const sessionId = wsToSessionMap.get(ws);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
sessionManager.detachWs(sessionId);
|
sessionManager.detachWs(sessionId, ws);
|
||||||
sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
// Only once nothing is watching. Another pane or another device still attached means the
|
||||||
|
// conversation is live, and arming the idle GC here would collect it out from under them.
|
||||||
|
if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +314,7 @@ function createMessageHandler(sessionId: string, model: string) {
|
|||||||
const session = sessionManager.getSession(sessionId);
|
const session = sessionManager.getSession(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
foldIntoSession(session, msg, model);
|
foldIntoSession(session, msg, model);
|
||||||
sendToClient(session.ws as ServerWebSocket<WSData> | null, msg, seq);
|
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +375,41 @@ async function handleClaudeCodeChat(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, username, userId } = ws.data;
|
const { email, username, userId } = ws.data;
|
||||||
|
|
||||||
const cwd = await resolveChatCwd(msg, email, userId);
|
// Identity first: it decides both whose home `~` expands against and whose account the turn runs as, and
|
||||||
|
// those must be the same answer. Resolving the cwd first would expand `~` before knowing whose it was.
|
||||||
|
const identity = await resolveTurnIdentity(userId);
|
||||||
|
if (identity.kind === 'refuse') {
|
||||||
|
sendToClient(ws, { type: 'error', message: identity.reason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member has to sign `claude` in themselves, once, with their own Anthropic account — the platform cannot
|
||||||
|
// do it for them without lending them the owner's credential, which is the thing this whole feature exists
|
||||||
|
// to avoid. Without this check their turn spawns, `claude` exits on an auth error, and it surfaces as "the
|
||||||
|
// agent is broken" — the exact confusion `/agent-status` was built to prevent, arriving through a different
|
||||||
|
// door. The refusal carries the instruction so the answer is the same whether the UI asked or not.
|
||||||
|
if (identity.kind === 'member') {
|
||||||
|
const state = await claudeLoginState({ email, osUser: identity.run.osUser });
|
||||||
|
if (!state.installed) {
|
||||||
|
sendToClient(ws, {
|
||||||
|
type: 'error',
|
||||||
|
message: 'Claude is not installed in your home yet — ask the server owner to reprovision your account.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.loggedIn) {
|
||||||
|
sendToClient(ws, {
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email);
|
||||||
|
|
||||||
|
const cwd = await resolveChatCwd(msg, email, userId, home);
|
||||||
|
|
||||||
const groupSlug = msg.groupSlug || null;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
@@ -336,7 +449,9 @@ async function handleClaudeCodeChat(
|
|||||||
try {
|
try {
|
||||||
if (!session._claudeKill) {
|
if (!session._claudeKill) {
|
||||||
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
// First turn of this session: open the persistent session + a SESSION-scoped event subscription
|
||||||
// (survives turn-end so background task:notifications keep flowing). handle.kill tears both down.
|
// (survives turn-end so background task:notifications keep flowing). `kill` tears both down for an
|
||||||
|
// explicit disconnect; `detach` drops only the listener, which is what the idle GC uses so an
|
||||||
|
// absent browser stops taking a live agent with it.
|
||||||
const handle = await sendClaudeCodeStreaming({
|
const handle = await sendClaudeCodeStreaming({
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
@@ -346,11 +461,13 @@ async function handleClaudeCodeChat(
|
|||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
|
member: identity.kind === 'member' ? identity.run : undefined,
|
||||||
resumeSessionId: msg.resumeSessionId,
|
resumeSessionId: msg.resumeSessionId,
|
||||||
onMessage,
|
onMessage,
|
||||||
});
|
});
|
||||||
session.piProcess = sessionId as any;
|
session.piProcess = sessionId as any;
|
||||||
session._claudeKill = handle.kill;
|
session._claudeKill = handle.kill;
|
||||||
|
session._sidecarUnsub = handle.detach;
|
||||||
} else {
|
} else {
|
||||||
// Session already live: push this turn onto the existing persistent session (no new subscription).
|
// Session already live: push this turn onto the existing persistent session (no new subscription).
|
||||||
await sidecar.spawnClaudeStreaming({
|
await sidecar.spawnClaudeStreaming({
|
||||||
@@ -385,12 +502,21 @@ async function handleOpenCodeChat(
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
cwdRoot?: string;
|
cwdRoot?: string;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
// The whole of B4 lived in this omission. The browser sent images, the bubble rendered them, and
|
||||||
|
// they stopped at this signature — so they were never passed on and never reached the model, with
|
||||||
|
// nothing anywhere reporting a loss.
|
||||||
|
images?: PromptImage[];
|
||||||
},
|
},
|
||||||
effectivePrompt: string,
|
effectivePrompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, username, userId } = ws.data;
|
const { email, username, userId } = ws.data;
|
||||||
|
|
||||||
const cwd = await resolveChatCwd(msg, email, userId);
|
// The owner's home: opencode receives no identity at all (`TODO.md` → Multi-user), so this path is
|
||||||
|
// owner-only and resolving anything else here would imply an isolation it does not have.
|
||||||
|
const cwd = await resolveChatCwd(msg, email, userId, getOwnerHomeDir(email));
|
||||||
|
|
||||||
|
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
|
||||||
|
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
|
||||||
|
|
||||||
const groupSlug = msg.groupSlug || null;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
@@ -427,6 +553,16 @@ async function handleOpenCodeChat(
|
|||||||
const onMessage = createMessageHandler(sessionId, model);
|
const onMessage = createMessageHandler(sessionId, model);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Drop the PREVIOUS turn's listener before opening the next one.
|
||||||
|
//
|
||||||
|
// This deliberately does not mirror the Claude guard above. Claude keeps one persistent session and
|
||||||
|
// skips re-subscribing; OpenCode runs a fresh `opencode run` subprocess per turn, so a new
|
||||||
|
// subscription each time is correct. What was wrong is that the old handle was overwritten without
|
||||||
|
// being detached, leaving the previous session-scoped listener attached — so every turn after the
|
||||||
|
// first delivered doubled, tripled, and so on, for any termination that is not result/error/stopped.
|
||||||
|
session._sidecarUnsub?.();
|
||||||
|
session._sidecarUnsub = undefined;
|
||||||
|
|
||||||
const handle = await sendOpenCodeStreaming({
|
const handle = await sendOpenCodeStreaming({
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
@@ -436,12 +572,14 @@ async function handleOpenCodeChat(
|
|||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
resumeSessionId: msg.resumeSessionId,
|
resumeSessionId: msg.resumeSessionId,
|
||||||
|
images: msg.images,
|
||||||
onMessage,
|
onMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle).
|
// Store the abort handle so handleStop can end the turn (OpenCode is aborted via this handle).
|
||||||
session.piProcess = sessionId as any;
|
session.piProcess = sessionId as any;
|
||||||
session._claudeKill = handle.kill;
|
session._claudeKill = handle.kill;
|
||||||
|
session._sidecarUnsub = handle.detach;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to start OpenCode streaming', { sessionId, error: String(err) });
|
logger.error('Failed to start OpenCode streaming', { sessionId, error: String(err) });
|
||||||
sendToClient(ws, { type: 'error', message: 'Failed to start OpenCode' });
|
sendToClient(ws, { type: 'error', message: 'Failed to start OpenCode' });
|
||||||
@@ -504,7 +642,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
|||||||
if (isClaudeModel(session.model)) {
|
if (isClaudeModel(session.model)) {
|
||||||
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
|
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
|
||||||
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
|
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
|
||||||
void sidecar.interruptClaude(sessionId);
|
void sidecar.interruptClaude(sessionId, ws.data.userId);
|
||||||
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
|
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
|
||||||
} else {
|
} else {
|
||||||
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
|
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
|
||||||
@@ -571,10 +709,14 @@ function adoptOrphanedSession(ws: ServerWebSocket<WSData>, sessionId: string, mo
|
|||||||
// Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of
|
// Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of
|
||||||
// this session" and opens a *second* subscription, which would then deliver every message twice.
|
// this session" and opens a *second* subscription, which would then deliver every message twice.
|
||||||
session._claudeKill = () => {
|
session._claudeKill = () => {
|
||||||
if (isClaudeModel(model)) sidecar.killClaude(sessionId);
|
if (isClaudeModel(model)) sidecar.killClaude(sessionId, userId);
|
||||||
else sidecar.killOpenCode(sessionId);
|
else sidecar.killOpenCode(sessionId);
|
||||||
unsub();
|
unsub();
|
||||||
};
|
};
|
||||||
|
// An adopted session can idle out and be released like any other, and releasing detaches through this
|
||||||
|
// field alone. Leaving it unset would drop the record while the listener stayed subscribed — a leak
|
||||||
|
// that grows by one every time a browser adopts a session and then goes away.
|
||||||
|
session._sidecarUnsub = unsub;
|
||||||
|
|
||||||
logger.info('Adopted orphaned chat session after restart', { sessionId, model });
|
logger.info('Adopted orphaned chat session after restart', { sessionId, model });
|
||||||
return session;
|
return session;
|
||||||
@@ -588,10 +730,19 @@ async function handleResumeCursor(
|
|||||||
msg: { sessionId: string; cursor: number; model?: string; cwd?: string; generating?: boolean },
|
msg: { sessionId: string; cursor: number; model?: string; cwd?: string; generating?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { sessionId, cursor } = msg;
|
const { sessionId, cursor } = msg;
|
||||||
const model = msg.model || DEFAULT_MODEL;
|
|
||||||
if (!sessionManager.getSession(sessionId)) {
|
const known = sessionManager.getSession(sessionId);
|
||||||
adoptOrphanedSession(ws, sessionId, model, msg.cwd ?? '');
|
const decision = decideResume(known?.model, msg.model);
|
||||||
|
|
||||||
|
if (decision.kind !== 'known') {
|
||||||
|
// Adopt on an assumption too: an unbound socket misses the turn entirely, which is worse than a
|
||||||
|
// harness guess that only ever costs us the cut-off check below.
|
||||||
|
adoptOrphanedSession(ws, sessionId, decision.model, msg.cwd ?? '');
|
||||||
|
if (decision.kind === 'assume') {
|
||||||
|
logger.warn('resume-cursor for an unknown session with no model; adopting on the default', { sessionId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionManager.attachWs(sessionId, ws);
|
sessionManager.attachWs(sessionId, ws);
|
||||||
wsToSessionMap.set(ws as any, sessionId);
|
wsToSessionMap.set(ws as any, sessionId);
|
||||||
try {
|
try {
|
||||||
@@ -602,7 +753,78 @@ async function handleResumeCursor(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) });
|
logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) });
|
||||||
}
|
}
|
||||||
if (msg.generating) await endTurnIfAgentIsGone(ws, sessionId, model);
|
// Only ask when the harness is actually known — never on an assumption. This check writes a DURABLE
|
||||||
|
// row, so a wrong answer is permanent: that was B7, where a defaulted `claude-code` made officer ask
|
||||||
|
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
|
||||||
|
// away" into a turn that was running perfectly well.
|
||||||
|
if (msg.generating && decision.kind !== 'assume') {
|
||||||
|
await endTurnIfAgentIsGone([ws], sessionId, decision.model, ws.data.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResumeDecision =
|
||||||
|
| { kind: 'known'; model: string }
|
||||||
|
| { kind: 'adopt'; model: string }
|
||||||
|
| { kind: 'assume'; model: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which harness a resuming session belongs to, and what that permits.
|
||||||
|
*
|
||||||
|
* This used to be `msg.model || DEFAULT_MODEL`, and `DEFAULT_MODEL` is `claude-code`. So a resume-cursor
|
||||||
|
* that omitted `model` declared every session — OpenCode ones included — to be Claude, with three
|
||||||
|
* consequences that all read as something else:
|
||||||
|
*
|
||||||
|
* - **Adopted into the wrong harness.** `adoptOrphanedSession` subscribes to the sidecar bus for that
|
||||||
|
* model and pins `session.model` for the rest of its life, so an OpenCode turn's output never
|
||||||
|
* arrived, and stopping it called `killClaude` on a key that sidecar had never held — a dead stop
|
||||||
|
* button, silently.
|
||||||
|
* - **A durable false `cut-off`.** `endTurnIfAgentIsGone` asked the Claude sidecar whether it was
|
||||||
|
* generating, was told `false` because it had never heard of the session, and wrote "the agent went
|
||||||
|
* away" into a turn that was running normally. It survives reload, which is the whole point of
|
||||||
|
* writing it durably, and is therefore unrecoverable from the UI.
|
||||||
|
* - Masked, never fixed, by the client always happening to send `model` next to `sessionId`.
|
||||||
|
*
|
||||||
|
* Two rules. **The server's own record beats the client's claim** — a session in memory already knows its
|
||||||
|
* harness, and letting a socket re-declare it is how the wrong sidecar gets a session in the first place.
|
||||||
|
* **A guess is still a guess**: when neither knows, the session is adopted on the default so delivery
|
||||||
|
* keeps working, but it is marked `assume` and nothing durable may be written from it.
|
||||||
|
*
|
||||||
|
* The middle position — refusing to adopt at all when the harness is unknown — was tried and was WRONG,
|
||||||
|
* visibly so within the hour. `useChat.ts` sends `model` only `if (modelRef.current)`, so a reconnect
|
||||||
|
* without one is ordinary, not exotic; declining to adopt left the socket unbound to a live turn, and
|
||||||
|
* the running turn's output went nowhere. On screen: the transcript collapsed to "turn completed
|
||||||
|
* without output" and only a refresh — which rebuilds from the durable log — brought it back.
|
||||||
|
*
|
||||||
|
* So adoption is about DELIVERY and must be generous. Only the durable write needs certainty.
|
||||||
|
*/
|
||||||
|
export function decideResume(knownModel: string | undefined, claimedModel: string | undefined): ResumeDecision {
|
||||||
|
if (knownModel) return { kind: 'known', model: knownModel };
|
||||||
|
if (claimedModel) return { kind: 'adopt', model: claimedModel };
|
||||||
|
return { kind: 'assume', model: DEFAULT_MODEL };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which background tasks are still outstanding, by replaying the durable log against itself.
|
||||||
|
*
|
||||||
|
* A task's whole life is two events — `task:started` and, eventually, `task:notification` with a terminal
|
||||||
|
* status — so started-minus-notified is the answer, and a Map keyed by taskId keeps the last word on each.
|
||||||
|
* There is no third event: a task the agent abandoned without notifying stays here until the log is pruned,
|
||||||
|
* which is the honest reading of the record rather than a bug to paper over.
|
||||||
|
*/
|
||||||
|
function collectRunningTasks(events: ServerMessage[]): RunningTask[] {
|
||||||
|
const running = new Map<string, RunningTask>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.type === 'task:started') {
|
||||||
|
running.set(event.taskId, {
|
||||||
|
taskId: event.taskId,
|
||||||
|
description: event.description,
|
||||||
|
taskType: event.taskType,
|
||||||
|
});
|
||||||
|
} else if (event.type === 'task:notification') {
|
||||||
|
running.delete(event.taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...running.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -621,7 +843,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
|||||||
const { claudeSessionId } = msg;
|
const { claudeSessionId } = msg;
|
||||||
if (!claudeSessionId) return;
|
if (!claudeSessionId) return;
|
||||||
|
|
||||||
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId);
|
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId, ws.data.userId);
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
// No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation
|
// No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation
|
||||||
// opened from history lands here every time. Stay silent and leave the socket as it was — the next
|
// opened from history lands here every time. Stay silent and leave the socket as it was — the next
|
||||||
@@ -633,6 +855,10 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
|||||||
// An officer restart takes the in-memory session with it while the agent carries on, so the key can
|
// An officer restart takes the in-memory session with it while the agent carries on, so the key can
|
||||||
// resolve to a session this process has never heard of. Adopting re-subscribes it to the sidecar's bus,
|
// resolve to a session this process has never heard of. Adopting re-subscribes it to the sidecar's bus,
|
||||||
// which is what makes the rest of the turn arrive.
|
// which is what makes the rest of the turn arrive.
|
||||||
|
// `DEFAULT_MODEL` is right here and wrong in resume-cursor, which is worth being explicit about since
|
||||||
|
// it was just removed there: this path reached `sessionId` by asking the CLAUDE sidecar to resolve a
|
||||||
|
// `claudeSessionId`, so the harness is not a guess — only Claude could have answered. Attach is
|
||||||
|
// Claude-only by construction. If an OpenCode reattach verb ever lands, this stops being safe.
|
||||||
const existing = sessionManager.getSession(sessionId);
|
const existing = sessionManager.getSession(sessionId);
|
||||||
const session = existing ?? adoptOrphanedSession(ws, sessionId, DEFAULT_MODEL, '');
|
const session = existing ?? adoptOrphanedSession(ws, sessionId, DEFAULT_MODEL, '');
|
||||||
|
|
||||||
@@ -653,14 +879,20 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
|||||||
// `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For
|
// `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For
|
||||||
// an adopted session it is a fresh record's default, so ask the agent — the same question, and for the
|
// an adopted session it is a fresh record's default, so ask the agent — the same question, and for the
|
||||||
// same reason, as `endTurnIfAgentIsGone`.
|
// same reason, as `endTurnIfAgentIsGone`.
|
||||||
const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId);
|
const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId, ws.data.userId);
|
||||||
session.isGenerating = isGenerating;
|
session.isGenerating = isGenerating;
|
||||||
|
|
||||||
|
// One read serves both answers: the head of the log is the cursor, and folding the whole log gives the
|
||||||
|
// tasks still outstanding. Reading it all is affordable because attach happens once per socket and this
|
||||||
|
// server has one user; a `getLastChatEventSeq` would only have saved a second round trip.
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
let runningTasks: RunningTask[] = [];
|
||||||
try {
|
try {
|
||||||
cursor = (await getLastChatEventSeq(sessionId)) ?? 0;
|
const events = await getChatEventsSince(sessionId, 0);
|
||||||
|
cursor = events.at(-1)?.id ?? 0;
|
||||||
|
runningTasks = collectRunningTasks(events.map((e) => e.event as ServerMessage));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to read chat event head on attach', { sessionId, error: String(err) });
|
logger.error('Failed to read the durable log on attach', { sessionId, error: String(err) });
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToClient(ws, {
|
sendToClient(ws, {
|
||||||
@@ -668,13 +900,19 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
|||||||
sessionId,
|
sessionId,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
cursor,
|
cursor,
|
||||||
|
runningTasks,
|
||||||
// Whatever the agent had typed but not yet finished as a message. The transcript on disk cannot
|
// Whatever the agent had typed but not yet finished as a message. The transcript on disk cannot
|
||||||
// supply it — the harness writes an assistant message only once it is complete — so this is the one
|
// supply it — the harness writes an assistant message only once it is complete — so this is the one
|
||||||
// piece of the turn a refresh would otherwise genuinely lose.
|
// piece of the turn a refresh would otherwise genuinely lose.
|
||||||
streamingText: session.streamBuffer,
|
streamingText: session.streamBuffer,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Attached socket to live session by transcript id', { sessionId, claudeSessionId, isGenerating });
|
logger.info('Attached socket to live session by transcript id', {
|
||||||
|
sessionId,
|
||||||
|
claudeSessionId,
|
||||||
|
isGenerating,
|
||||||
|
recoveredTasks: runningTasks.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -690,12 +928,13 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
|
|||||||
* so its sessions are left alone rather than guessed at.
|
* so its sessions are left alone rather than guessed at.
|
||||||
*/
|
*/
|
||||||
async function endTurnIfAgentIsGone(
|
async function endTurnIfAgentIsGone(
|
||||||
ws: ServerWebSocket<WSData> | null,
|
targets: Iterable<ServerWebSocket<WSData> | null>,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
model: string,
|
model: string,
|
||||||
|
userId: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isClaudeModel(model)) return;
|
if (!isClaudeModel(model)) return;
|
||||||
if (await sidecar.isClaudeGenerating(sessionId)) return;
|
if (await sidecar.isClaudeGenerating(sessionId, userId)) return;
|
||||||
|
|
||||||
const session = sessionManager.getSession(sessionId);
|
const session = sessionManager.getSession(sessionId);
|
||||||
if (session) session.isGenerating = false;
|
if (session) session.isGenerating = false;
|
||||||
@@ -703,11 +942,11 @@ async function endTurnIfAgentIsGone(
|
|||||||
const event: ServerMessage = { type: 'cut-off' };
|
const event: ServerMessage = { type: 'cut-off' };
|
||||||
try {
|
try {
|
||||||
const seq = await appendChatEvent(sessionId, event);
|
const seq = await appendChatEvent(sessionId, event);
|
||||||
sendToClient(ws, event, seq);
|
for (const target of targets) sendToClient(target, event, seq);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Still tell this client — an un-replayable explanation beats a spinner that never stops.
|
// Still tell every client — an un-replayable explanation beats a spinner that never stops.
|
||||||
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) });
|
||||||
sendToClient(ws, event);
|
for (const target of targets) sendToClient(target, event);
|
||||||
}
|
}
|
||||||
logger.info('Ended a turn whose agent had gone', { sessionId });
|
logger.info('Ended a turn whose agent had gone', { sessionId });
|
||||||
}
|
}
|
||||||
@@ -719,7 +958,17 @@ async function endTurnIfAgentIsGone(
|
|||||||
sidecar.onClaudeSidecarStarted(() => {
|
sidecar.onClaudeSidecarStarted(() => {
|
||||||
for (const session of sessionManager.getAllSessions()) {
|
for (const session of sessionManager.getAllSessions()) {
|
||||||
if (!session.isGenerating) continue;
|
if (!session.isGenerating) continue;
|
||||||
void endTurnIfAgentIsGone(session.ws as ServerWebSocket<WSData> | null, session.sessionId, session.model);
|
// No owner recorded, no question asked. `isClaudeGenerating` is now scoped to a caller, and there is no
|
||||||
|
// safe id to substitute — asking as the owner would let a member's orphaned session be answered with the
|
||||||
|
// owner's authority, and asking as nobody is not a thing. Leaving it marked generating is the same
|
||||||
|
// outcome as before this loop existed, and it self-corrects on the next reconnect.
|
||||||
|
if (session.userId === undefined) continue;
|
||||||
|
void endTurnIfAgentIsGone(
|
||||||
|
session.sockets as Set<ServerWebSocket<WSData>>,
|
||||||
|
session.sessionId,
|
||||||
|
session.model,
|
||||||
|
session.userId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
|
|||||||
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||||
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||||
@@ -18,7 +19,6 @@ async function getUserTtsVoice(userId: number): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'];
|
|
||||||
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
||||||
|
|
||||||
async function cleanOldCacheDirs(userDataDir: string) {
|
async function cleanOldCacheDirs(userDataDir: string) {
|
||||||
@@ -28,23 +28,51 @@ async function cleanOldCacheDirs(userDataDir: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedHomeDir(homeDir: string) {
|
// `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of
|
||||||
for (const dir of DEFAULT_HOME_DIRS) {
|
// any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING
|
||||||
const target = join(homeDir, dir);
|
// at it, which is not a listing's business and not a layout the platform has any standing to choose.
|
||||||
if (!existsSync(target)) await mkdir(target, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const router = createRouter();
|
export const router = createRouter();
|
||||||
|
|
||||||
type UserCtx = { email: string };
|
/**
|
||||||
|
* Resolve whose home this request may touch, once, before any handler runs.
|
||||||
|
*
|
||||||
|
* A middleware rather than a change to `getRootDir`'s signature because that function is called from
|
||||||
|
* fifteen places in this file. Making it async would have meant editing fifteen call sites, and the
|
||||||
|
* failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a
|
||||||
|
* member. Resolving here means a handler cannot run without the answer.
|
||||||
|
*
|
||||||
|
* The `user-data` root is untouched by this — it is already keyed on the caller's own email and holds
|
||||||
|
* platform-written data rather than anything executable.
|
||||||
|
*/
|
||||||
|
router.use(async (ctx, next) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const resolved = await resolveHomeDir(user.id as number);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
throw resolved.needsOsAccount
|
||||||
|
? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`)
|
||||||
|
: errors.FORBIDDEN(resolved.reason);
|
||||||
|
}
|
||||||
|
ctx.set('user', { ...user, homeDir: resolved.home });
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of
|
||||||
|
* `getRootDir` keep working unchanged and none of them can forget to resolve it.
|
||||||
|
*/
|
||||||
|
type UserCtx = { email: string; homeDir?: string };
|
||||||
|
|
||||||
function getUserDataDir(email: string): string {
|
function getUserDataDir(email: string): string {
|
||||||
return join(DATA_PATH, email);
|
return join(DATA_PATH, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRootDir(user: UserCtx, root?: string): string {
|
export function getRootDir(user: UserCtx, root?: string): string {
|
||||||
if (!root || root === 'home') return getOwnerHomeDir(user.email);
|
// `user.homeDir` is set for every request that reached a handler — the middleware refuses the request
|
||||||
|
// otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand;
|
||||||
|
// it is NOT a "member without an OS account gets the owner's home" path, because such a request never
|
||||||
|
// gets this far. See user-home.ts for why that distinction is the whole point.
|
||||||
|
if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email);
|
||||||
if (root === 'user-data') return getUserDataDir(user.email);
|
if (root === 'user-data') return getUserDataDir(user.email);
|
||||||
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
|
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
|
||||||
}
|
}
|
||||||
@@ -140,10 +168,17 @@ router.get('/ls', async (ctx) => {
|
|||||||
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
|
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
|
||||||
const absPath = resolveUserPath(rootDir, relPath);
|
const absPath = resolveUserPath(rootDir, relPath);
|
||||||
|
|
||||||
// Auto-create dir if missing (only for user home root)
|
// Create the home root itself if it is missing, and nothing else. A listing that invents its own contents
|
||||||
|
// is a listing you cannot trust — the folder set it used to seed is gone.
|
||||||
|
//
|
||||||
|
// Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of
|
||||||
|
// whether the directory can be used.
|
||||||
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
|
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
|
||||||
await seedHomeDir(rootDir);
|
try {
|
||||||
await mkdir(absPath, { recursive: true });
|
await mkdir(absPath, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Either it exists, or it is not ours to create.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old top-level cache dirs (migrated to cache/ prefix)
|
// Remove old top-level cache dirs (migrated to cache/ prefix)
|
||||||
@@ -154,7 +189,19 @@ router.get('/ls', async (ctx) => {
|
|||||||
let names: string[];
|
let names: string[];
|
||||||
try {
|
try {
|
||||||
names = await readdir(absPath);
|
names = await readdir(absPath);
|
||||||
} catch {
|
} catch (ex) {
|
||||||
|
// A missing directory resets the browser to the root, which is the right answer for a stale path.
|
||||||
|
//
|
||||||
|
// A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and
|
||||||
|
// theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this
|
||||||
|
// returned an empty listing — the UI said "This folder is empty" over five directories that existed.
|
||||||
|
// An empty result is data; it should never be how a refusal looks.
|
||||||
|
if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') {
|
||||||
|
throw errors.FORBIDDEN(
|
||||||
|
`Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` +
|
||||||
|
`lists are missing — reprovision the Linux account from Settings → User management.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return ctx.json({ path: '/', entries: [], reset: true });
|
return ctx.json({ path: '/', entries: [], reset: true });
|
||||||
}
|
}
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ const proxy = createSidecarProxy({
|
|||||||
// Originals and `download/archive` zips are large and Immich builds the archive as it streams it, so the
|
// Originals and `download/archive` zips are large and Immich builds the archive as it streams it, so the
|
||||||
// socket can sit quiet longer than the default 60s idle drop allows.
|
// socket can sit quiet longer than the default 60s idle drop allows.
|
||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
|
// Immich judges an asset from its first few KB and rejects immediately, while we are still writing —
|
||||||
|
// and the response is lost in the teardown, which is what made every large upload fail with an
|
||||||
|
// unexplained empty 400. Buffering here removes the overlap. The sidecar does the same on its own hop
|
||||||
|
// to Immich (sidecar/photos/routes.ts → readBodyCapped), and both are needed: fixing one alone still
|
||||||
|
// lost the answer roughly two runs in three at 32 MB.
|
||||||
|
bufferRequestBody: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const photosRouter = proxy.router;
|
export const photosRouter = proxy.router;
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { createRouter } from '../../create-router';
|
|
||||||
import { readdir } from 'node:fs/promises';
|
|
||||||
import { basename, join } from 'node:path';
|
|
||||||
|
|
||||||
const plansDir = join(process.cwd(), 'plans');
|
|
||||||
|
|
||||||
export const plansRouter = createRouter();
|
|
||||||
|
|
||||||
plansRouter.get('/', async (ctx) => {
|
|
||||||
try {
|
|
||||||
const files = await readdir(plansDir);
|
|
||||||
const plans = files.filter((f) => f.endsWith('.md')).map((f) => f.replace('.md', ''));
|
|
||||||
return ctx.json(plans);
|
|
||||||
} catch {
|
|
||||||
return ctx.json([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
plansRouter.get('/:name', async (ctx) => {
|
|
||||||
// A single path segment is not a single *name*: hono percent-decodes params, so `..%2F..%2Fsecret`
|
|
||||||
// arrives here as `../../secret` and `join` would happily walk out of plansDir. Verified against hono
|
|
||||||
// directly. Auth limits the blast radius to the owner's own token, and the `.md` suffix limits it to
|
|
||||||
// markdown, but "read any .md on the disk" is not what this endpoint is for.
|
|
||||||
const name = basename(ctx.req.param('name'));
|
|
||||||
const filePath = join(plansDir, `${name}.md`);
|
|
||||||
const file = Bun.file(filePath);
|
|
||||||
if (!(await file.exists())) return ctx.text('Not found', 404);
|
|
||||||
const text = await file.text();
|
|
||||||
return ctx.text(text);
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { getUserSettings } from 'officerdb';
|
import { getUserSettings } from 'officerdb';
|
||||||
import { getTaskByDirName } from './task-files';
|
import { getTaskByDirName } from './task-files';
|
||||||
import { getHomeDir } from '../../data-path';
|
import { getHomeDir, getOwnerHomeDir } from '../../data-path';
|
||||||
import { resolveBaseCwd } from '../chat/websocket';
|
import { resolveBaseCwd } from '../chat/websocket';
|
||||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||||
import type { TurnMessage, MessageCost } from '../chat/types';
|
import type { TurnMessage, MessageCost } from '../chat/types';
|
||||||
@@ -496,7 +496,7 @@ async function runForeach({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||||
const resolvedCwd = resolveBaseCwd(email, cwdRelative);
|
const resolvedCwd = resolveBaseCwd(getOwnerHomeDir(email), cwdRelative);
|
||||||
const targetDir = resolvedCwd;
|
const targetDir = resolvedCwd;
|
||||||
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
|
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
|
||||||
|
|
||||||
@@ -588,7 +588,7 @@ export async function executePipeline({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseCwd = resolveBaseCwd(email, cwd);
|
const baseCwd = resolveBaseCwd(getOwnerHomeDir(email), cwd);
|
||||||
let model = modelOverride || (await resolveModel(userId));
|
let model = modelOverride || (await resolveModel(userId));
|
||||||
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
|
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
|
||||||
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
|||||||
import type { UserRole } from 'officerdb';
|
import type { UserRole } from 'officerdb';
|
||||||
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
|
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
|
||||||
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
|
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
|
||||||
|
import { capabilityAvailability } from '../../app-store/availability';
|
||||||
|
|
||||||
// Two audiences, deliberately split.
|
// Two audiences, deliberately split.
|
||||||
//
|
//
|
||||||
@@ -28,6 +29,11 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
|||||||
const userId = ctx.get('user').id as number;
|
const userId = ctx.get('user').id as number;
|
||||||
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
||||||
|
|
||||||
|
// What EXISTS on this server, which is a different question from what this account may use. A
|
||||||
|
// capability the owner holds unconditionally still means nothing if its sidecar was never installed,
|
||||||
|
// and the owner is as subject to that as a member — see app-store/availability.ts.
|
||||||
|
const { unavailable, manifests } = await capabilityAvailability();
|
||||||
|
|
||||||
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to
|
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to
|
||||||
// remember to special-case. One shape for both audiences means one code path in the UI.
|
// remember to special-case. One shape for both audiences means one code path in the UI.
|
||||||
const held = isOwner
|
const held = isOwner
|
||||||
@@ -35,16 +41,34 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
|||||||
: [...grants].map(([key, level]) => ({ key, level }));
|
: [...grants].map(([key, level]) => ({ key, level }));
|
||||||
|
|
||||||
const heldKeys = new Set(held.map((h) => h.key));
|
const heldKeys = new Set(held.map((h) => h.key));
|
||||||
|
// Held AND present. Two subtractions rather than one because they mean different things to the UI: a
|
||||||
|
// capability withheld is "not yours", one whose sidecar is absent is "not here yet, install it".
|
||||||
|
const usable = held.filter(({ key }) => !unavailable.has(key));
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
isOwner,
|
isOwner,
|
||||||
capabilities: held,
|
capabilities: held,
|
||||||
|
/** Capabilities the account holds whose sidecar is not installed or is disabled. */
|
||||||
|
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
|
||||||
|
/**
|
||||||
|
* Dock tiles and routes belonging to installed sidecars the account may reach.
|
||||||
|
*
|
||||||
|
* Filtered by capability here rather than in the client: a member must not be handed the manifest
|
||||||
|
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
|
||||||
|
* privacy that lasts until someone opens the network tab.
|
||||||
|
*/
|
||||||
|
plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)),
|
||||||
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
|
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
|
||||||
routes: held.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
|
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
|
||||||
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
|
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
|
||||||
// this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in
|
// this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in
|
||||||
// screens — and a guard that cannot tell those apart either blanks the app or guards nothing.
|
// screens — and a guard that cannot tell those apart either blanks the app or guards nothing.
|
||||||
deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key)).flatMap((c) => c.routes ?? []),
|
// Routes of capabilities this account does not hold, PLUS those whose sidecar is not installed. The
|
||||||
|
// guard treats both the same — there is nothing to show — while `unavailable` above lets the UI
|
||||||
|
// explain the second case as something the owner can fix by installing it.
|
||||||
|
deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key) || unavailable.has(c.key)).flatMap(
|
||||||
|
(c) => c.routes ?? [],
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,17 +76,31 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
|||||||
export const capabilityAdminRouter = createRouter();
|
export const capabilityAdminRouter = createRouter();
|
||||||
|
|
||||||
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
||||||
|
// Only what this server can actually do RIGHT NOW.
|
||||||
|
//
|
||||||
|
// The same subtraction the dock already makes, applied to the granting UI — which was showing all
|
||||||
|
// fourteen app capabilities on a fresh install where none of their sidecars existed. Offering to grant
|
||||||
|
// Photos on a machine with no Immich is not a permission decision, it is a menu of things that would
|
||||||
|
// 403 for a different reason than the owner thinks.
|
||||||
|
//
|
||||||
|
// Fail open on a degraded read: `capabilityAvailability` returns an empty `unavailable` set when it
|
||||||
|
// cannot see install state, so the list falls back to everything rather than to nothing. An owner whose
|
||||||
|
// Permissions screen emptied itself because one query failed would reasonably conclude the feature broke.
|
||||||
|
const { unavailable } = await capabilityAvailability();
|
||||||
|
|
||||||
|
const describe = (c: (typeof GRANTABLE_CAPABILITIES)[number]) => ({
|
||||||
|
key: c.key,
|
||||||
|
label: c.label,
|
||||||
|
description: c.description,
|
||||||
|
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
||||||
|
routes: c.routes ?? [],
|
||||||
|
hasPersonalWrites: !!c.personal?.length,
|
||||||
|
});
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
// Only the grantable kind is offered. `execution` and `admin` are deliberately not in this list:
|
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
|
||||||
// a UI that shows a checkbox it will refuse to honour is worse than one that never offered it.
|
// shows a checkbox it will refuse to honour is worse than one that never offered it.
|
||||||
capabilities: GRANTABLE_CAPABILITIES.map((c) => ({
|
capabilities: GRANTABLE_CAPABILITIES.filter((c) => !unavailable.has(c.key)).map(describe),
|
||||||
key: c.key,
|
|
||||||
label: c.label,
|
|
||||||
description: c.description,
|
|
||||||
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
|
||||||
routes: c.routes ?? [],
|
|
||||||
hasPersonalWrites: !!c.personal?.length,
|
|
||||||
})),
|
|
||||||
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
|
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
|
||||||
// database refuses a row for that role.
|
// database refuses a row for that role.
|
||||||
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||||
@@ -90,7 +128,7 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
|
|||||||
// grant the resolver would drop on read anyway.
|
// grant the resolver would drop on read anyway.
|
||||||
const known = CAPABILITY_BY_KEY.get(capability);
|
const known = CAPABILITY_BY_KEY.get(capability);
|
||||||
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
|
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
|
||||||
if (known.kind !== 'app') {
|
if (known.kind !== 'app' && known.kind !== 'confined') {
|
||||||
throw errors.BAD_REQUEST(
|
throw errors.BAD_REQUEST(
|
||||||
known.kind === 'execution'
|
known.kind === 'execution'
|
||||||
? `${known.label} runs as the server owner and can never be granted`
|
? `${known.label} runs as the server owner and can never be granted`
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Handler } from 'hono';
|
||||||
|
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||||
|
import type { UserRole } from 'officerdb';
|
||||||
|
import argon2 from 'argon2';
|
||||||
|
import * as errors from '@@/custom-errors';
|
||||||
|
import { OS_USERS_ENABLED } from '@@/os-user';
|
||||||
|
import { validatePublicKey } from '@@/os-user-ssh';
|
||||||
|
import { provisionOsAccount } from './provision-os';
|
||||||
|
import { validatePassword } from '../auth/validate-password';
|
||||||
|
import { validateUsername } from '../auth/validate-username';
|
||||||
|
import { toPublicUser } from './manage-users';
|
||||||
|
|
||||||
|
// The owner creating a second account. Until this existed, `createUser` had exactly one call site —
|
||||||
|
// `auth/bootstrap.ts`, gated on an empty user table — so every non-owner account on any instance had
|
||||||
|
// been inserted into Postgres by hand.
|
||||||
|
//
|
||||||
|
// ── Why the owner sets the password ──
|
||||||
|
//
|
||||||
|
// The alternative is an invite: a token emailed to the person, who then sets their own. That is the
|
||||||
|
// better shape and it needs a mail path, a token table and an expiry policy. This is the honest
|
||||||
|
// intermediate: the owner types a password and tells the person, the same way they would hand over a
|
||||||
|
// wifi key. `passwordChangedAt` stays null, so nothing pretends the person chose it.
|
||||||
|
//
|
||||||
|
// ── Status is 'Active', deliberately ──
|
||||||
|
//
|
||||||
|
// The column defaults to 'Unverified' and `signin.ts` refuses anything that is not 'Active' with a bare
|
||||||
|
// UNAUTHORIZED. So an account created at the default would be indistinguishable from a wrong password,
|
||||||
|
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
||||||
|
// UI is verified by definition — the owner is the verification.
|
||||||
|
|
||||||
|
/** Roles this route may assign. Never 'Super Admin' — see below. */
|
||||||
|
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||||
|
|
||||||
|
export const createUserHandler: Handler = async function (ctx) {
|
||||||
|
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
|
||||||
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
||||||
|
const password = typeof body.password === 'string' ? body.password : '';
|
||||||
|
const role = typeof body.role === 'string' ? body.role : 'Member';
|
||||||
|
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw errors.BAD_REQUEST('Invalid email address');
|
||||||
|
if (!name) throw errors.BAD_REQUEST('Name is required');
|
||||||
|
|
||||||
|
// The same validators bootstrap uses. A member's password protects the same surface a member can
|
||||||
|
// reach, so there is no argument for a weaker rule here — and two different rules would mean the
|
||||||
|
// owner could create an account that could not then change its own password to something similar.
|
||||||
|
const username = validateUsername(typeof body.username === 'string' ? body.username : undefined);
|
||||||
|
validatePassword(password);
|
||||||
|
|
||||||
|
// Refused rather than filtered, so the owner is told instead of quietly getting a Member.
|
||||||
|
//
|
||||||
|
// There is exactly one owner. The database only pins user 1's role — a row-level CHECK cannot say
|
||||||
|
// "no OTHER row may hold this" — so a second Super Admin is storable, and `getOwnerUser()` would then
|
||||||
|
// return whichever the query reached first. That answer decides the identity the agent sidecar runs
|
||||||
|
// as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query
|
||||||
|
// plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown.
|
||||||
|
if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) {
|
||||||
|
throw errors.BAD_REQUEST(
|
||||||
|
role === 'Super Admin'
|
||||||
|
? 'There is one server owner and it cannot be created here.'
|
||||||
|
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The inbound SSH key, if the owner supplied one. Validated HERE rather than at use, so a bad paste is a
|
||||||
|
// 400 on the form instead of an account that exists with a confusing warning attached.
|
||||||
|
//
|
||||||
|
// Optional by design: an account with no inbound key is platform-only, which is a perfectly good state.
|
||||||
|
// The OUTBOUND key is generated regardless — see os-user-ssh.ts for why those are not alternatives.
|
||||||
|
const rawKey = typeof body.sshPublicKey === 'string' ? body.sshPublicKey.trim() : '';
|
||||||
|
let inboundKey: string | null = null;
|
||||||
|
if (rawKey) {
|
||||||
|
const checked = validatePublicKey(rawKey);
|
||||||
|
if (!checked.ok) throw errors.BAD_REQUEST(`SSH public key: ${checked.error}`);
|
||||||
|
inboundKey = checked.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked before the insert purely for the message — both columns are unique, so the database is the
|
||||||
|
// real guard and this is a race it can lose harmlessly (the insert then throws).
|
||||||
|
if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists');
|
||||||
|
if (await getUserByUsername(username)) throw errors.CONFLICT('That username is taken');
|
||||||
|
|
||||||
|
const user = await createUser({
|
||||||
|
email,
|
||||||
|
password: await argon2.hash(password),
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
status: 'Active',
|
||||||
|
role: role as UserRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
|
||||||
|
// than thrown — this is a side effect of creating a platform account, and a failed `useradd` must not undo
|
||||||
|
// an account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which is what an
|
||||||
|
// account created before this feature looks like, and which every consumer already handles.
|
||||||
|
//
|
||||||
|
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
|
||||||
|
// account was made does not cost anybody their password and dashboards.
|
||||||
|
const os = OS_USERS_ENABLED
|
||||||
|
? await provisionOsAccount({ userId: user.id, email, username, inboundKey })
|
||||||
|
: { osUser: null, sshPublicKey: null, error: null };
|
||||||
|
|
||||||
|
if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`);
|
||||||
|
|
||||||
|
return ctx.json(
|
||||||
|
{
|
||||||
|
user: { ...toPublicUser(user), osUser: os.osUser, osSshPublicKey: os.sshPublicKey },
|
||||||
|
osUserError: os.error,
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user