import './servers/bootstrap'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertCapabilityTotality } from './servers/capabilities/totality'; import { assertInstallLayout } from './servers/data-path'; import { PORT } from './servers/officer-url.mjs'; 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 { isTokenBlacklisted, getUserById } from 'officerdb'; import { terminalWebsocket } from './servers/api/terminal/websocket'; import { chatWebsocket } from './servers/api/chat/websocket'; import { taskRunnerWebsocket } from './servers/api/tasks/task-executor'; import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; import { cliampWebsocket, cliampAudioWebsocket } from './servers/api/cliamp/relay'; import { desktopWebsocket } from './servers/api/desktop/websocket'; import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket'; import officerWeb from './apps/officer-web/index.gen.html'; // import { startBrowserRelay } from './servers/api/browser/relay'; // switched off — see below import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report import type { SidecarRegistration } from './servers/sidecar/registration-protocol'; import { toShellUsername } from './servers/data-path'; // Build static file routes from public/ const publicRoutes: Record Response> = {}; for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) { const bunFile = Bun.file(`./public/${file}`); publicRoutes[`/${file}`] = () => new Response(bunFile); } type WSData = { userId: number; email: string; username: string; provider: | 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'vault' | 'sidecar'; sessionId?: string; cwd?: string; command?: string; cols?: number; rows?: number; search?: string; // raw query string, for providers that relay it to a sidecar }; // Sidecar registration WebSocket handler const sidecarConnections = new Map, string>(); // ws → sidecar ID const sidecarWebsocket = { open(_ws: ServerWebSocket) { // Wait for registration message }, message(ws: ServerWebSocket, raw: string | Buffer) { try { const data = typeof raw === 'string' ? raw : raw.toString(); const msg = JSON.parse(data); if (msg.type === 'register') { const id = registerSidecar(ws, msg as SidecarRegistration); sidecarConnections.set(ws, id); ws.send(JSON.stringify({ type: 'registered', id })); return; } // Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs) if (typeof msg.type === 'string' && msg.type.startsWith('queue:') && msg.id) { handleSidecarQueueCommand(ws, msg); return; } const id = sidecarConnections.get(ws); if (id) { handleSidecarMessage(id, msg); } } catch { // skip malformed messages } }, close(ws: ServerWebSocket) { const id = sidecarConnections.get(ws); if (id) { unregisterSidecar(id); sidecarConnections.delete(ws); } }, drain() {}, }; // Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs) async function handleSidecarQueueCommand(ws: ServerWebSocket, msg: Record) { const id = msg.id as string; try { switch (msg.type) { case 'queue:enqueue': { const job = await queueEnqueue(msg.params as import('./servers/queue/types').EnqueueParams); ws.send(JSON.stringify({ type: 'queue:enqueued', id, job })); break; } case 'queue:cancel': { const job = await queueCancel(msg.jobId as string); ws.send(JSON.stringify({ type: 'queue:cancelled', id, job })); break; } case 'queue:list': { const jobs = await queueList(); ws.send(JSON.stringify({ type: 'queue:list', id, jobs })); break; } case 'queue:get': { const job = await queueGet(msg.jobId as string); ws.send(JSON.stringify({ type: 'queue:get', id, job })); break; } default: ws.send(JSON.stringify({ type: 'queue:error', id, error: `Unknown queue command: ${msg.type}` })); } } catch (err) { ws.send(JSON.stringify({ type: 'queue:error', id, error: err instanceof Error ? err.message : String(err) })); } } const handlers: Record = { terminal: terminalWebsocket, chat: chatWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, cliamp: cliampWebsocket, 'cliamp-audio': cliampAudioWebsocket, desktop: desktopWebsocket, vault: vaultWebsocket, sidecar: sidecarWebsocket, }; // Both doors into the platform, checked against the capability 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. assertCapabilityTotality({ apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES], wsProviders: Object.keys(handlers), }); // That the working directory is the repo, because every path derives from its parent. First of the three, // since a wrong answer here makes the other two check the wrong files. assertInstallLayout(); // 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. await assertSecretsClosed(process.cwd()); async function upgradeWs( req: Request, server: any, provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop', ) { const token = new URL(req.url).searchParams.get('token'); if (!token) return new Response('Unauthorized', { status: 401 }); try { // 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.jti) { if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 }); } // The capability backstop, applied to sockets. Everything above this line AUTHENTICATES — it proves // who is calling and never asks what they may reach. That is why a Member with a valid token could // open a terminal here in the same minute it was 403'd on GET /api/tasks. // // This resolves against the same registry as the HTTP door rather than a parallel list, which is the // whole point: the two doors cannot disagree about what a role holds, because there is only one // declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here // by being `execution` capabilities, not by being absent from an array someone has to maintain. if (!(await isWsProviderAllowed(user.id, provider))) { return new Response('Forbidden', { status: 403 }); } const url = new URL(req.url); const sessionId = url.searchParams.get('sessionId') ?? undefined; const cwd = url.searchParams.get('cwd') ?? undefined; const command = url.searchParams.get('command') ?? 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; // ── 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, { data: { userId: user.id, email: user.email, username: toShellUsername(user.username ?? '', user.email), provider, sessionId, cwd, command, cols, rows, search: url.search, }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); } catch { return new Response('Unauthorized', { status: 401 }); } } const server = serve({ port: PORT, idleTimeout: 60, maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB routes: { ...publicRoutes, '/novnc/*': (req) => { const file = Bun.file(`public${new URL(req.url).pathname}`); return new Response(file); }, '/vendor/*': (req) => { const file = Bun.file(`public${new URL(req.url).pathname}`); return new Response(file); }, // Icons and assets belonging to installed sidecars, copied to public/plugins// 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); // everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy. '/api/vault/notifications/*': (req, server) => { if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(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: '' }, }); 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/cliamp/ws': (req, server) => upgradeWs(req, server, 'cliamp'), '/api/cliamp/audio/ws': (req, server) => upgradeWs(req, server, 'cliamp-audio'), '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'), // 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 // and the phone gets an HTML page where it expected a redirect. '/.well-known/caldav': honoServer.fetch, '/.well-known/carddav': 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, '/api': honoServer.fetch, '/api/*': honoServer.fetch, }, websocket: { open(ws) { const { provider } = (ws as unknown as ServerWebSocket).data; handlers[provider]!.open(ws as any); }, message(ws, raw) { const { provider } = (ws as unknown as ServerWebSocket).data; handlers[provider]!.message(ws as any, raw); }, close(ws) { const { provider } = (ws as unknown as ServerWebSocket).data; handlers[provider]!.close(ws as any); }, drain() {}, }, development: process.env.NODE_ENV !== 'production' && { hmr: true, }, }); console.log(`🚀 Server running at ${server.url}`); // ── The browser relay is not started. Deliberate, 2026-08-13. ── // // The Chrome extension, its CDP bridge (api/browser/) and the /browser screen all remain on disk: this // is going to be extracted into a plugin, and deleting it would mean writing it again. What is switched // off is the second listener and the /api/browser mount (see hono.ts) — the platform serves one port. // // It used to read BROWSER_RELAY_PORT, default 18792. The name is recorded here because there is nothing // left to grep for, and whoever does the extraction needs to know what it was called. // // ── Read this before turning it back on ── // // The port is not only configuration. relay-auth.ts derives each extension's token as // HMAC(JWT_SECRET, `officer-browser-relay-v1:${port}:${userId}:${salt}`), so the port is an INPUT TO A // CREDENTIAL. Bringing the relay back on a different number silently invalidates every paired browser, // and the extension reports it as "Relay not reachable" — which SETUP.md attributes to a wrong address, // port or token. Re-pairing means re-copying from Settings → Browser Relay. // // Whatever it comes back as, it cannot be a kernel-assigned port:0 the way the other sidecars are. The // extension is configured by hand and stores the value, so a port that changes each restart breaks the // pairing every restart. It has to be predictable — PORT + 2 is the shape the Anthropic proxy already // uses for the same reason. // // try { // await startBrowserRelay(BROWSER_RELAY_PORT); // console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`); // } catch (err) { // console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err); // } // Initialize queue engine in API server process import { initQueue, enqueueJob as queueEnqueue, cancelJob as queueCancel, listAllJobs as queueList, readJob as queueGet, } from './servers/queue/init'; initQueue().catch((err) => console.error('[queue] failed to initialize:', err)); // Mark any orphaned pipeline jobs from previous server run import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager'; cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err)); // PulseAudio and the `virtual_out` sink used to be set up here, at every boot of a process that has no // audio responsibilities. They belong to the music sidecar, which owns both cliamp halves now // (sidecar/music/pulse-audio.ts). // Pi check/install is handled by bootstrap.ts (imported above)