diff --git a/src/server.tsx b/src/server.tsx index b8b813c8..0c8cbcff 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -30,6 +30,7 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket'; +import { pluginWsWebsocket, upgradePluginWs } from './servers/api/plugin-ws/websocket'; // Build static file routes from public/ // A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file` @@ -156,6 +157,7 @@ async function handleSidecarQueueCommand(ws: ServerWebSocket, msg: Recor const handlers: Record = { terminal: terminalWebsocket, 'dev-proxy': devProxyWebsocket, + 'plugin-ws': pluginWsWebsocket, chat: chatWebsocket, 'chat-notify': chatNotifyWebsocket, 'task-runner': taskRunnerWebsocket, @@ -195,7 +197,7 @@ const WS_ROUTES: Record = { // 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'])]; +const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy', 'plugin-ws'])]; /* Both doors into the platform, checked against the permission registry before either opens. @@ -487,6 +489,18 @@ const server = serve({ if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(req, server); return honoServer.fetch(req, server); }, + /* + Every plugin's socket, through one wildcard declared at boot. + + This is the route that made a plugin websocket possible at all. `WS_ROUTES` is read once before + `serve()`, so nothing installed later can add an entry to it — but a wildcard does not need to, + because the plugin name is a path segment rather than a route. The same trick `dev-proxy` above has + been using all along; it simply had no second consumer until now. + */ + '/api/plugin-ws/*': (req: Request, server: any) => { + if (req.headers.get('upgrade') === 'websocket') return upgradePluginWs(req, server); + return honoServer.fetch(req, server); + }, '/api/sidecar/register': (req: Request, server: any) => { const ok = server.upgrade(req, { data: { provider: 'sidecar', userId: 0, email: '', username: '' }, diff --git a/src/servers/api/plugin-ws/target.ts b/src/servers/api/plugin-ws/target.ts new file mode 100644 index 00000000..39071d97 --- /dev/null +++ b/src/servers/api/plugin-ws/target.ts @@ -0,0 +1,42 @@ +import { sidecarPort } from '@@/sidecar-registry'; +import { snapshotPlugins } from '@@/plugins/mount'; + +/* + Where a plugin's socket actually goes. + + A target is a TCP address and never a URL, and that is what keeps the relay ignorant of the protocol on + the other side. The ignorance is the point: it is the only reason one relay can serve an RFB display and a + line-oriented sidecar without knowing which is which. The moment this returned a scheme or a path, the + relay would have to care what it was carrying. + + Two forms, because two things genuinely differ: + + { port: 5900 } a fixed port a plugin's own service listens on — `x11vnc` for a desktop, or + anything the plugin starts itself and knows the number of in advance. + { sidecar: 'name' } resolved at connect time, because a sidecar binds an EPHEMERAL port and announces + it. A manifest cannot know that number, which is why the indirection exists rather + than being a convenience. + + **Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host + would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is + granted to. There is no case for it that a sidecar does not already serve. +*/ + +export type PluginWsTarget = { host: string; port: number }; + +export async function pluginWsTargetFor(appName: string): Promise { + const { states } = await snapshotPlugins(); + // Installed, not merely present on disk. A plugin directory that exists but is not installed has no + // routes and no permission, so it must have no socket either. + const state = states.find((st) => st.plugin.appName === appName && st.install); + const socket = state?.plugin.manifest.websocket; + if (!socket) return null; + + if ('port' in socket) return { host: '127.0.0.1', port: socket.port }; + + // A sidecar that has not announced its port yet is indistinguishable from one that never will, and both + // are correctly a refusal — there is nothing to relay onto. The caller says "not accepting connections", + // which is true either way. + const port = sidecarPort(socket.sidecar); + return port ? { host: '127.0.0.1', port } : null; +} diff --git a/src/servers/api/plugin-ws/websocket.ts b/src/servers/api/plugin-ws/websocket.ts new file mode 100644 index 00000000..b0d142a5 --- /dev/null +++ b/src/servers/api/plugin-ws/websocket.ts @@ -0,0 +1,182 @@ +import type { ServerWebSocket, Socket } from 'bun'; +import { resolveAuthToken } from '@@/auth-token'; +import { isTokenBlacklisted } from 'officerdb'; +import { isApiRequestAllowed } from '@@/permissions/authorize'; +import { pluginWsTargetFor } from './target'; + +/* + One websocket door for every plugin, so a plugin can have a socket at all. + + ── Why this exists ── + + Bun's route table is fixed at `serve()`, and `WS_ROUTES` is a static object read at boot. So a plugin + installed afterwards had no way to add a socket route, and "a websocket provider is a platform concern" + became a hard ceiling rather than a preference: `/minesweeper` had to live in the platform and + `plugins/desktop` could not do the same thing at all. + + The way out was already in the tree. `dev-proxy` serves a socket without a `WS_ROUTES` entry, through a + WILDCARD route whose handler calls `server.upgrade` itself. Nothing about the route table needs to become + dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the + plugin name is a path segment rather than a route. + + ── What it does and does not read ── + + Bytes, both ways, without interpreting them. Same as the terminal's relay onto the pty sidecar and the + minesweeper relay onto x11vnc: frame boundaries carry no meaning to a stream protocol, so chunks are + forwarded exactly as they arrive. + + A plugin's target is a TCP address, never a URL. That is deliberate — it keeps this file ignorant of + whatever protocol is on the other side, which is the only reason one relay can serve an RFB display and a + line-oriented sidecar without knowing which is which. +*/ + +export type PluginWSData = { + provider: 'plugin-ws'; + plugin: string; + host: string; + port: number; + userId: number; +}; + +type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean }; +const relays = new Map, Relay>(); + +const closeClient = (ws: ServerWebSocket, code: number, reason: string) => { + const relay = relays.get(ws); + relays.delete(ws); + if (relay) relay.done = true; + try { + relay?.socket?.end(); + } catch { + /* already gone */ + } + try { + ws.close(code, reason); + } catch { + /* already closing */ + } +}; + +export const pluginWsWebsocket = { + async open(ws: ServerWebSocket) { + /* + The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is + already connected and may already be talking by the time `Bun.connect` resolves — and an RFB client + sends its protocol version immediately. Dropping those bytes desynchronises the stream in a way that + looks like a corrupt display rather than a lost packet. Learned by dev-proxy first. + */ + const relay: Relay = { socket: null, queue: [], done: false }; + relays.set(ws, relay); + + try { + const socket = await Bun.connect({ + hostname: ws.data.host, + port: ws.data.port, + socket: { + data: (_s, chunk) => { + try { + ws.send(chunk); + } catch { + /* client gone; the close handler tidies up */ + } + }, + close: () => closeClient(ws, 1011, 'upstream closed'), + error: () => closeClient(ws, 1011, 'upstream error'), + }, + }); + + // Between the await above and here the client may have gone. Writing to a socket nobody is reading + // would keep the upstream connection open for as long as the plugin's process lives. + if (relay.done) { + socket.end(); + return; + } + relay.socket = socket; + for (const chunk of relay.queue) socket.write(chunk); + relay.queue.length = 0; + } catch { + // A plugin whose process is not listening is the ordinary case, not an exception: a sidecar that has + // not booted yet, or a plugin whose service is stopped. The client is told, and told which plugin. + closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`); + } + }, + + message(ws: ServerWebSocket, message: string | Uint8Array) { + const relay = relays.get(ws); + if (!relay) return; + const bytes = typeof message === 'string' ? new TextEncoder().encode(message) : message; + if (relay.socket) relay.socket.write(bytes); + else relay.queue.push(bytes); + }, + + close(ws: ServerWebSocket) { + const relay = relays.get(ws); + relays.delete(ws); + if (relay) relay.done = true; + try { + relay?.socket?.end(); + } catch { + /* already gone */ + } + }, +}; + +/** + * `/api/plugin-ws/` with an upgrade header. + * + * Authorisation is PER PLUGIN, which is why `plugin-ws` is exempt from the boot-time totality check rather + * than carrying a registry entry of its own. One entry covering every plugin's socket would be a hole: + * granting one plugin would grant them all. + * + * So the decision is delegated to the plugin's OWN capability, by asking the same question its HTTP routes + * are asked — `isApiRequestAllowed(user, 'GET', '/api/')`. That is deliberate and it is the whole + * safety argument: there is no second permission to grant, to forget, or to drift out of step. Granting a + * plugin grants its socket, and revoking it closes it. + * + * Async, like `upgradeWs`. `server.upgrade` must be called synchronously with respect to the request, but + * Bun accepts a route handler that returns a promise — which is what lets the token resolve, the blacklist + * check and the permission decision all happen before anything is upgraded. + */ +export async function upgradePluginWs(req: Request, server: any): Promise { + const url = new URL(req.url); + const match = url.pathname.match(/^\/api\/plugin-ws\/([a-z0-9-]{1,64})\/?$/); + if (!match) return undefined; + const plugin = match[1]!; + + // A browser cannot set headers on a WebSocket upgrade, so the token arrives as a query parameter — the + // concession every other socket here already makes. Verified through the same resolver as the HTTP doors, + // so an API key that works against /api works here too. + const token = url.searchParams.get('token'); + if (!token) return new Response('Unauthorized', { status: 401 }); + + try { + const user = await resolveAuthToken(token); + if (!user) return new Response('Unauthorized', { status: 401 }); + if (user.jti && (await isTokenBlacklisted(user.jti))) return new Response('Unauthorized', { status: 401 }); + + // The plugin's own gate, asked as if this were a GET against its HTTP prefix. + if (!(await isApiRequestAllowed(user.id, 'GET', `/api/${plugin}`)).allowed) { + return new Response('Forbidden', { status: 403 }); + } + + const target = await pluginWsTargetFor(plugin); + // 404 for both "no such plugin" and "no socket declared", identically. A caller must not be able to + // discover which plugins are installed by which refusal it gets — the same reasoning as SendOff's + // recipient lookup. + if (!target) return new Response('Not found', { status: 404 }); + + const ok = server.upgrade(req, { + data: { + provider: 'plugin-ws', + plugin, + host: target.host, + port: target.port, + userId: user.id, + } satisfies PluginWSData, + }); + return ok ? undefined : new Response('Upgrade failed', { status: 400 }); + } catch { + // Any failure to establish WHO is calling denies. Never open a socket on a maybe. + return new Response('Unauthorized', { status: 401 }); + } +} diff --git a/src/servers/permissions/totality.ts b/src/servers/permissions/totality.ts index 70ab7ac5..6e262b54 100644 --- a/src/servers/permissions/totality.ts +++ b/src/servers/permissions/totality.ts @@ -54,6 +54,12 @@ const EXEMPT_WS_PROVIDERS: Record = { // same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner // without carrying an account through `upgradeWs`. Refused outright without that cookie. 'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues', + // One relay serving every plugin's socket, so a single registry entry here would be a HOLE: granting one + // plugin would grant them all. `upgradePluginWs` therefore asks the PLUGIN's own capability, as if the + // socket were a GET against its HTTP prefix — which means the decision is already in the registry, just + // not under this provider's name. Exempt because the check is per-request and per-plugin, never because + // it is absent. + 'plugin-ws': "relayed per plugin; authorised by that plugin's own capability in upgradePluginWs", }; /** diff --git a/src/servers/plugins/manifest.ts b/src/servers/plugins/manifest.ts index 178b67fb..61ea779b 100644 --- a/src/servers/plugins/manifest.ts +++ b/src/servers/plugins/manifest.ts @@ -282,6 +282,26 @@ export type PluginManifest = { * a missing one that cannot be installed here refuses the install rather than proceeding to a plugin * that runs and quietly produces nothing. */ + /** + * A websocket this plugin serves, relayed by the platform. + * + * Declaring this makes `/api/plugin-ws/` a byte relay onto the address below. It exists because + * Bun's route table is fixed at `serve()`, so a plugin installed afterwards could never add a socket route + * — which is why `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a + * display at all. + * + * Authorisation is the plugin's OWN capability, asked as if the socket were a GET against its HTTP prefix. + * There is deliberately no separate socket permission: granting the plugin grants its socket, and nothing + * can drift out of step because there is only one decision. + * + * `{ port }` for a fixed loopback port the plugin's own service listens on. `{ sidecar }` when the target + * is a sidecar, whose port is ephemeral and announced at registration — a manifest cannot know that + * number, which is the whole reason the indirection exists. + * + * The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a + * request-forgery primitive reachable by anyone the plugin is granted to. + */ + websocket?: { port: number } | { sidecar: string }; osDependencies?: OsDependency[]; /** diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index 81cc3d04..6b19b334 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -120,6 +120,25 @@ export function unregisterSidecar(id: string): void { } } +/* + Every sidecar's announced HTTP port, by name. + + `createSidecarProxy` already learns this for the prefix it serves, by subscribing to `:server`. That + is per-proxy state and unreachable from anywhere else, which was fine while the only consumer was the + proxy itself — and stopped being fine when a plugin needed a WEBSOCKET relayed onto the same sidecar. A + second subscription in a second place would be a second thing to keep in step. + + So the port is recorded here, once, from the same event. A sidecar that has not announced yet is absent + rather than zero: "not listening" and "listening on nothing" are different answers and only one of them is + true. +*/ +const sidecarPorts = new Map(); + +/** The port a named sidecar announced, or null if it has not announced one. */ +export function sidecarPort(name: string): number | null { + return sidecarPorts.get(name) ?? null; +} + export function handleSidecarMessage(id: string, msg: SidecarEvent): void { // Check if this is a response to a pending request if ('id' in msg && msg.id && pending.has(msg.id)) { @@ -130,6 +149,13 @@ export function handleSidecarMessage(id: string, msg: SidecarEvent): void { return; } + // Record any `:server` announcement before dispatching, so `sidecarPort` is current by the time a + // subscriber reacts to the same event. + if ('type' in msg && typeof msg.type === 'string' && msg.type.endsWith(':server')) { + const port = (msg as { port?: number }).port; + if (typeof port === 'number') sidecarPorts.set(msg.type.slice(0, -':server'.length), port); + } + // Otherwise dispatch as event dispatchEvent(msg); }