Compare commits
3
Commits
78db9c8c93
...
7e054ddf11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e054ddf11 | ||
|
|
0c2ff88c88 | ||
|
|
c982353381 |
@@ -20,10 +20,10 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**87 open, 22 closed**, across 29 themes.
|
||||
**85 open, 24 closed**, across 29 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| ------------------------------------------------------------------------------------------ | ---- | ------ |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 1 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 2 | 0 |
|
||||
| [Backburner](todo/backburner/) | 2 | 0 |
|
||||
@@ -35,9 +35,9 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [Fleet — one command across every server](todo/fleet/) | 5 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 6 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 4 | 1 |
|
||||
| [Known bugs](todo/known-bugs/) | 3 | 2 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 3 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 18 | 3 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 4 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 |
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# 2026.08.22.01 — deploy report
|
||||
|
||||
**6 of 6 hosts succeeded.** `officer-dev` skipped (the commander's own host) and already on this code.
|
||||
No host failed, no process came back `errored`.
|
||||
|
||||
Tag `deploy/2026.08.22.01` → `78db9c8c`. `package.json` reads `2026.08.22.01` at that commit — they agree.
|
||||
Every host went `98177c3b` → `78db9c8c`, a clean fast-forward.
|
||||
|
||||
Per the plan: **no `bun install` and no `bun db:push` ran anywhere.**
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`, from PM2's `pm_cwd` (the stale second checkout at
|
||||
`~/dockers/officer.dev/platform` was not touched).
|
||||
- Tree clean, tracked and untracked both. HEAD `98177c3b` → `78db9c8c`.
|
||||
- `bun run plugins:update`: `12/12 updated in 48s`, no plugin reported a failure. It bounced twelve plugin
|
||||
sidecars of its own accord — atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||
photos, soulseek, transmission, vault.
|
||||
- `pm2 restart officer`: `restart_time` 64 → 65, `online`.
|
||||
- All 19 processes `online` afterwards.
|
||||
|
||||
One gap, stated because it is the kind of thing a report should not quietly round up: **the exit code of
|
||||
`plugins:update` was lost on this host** — the shell wrapper swallowed it. Success is asserted from the
|
||||
`12/12 updated` line and the absence of any per-plugin failure, not from an exit status. Every other host
|
||||
captured an explicit `0`.
|
||||
|
||||
### `fofos` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`.
|
||||
- **One untracked file present: `scripts/chk.ts`.** Noted, not a reason to skip — a fast-forward cannot
|
||||
touch it, and it was left alone. No tracked change.
|
||||
- `plugins:update` exit 0: `1/1 updated in 7s` (gitea), its sidecar restarted.
|
||||
- `pm2 restart officer`: 18 → 19, `online`. All 8 processes `online`.
|
||||
- The capabilities sync that failed here for three deploys ran clean: `capabilities: pulled`. The ownership
|
||||
fix of 2026-08-21 is holding.
|
||||
|
||||
### `macbookpro` — succeeded
|
||||
|
||||
- Awake and reachable. Checkout `/Users/pastilhas/projects/officer-suite/platform`.
|
||||
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `bun` run with `PATH=$PATH:/opt/homebrew/bin` **appended**, `pm2` run as a separate command through the
|
||||
interactive shell, exactly as the plan and `docs/servers.md` require.
|
||||
- `plugins:update` exit 0: `no plugins installed — nothing to update`. No sidecar bounced.
|
||||
- `pm2 restart officer`: 5 → 6, `online`. All 6 processes `online`.
|
||||
|
||||
### `edge-pertento-bridge` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `2/2 updated in 9s` (gitea, offscale), both sidecars restarted.
|
||||
- `pm2 restart officer`: 7 → 8, `online`. All 8 processes `online`.
|
||||
|
||||
### `production-pertento-bridge` — succeeded
|
||||
|
||||
- Checkout `/home/pastilhas/officerdev/platform`. HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `1/1 updated in 4s` (gitea), its sidecar restarted.
|
||||
nvm printed its `.npmrc` `globalconfig`/`prefix` warning first. Pre-existing, unrelated to this release,
|
||||
and the command still exited 0 — recorded so the next reader does not chase it.
|
||||
- `pm2 restart officer`: 9 → 10, `online`.
|
||||
- This host also runs a **non-Officer** PM2 set (`Dashboard`, `Tracking`, `Uptime`, five `Crons *` entries).
|
||||
All `online`, none touched.
|
||||
|
||||
### `deadpool-rezio-bridge` — succeeded
|
||||
|
||||
- Login `br`, not `pastilhas`. Checkout `/home/br/officerdev/platform`.
|
||||
- HEAD `98177c3b` → `78db9c8c`, tree clean.
|
||||
- `plugins:update` exit 0: `2/2 updated in 8s` (email, offscale), both sidecars restarted.
|
||||
Printed `capabilities: adopted` rather than `pulled` — this host's capabilities checkout is newer than
|
||||
the others'. Not an error.
|
||||
- `pm2 restart officer`: 3 → 4, `online`. All 9 processes `online`.
|
||||
|
||||
### `officer-dev` — SKIPPED, and current
|
||||
|
||||
The commander runs here, inside `officer-claude-code`. Nothing was pulled and nothing restarted.
|
||||
|
||||
It is **not behind**: `deploy/2026.08.22.01` is an ancestor of the local HEAD. The checkout is in fact
|
||||
**two commits past** the tag — `c9823533` and `0c2ff88c`, neither on `origin` when this ran — written by
|
||||
another agent working in this same tree during the rollout. Its work was left alone; only this report was
|
||||
staged, by path.
|
||||
|
||||
Worth knowing rather than discovering: **pushing this report carries those two commits to `origin/master`
|
||||
with it**, because they are its ancestors. Nothing was deployed from them — every host in this rollout is
|
||||
at `78db9c8c`, the tag — but the next release will include them.
|
||||
|
||||
`officer` here is `online` at `restart_time` 180, running the tree this tag names.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Every restarted process came back.** `pm2 jlist` re-read on each host after the restart: `officer`
|
||||
`online` with `restart_time` incremented, and no process anywhere in state `errored` or `stopped`.
|
||||
- **The theme data landed.** The release is mostly data, so a bad file shows up as a missing or unreadable
|
||||
theme rather than a failed boot. All six hosts carry **227** theme CSS files under
|
||||
`src/workspaces/theme/css/themes/`, matching the release tree exactly.
|
||||
- `scripts/deploy-claim.sh state 2026.08.22.01` → `done`.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` was skipped entirely** — its own host, by design. The owner restarts it deliberately when
|
||||
happy with the rest. It needs no pull.
|
||||
- **No theme was loaded through a UI.** The plan asks that a theme picker still lists themes and one loads.
|
||||
A commander cannot click. The file-count check above is the substitute, and it is weaker: it proves the
|
||||
files arrived, not that each parses.
|
||||
- **`alpha`'s `plugins:update` exit code was not captured** (see above). Judged from output, not status.
|
||||
+67
-16
@@ -162,16 +162,60 @@ const handlers: Record<string, any> = {
|
||||
sidecar: sidecarWebsocket,
|
||||
};
|
||||
|
||||
// Both doors into the platform, checked against the permission registry before either opens.
|
||||
//
|
||||
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
|
||||
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
|
||||
// from the map directly above, so neither list can be a stale copy of the thing it describes.
|
||||
/**
|
||||
* Every websocket path Bun serves, and the provider each one upgrades to.
|
||||
*
|
||||
* ── Why this exists rather than a hand-written route table ──
|
||||
*
|
||||
* The boot check below used to be fed `Object.keys(handlers)` — the map of provider implementations — while
|
||||
* Bun served a SEPARATE, hand-listed set of routes. Two lists describing one thing, and the comment beside
|
||||
* the check said neither could be a stale copy of the other, which was simply not true: a socket route with
|
||||
* no handler, or with no registry claim, was invisible to the very check that exists to refuse it.
|
||||
*
|
||||
* It had a live instance. `/api/cliamp/ws` and `/api/cliamp/audio/ws` stayed in the route table for two
|
||||
* days after their handlers and registry entries were commented out — not exploitable, since
|
||||
* `isWsProviderAllowed` finds no permission and 403s a member while the owner upgrades onto a dead socket,
|
||||
* but exactly the drift the guard is for. Deleting cliamp on 2026-08-15 removed the symptom and left the
|
||||
* shape.
|
||||
*
|
||||
* Now the routes are GENERATED from this, and this is what totality reads. A path added here without a
|
||||
* handler or without a registry claim fails the boot rather than serving quietly.
|
||||
*/
|
||||
const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
'/api/tasks/run/ws': 'task-runner',
|
||||
'/api/tasks/pipeline/ws': 'pipeline',
|
||||
'/api/terminal/ws': 'terminal',
|
||||
'/api/chat/ws': 'chat',
|
||||
'/api/chat/notify/ws': 'chat-notify',
|
||||
};
|
||||
|
||||
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
||||
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
||||
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
||||
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
|
||||
|
||||
/*
|
||||
Both doors into the platform, checked against the permission registry before either opens.
|
||||
|
||||
This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated cannot
|
||||
be reached even once. The HTTP half comes from hono.ts's own mount table. The socket half now comes from
|
||||
WS_ROUTES, which the route table is BUILT from, so the check reads what is actually served rather than a
|
||||
parallel list that agrees with it by hand.
|
||||
|
||||
A provider with a handler and no route is fine and is not checked: unreachable is the safe direction. The
|
||||
failure this catches is the other one — a route reachable with nothing claiming it.
|
||||
*/
|
||||
assertPermissionTotality({
|
||||
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
||||
wsProviders: Object.keys(handlers),
|
||||
wsProviders: SERVED_WS_PROVIDERS,
|
||||
});
|
||||
|
||||
// A route cannot name a provider that has no implementation. Cheap, and it turns a 500 on first connect
|
||||
// into a refusal to boot with the name in it.
|
||||
for (const [path, provider] of Object.entries(WS_ROUTES)) {
|
||||
if (!handlers[provider]) throw new Error(`${path} upgrades to '${provider}', which has no handler`);
|
||||
}
|
||||
|
||||
// Chat's phone channel. Subscribes to the same signal the browser bell uses and asks the same question;
|
||||
// it just answers it into APNs/FCM instead of a websocket. Registered here, and explicitly, because a
|
||||
// module whose only job is a side effect is one an import sweep quietly deletes.
|
||||
@@ -312,11 +356,16 @@ console.log(
|
||||
: '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
|
||||
);
|
||||
|
||||
async function upgradeWs(
|
||||
req: Request,
|
||||
server: any,
|
||||
provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline',
|
||||
) {
|
||||
/**
|
||||
* A provider reachable by upgrading a URL.
|
||||
*
|
||||
* Named rather than inline so `WS_ROUTES` is checked against it: a route naming a provider this function
|
||||
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
|
||||
* deliberately absent — they upgrade through their own handlers, not through here.
|
||||
*/
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
@@ -441,11 +490,13 @@ const server = serve({
|
||||
});
|
||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||
},
|
||||
'/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
|
||||
'/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
|
||||
'/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
|
||||
'/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
|
||||
'/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'),
|
||||
// Generated from WS_ROUTES, so this table and the boot check cannot describe different sets.
|
||||
...Object.fromEntries(
|
||||
Object.entries(WS_ROUTES).map(([path, provider]) => [
|
||||
path,
|
||||
(req: Request, server: any) => upgradeWs(req, server, provider),
|
||||
]),
|
||||
),
|
||||
// CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
|
||||
// fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
|
||||
// credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
|
||||
|
||||
@@ -257,6 +257,16 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
offset: windowed ? start : 0,
|
||||
title: context?.title ?? null,
|
||||
partCount: context?.partCount ?? 1,
|
||||
/*
|
||||
Whether this conversation is a commander, derived from ITS OWN cwd rather than from the screen asking.
|
||||
|
||||
The composer offers "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and
|
||||
decided whether to by looking at the route it was rendered on. So the same commander, opened from
|
||||
/chat instead of Command HQ, silently lost the control that is the whole point of it. A commander is a
|
||||
commander wherever it is rendered, and `detail.cwd` is the transcript's own directory, so this answer
|
||||
travels with the conversation.
|
||||
*/
|
||||
isCommander: isCommandersDir(who.email, detail.cwd),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ type ResumeState = {
|
||||
messages: ChatMessage[];
|
||||
total: number;
|
||||
offset: number;
|
||||
/** Carried so the composer offers a commander's own controls here, not only in Command HQ. */
|
||||
isCommander: boolean;
|
||||
};
|
||||
|
||||
// Transcripts get extremely long, so only the tail is loaded up front; the chat pages older messages in
|
||||
@@ -104,6 +106,9 @@ const ChatPanelInner = ({
|
||||
promptPrefix={promptPrefix}
|
||||
panelId={panelId}
|
||||
sessionId={sessionKey ?? undefined}
|
||||
// From the SESSION, so a commander opened here gets a commander's composer. `chatContext` may also
|
||||
// set it via `context`; either being true is enough — see `manager.isCommander`.
|
||||
isCommander={resume?.isCommander === true}
|
||||
{...chatContext}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,6 +226,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
|
||||
messages: detail.messages as unknown as ChatMessage[],
|
||||
total: detail.total,
|
||||
offset: detail.offset,
|
||||
isCommander: detail.isCommander === true,
|
||||
});
|
||||
} catch {
|
||||
// A stale id — the transcript was deleted or pruned from under us. Open a NEW conversation rather
|
||||
|
||||
@@ -23,6 +23,14 @@ type EmbeddableChatProps = {
|
||||
chat?: UseChatType;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/**
|
||||
* This SESSION is a commander, as reported by `GET /chat/sessions/:id`.
|
||||
*
|
||||
* Separate from `context`, which says which SCREEN is rendering. Both can make the composer offer the
|
||||
* handoff watcher and only this one is true of the conversation wherever it is opened — which is the bug:
|
||||
* a commander opened in /chat had `context: 'chat'` and silently lost the control.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
onMessageComplete?: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ type UseEmbeddableChatParams = {
|
||||
chat?: UseChatType;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/** This session is a commander, per `GET /chat/sessions/:id`. See the note where it is surfaced. */
|
||||
isCommander?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -611,6 +613,15 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
// place a `handoff` watcher can be promised — see BackgroundJobDialog — and it was invisible to the
|
||||
// composer, so the option was never rendered anywhere at all.
|
||||
context: params.context,
|
||||
/*
|
||||
One answer to "is this a commander", from either of the two things that can know.
|
||||
|
||||
The SCREEN knows when Command HQ starts one, before the session exists and before any route could
|
||||
report on it. The SESSION knows once it has a transcript, wherever it is opened. Neither alone is
|
||||
enough: screen-only was the original bug (a commander in /chat lost its controls) and session-only
|
||||
would lose the brand-new composer, which has no session yet.
|
||||
*/
|
||||
isCommander: params.context === 'command-hq' || params.isCommander === true,
|
||||
messages,
|
||||
historicalCount,
|
||||
streamingText,
|
||||
|
||||
@@ -199,12 +199,16 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
`handoff` — exit, act, re-arm — is only offered inside a Command HQ commander, because it is a
|
||||
promise the platform has to keep rather than only a sentence in the prompt.
|
||||
|
||||
Read from the MANAGER rather than recomputed from the route, which is what it was: a commander
|
||||
opened from /chat reported `context: 'chat'` and lost the option, having gained it two hours
|
||||
earlier. `manager.isCommander` is true when either the screen or the session says so.
|
||||
|
||||
This prop was never passed by anybody, so `isCommander` defaulted to false everywhere and the
|
||||
option was rendered NOWHERE — including in a commander, where the dialog's own code makes it the
|
||||
default. The whole mechanism existed: the fire behaviour, the contract, `armWatcher` reaching the
|
||||
server. Only the radio button was missing, so the feature looked unbuilt.
|
||||
*/
|
||||
isCommander={manager.context === 'command-hq'}
|
||||
isCommander={manager.isCommander}
|
||||
onSubmit={(intent, preamble, armWatcher) => {
|
||||
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
||||
// background jobs an agent starts on its own.
|
||||
|
||||
@@ -96,6 +96,14 @@ export type ClaudeSessionDetail = {
|
||||
title?: string | null;
|
||||
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
|
||||
partCount?: number;
|
||||
/**
|
||||
* A commander rather than an ordinary conversation, derived server-side from this session's own cwd.
|
||||
*
|
||||
* It travels with the SESSION and not with the screen, which is the whole point. The composer decides
|
||||
* whether to offer "exit and hand back, then re-arm" — the only fire behaviour a watcher wants — and used
|
||||
* to decide it from the route, so the same commander opened in /chat quietly lost it.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
};
|
||||
|
||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: known-bugs/a-commander-opened-from-chat-loses-its-context
|
||||
title: 'A commander opened from /chat loses its commander controls'
|
||||
theme: known-bugs
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-21
|
||||
date_source: stated
|
||||
order: 4
|
||||
@@ -28,3 +28,16 @@ closes this and makes the same thing work in any future surface that renders a c
|
||||
|
||||
Related: the same `context` name means two different things either side of `EmbeddableChat` — a `useChat`
|
||||
option and a component prop, neither reading the other's — which is what hid the original bug twice.
|
||||
|
||||
---
|
||||
|
||||
**Fixed 2026-08-22.** `GET /chat/sessions/:id` now reports `isCommander`, derived from the session's own cwd
|
||||
by the same `isCommandersDir` the list and `/chat/live` already used — so the answer travels with the
|
||||
conversation instead of with the screen showing it.
|
||||
|
||||
The composer reads ONE value, `manager.isCommander`, true when either source says so. Both are needed and
|
||||
neither is sufficient: the SCREEN knows when Command HQ starts a commander, before a session or a transcript
|
||||
exists for any route to report on, and the SESSION knows once it does, wherever it is opened. Screen-only
|
||||
was this bug; session-only would have lost the brand-new composer.
|
||||
|
||||
Verified on a real commander: `172cdd8c` resolves `cwd=…/commanders` and `isCommander=true`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
id: multi-user/assertpermissiontotality-checks-the-wrong
|
||||
title: "`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13"
|
||||
title: '`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13'
|
||||
theme: multi-user
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-13
|
||||
date_source: stated
|
||||
order: 12
|
||||
@@ -17,3 +17,27 @@ a dead socket. But the boot check that exists to stop exactly this cannot see it
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
---
|
||||
|
||||
**Fixed 2026-08-22.** Half of this item was already stale and half was still exactly true, which is worth
|
||||
separating because the stale half is what would have made someone close it.
|
||||
|
||||
Stale: the eight red tests in `registry.test.ts` are green, and have been since cliamp was deleted on
|
||||
2026-08-15 — that removed the live instance of the drift along with the routes.
|
||||
|
||||
Still true: the two lists were still two lists. `assertPermissionTotality` read `Object.keys(handlers)`, the
|
||||
map of implementations, while Bun served a hand-written route table twenty lines away. The comment beside
|
||||
the check claimed neither could be a stale copy of the other, and that was simply false.
|
||||
|
||||
The fix is one declaration. `WS_ROUTES` maps path → provider, the Bun route table is GENERATED from it, and
|
||||
the boot check reads it — so a socket route cannot exist without being checked. Two smaller guards came with
|
||||
it: a route naming a provider with no handler now refuses to boot with the path in the message, and the
|
||||
provider union that was inline in `upgradeWs` is named (`UpgradableProvider`) so the route table typechecks
|
||||
against what that function can actually serve.
|
||||
|
||||
`sidecar` and `dev-proxy` are listed explicitly as served-but-not-in-WS_ROUTES, because they upgrade through
|
||||
their own handlers. Named rather than omitted, so their absence cannot read as an oversight.
|
||||
|
||||
Verified by running the check against the real prefix lists: the served set passes, and adding one unclaimed
|
||||
provider is refused with `websocket provider 'cliamp-audio' is served but no permission claims it`.
|
||||
|
||||
Reference in New Issue
Block a user