import './servers/bootstrap'; import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { join } from 'node:path'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { assertPermissionTotality } from './servers/permissions/totality'; import { bootstrapCaldav } from './servers/caldav-bootstrap'; import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount'; import { listPluginRootClaims } from './servers/plugins/public-paths'; import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate'; import { assertInstallLayout } from './servers/data-path'; import { PORT } from './servers/officer-url.mjs'; import { assertSecretsClosed, secureRuntimeSecrets } from './servers/os-user'; import { resolveHomeDir } from './servers/user-home'; import { resolveAuthToken } from './servers/auth-token'; import { isWsProviderAllowed } from './servers/permissions/authorize'; import { isTokenBlacklisted, getUserById } from 'officerdb'; import { terminalWebsocket } from './servers/api/terminal/websocket'; import { chatWebsocket } from './servers/api/chat/websocket'; import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery'; import { registerChatPushNotifications } from './servers/api/chat/notify-push'; import { startNudger } from './servers/api/chat/nudger'; import { taskRunnerWebsocket } from './servers/api/tasks/task-executor'; import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; import officerWeb from './apps/officer-web/index.gen.html'; import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; import { sendProvisionableUsers } from './servers/plugins/user-provisioning'; 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'; 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` // handle opened now. // // `plugins/` is EXCLUDED, and that is not an optimisation. Everything under it is published and removed // at runtime by the plugin installer, and this map is spread into the route table ahead of the // `/plugins/*` wildcard — so an exact entry here WINS over the dynamic handler that knows how to say // 404. A plugin whose icon existed at boot and was then uninstalled left a route pointing at a deleted // file, which answered `500 ENOENT` on every dock render until the next restart, with the error in the // log each time. Precisely what the wildcard's own comment says it exists to avoid. // // The add case was already known and is why the wildcard exists at all. This is its mirror: a snapshot // cannot describe a directory that changes while the server runs, in either direction. So it does not // try — `/plugins/*` owns that prefix alone. const publicRoutes: Record Response> = {}; for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) { if (file.startsWith('plugins/')) continue; const bunFile = Bun.file(`./public/${file}`); publicRoutes[`/${file}`] = () => new Response(bunFile); } type WSData = { userId: number; email: string; username: string; provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | '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 registration = msg as SidecarRegistration; const id = registerSidecar(ws, registration); sidecarConnections.set(ws, id); ws.send(JSON.stringify({ type: 'registered', id })); // The account list, for a plugin whose MANIFEST declares `userProvisioning`. // // Read from the manifest and never from this message: `name` and `handles` are self-declared and // believed, so a capability asserted here would be a plugin granting itself one. The manifest was // reviewed when the owner installed it. // // After the ack, not inside it — registration must not wait on a database query. A slow or // unreachable Postgres would otherwise stall every sidecar's startup, and none of them need the // list to start. void sendProvisionableUsers(ws, registration.name); 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, 'dev-proxy': devProxyWebsocket, 'plugin-ws': pluginWsWebsocket, chat: chatWebsocket, 'chat-notify': chatNotifyWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, sidecar: sidecarWebsocket, }; /** * 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 = { '/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', 'plugin-ws'])]; /* 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: 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. registerChatPushNotifications(); // Nudger's tick. Here rather than on first use, because a schedule armed before a restart has to survive // one — the row is in Postgres and nothing else would ever look at it again. startNudger(); // 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()); // Then close the ones the platform writes ITSELF, which is a repair rather than a refusal — see // `secureRuntimeSecrets`. It runs on every boot because `officer-anthropic-proxy` is a separate PM2 // process that can recreate its state file at the machine's umask hours after this one started, and // because the file it protects is the owner's Claude Max subscription in string form. const secured = await secureRuntimeSecrets(); if (secured.repaired.length) { console.warn( `[boot] closed ${secured.repaired.length} world-readable platform secret(s): ${secured.repaired.join(', ')}\n` + ' If this names sidecar/claude-state.json, the proxy secret was readable by every account on\n' + ' this machine and should be ROTATED, not just closed — the mode is fixed, the string is not.', ); } for (const path of secured.failed) console.error(`[boot] could not close ${path} — check its owner and mode`); // Mount whatever is installed, BEFORE serve(). // // Without this a restart silently unmounts every plugin: `honoServer` is built with none at import, // because discovery reads the disk and the database and neither can be awaited at module scope. The // install verbs each rebuild, so it looked correct in testing — right up until the first restart, which // is exactly the kind of bug that ships. // // Before `serve()` rather than after, so there is no window in which an installed plugin's route 404s. // Failure is logged and survived: a plugin that will not load must not stop the platform from starting, // and `refreshPluginMounts` already isolates one bad plugin from the rest. try { // Generate first, then build, then mount. The generated module is what the bundler reads, so writing it // after the build would produce a bundle describing the previous plugin set. const { states } = await snapshotPlugins(); generatePluginsModule(states); const built = await rebuildFrontend(); if (built.ok) console.log(`[plugins] frontend built in ${built.ms}ms (${built.outputs} outputs)`); else console.error('[plugins] frontend build FAILED —', built.error); // `skipFrontend`, because the build above already used the same snapshot. Rebuilding here would cost a // second of start-up to produce a byte-identical bundle. const { mounted, broken, rejected } = await refreshPluginMounts({ skipFrontend: true }); if (mounted.length) console.log(`[plugins] mounted ${mounted.join(', ')}`); if (broken.length) console.error(`[plugins] unreadable: ${broken.join(', ')}`); if (rejected.length) console.error(`[plugins] permission keys refused: ${rejected.join(', ')}`); } catch (err) { console.error('[plugins] none mounted —', err instanceof Error ? err.message : err); } // ── Serving the SPA ── // // In PRODUCTION the bundle comes from `build/`, rebuilt whenever a plugin is installed. That is what makes // a plugin's frontend appear without restarting the process: the HTML import below is bundled once when // the module graph loads and can never change afterwards. // // In DEVELOPMENT the HTML import is kept, because it is what gives HMR — and `bun --watch` restarts on // every source change anyway, so nothing is gained by building. The two paths differ only in where the // bytes come from; the generated `Plugins.gen.tsx` is the same file in both. const serveBuilt = async (req: Request): Promise => { const path = new URL(req.url).pathname; // An asset if it exists, the shell otherwise. A client-side route like `/offscale/servers` is not a file // and must return index.html, which is what makes deep links work at all. // A request that LOOKS like an asset and is not one must 404, never fall through to the shell. Serving // HTML with a JS content-type produces "Unexpected token '<'" and a blank page — a failure mode that // reads as a broken build rather than a missing file. // // SINGLE-SEGMENT only, and that is load-bearing. The bundler emits everything to the root of `build/` // — `chunk-.js`, `chunk-.css`, the shell — so a real build asset never has a second slash. // Multi-segment assets (`public/**`, `/plugins//icon.png`) are served by Bun's `routes` table // before this handler runs and never arrive here at all. // // Without the segment test, `/files/Tests/logo.png` — a client route naming a DIRECTORY that happens to // end in `.png` — 404s instead of reaching the SPA. Rare, but it fails silently and looks like a broken // deep link rather than a rule. const isSingleSegment = path.indexOf('/', 1) === -1; if (isSingleSegment && /\.(js|css|map|png|svg|ico|webmanifest|woff2?)$/.test(path)) { const file = Bun.file(join(BUILD_DIR, path.slice(1))); if (!(await file.exists())) return new Response('Not found', { status: 404 }); return new Response(file, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } }); } const asset = Bun.file(join(BUILD_DIR, path === '/' ? SHELL_FILE : path.slice(1))); if (path !== '/' && (await asset.exists())) { // Content-hashed, so the name changes whenever the bytes do and this can be cached hard. The shell // below is the opposite case and must not be. return new Response(asset, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } }); } const shell = Bun.file(join(BUILD_DIR, SHELL_FILE)); if (!(await shell.exists())) { // The build failed or never ran. Say so rather than 404ing, because a blank page here looks like a // broken deploy and this is recoverable by restarting or reinstalling the plugin that broke it. return new Response('The interface has not been built. Check the server log for a build failure.', { status: 503, headers: { 'content-type': 'text/plain' }, }); } // NEVER cache the shell. // // It names the hashed chunks, so a cached shell is a browser pinned to a build that no longer exists on // disk — every asset it asks for 404s, or worse it renders an old app against a current API. Served with // no cache headers at all until now, which leaves it to the browser's heuristics: a plugin could be // installed, the bundle rebuilt, and the tab keep running the previous one with no way to tell. return new Response(shell, { headers: { 'content-type': 'text/html', 'cache-control': 'no-cache, no-store, must-revalidate', }, }); }; const isProduction = process.env.NODE_ENV === 'production'; const spaRoutes = isProduction ? { '/': serveBuilt, '/*': serveBuilt } : { '/': officerWeb, '/*': officerWeb }; // Said out loud, because "is it serving the build I just made, or the one bundled at import?" is otherwise // answerable only by hiding the shell and watching for a 503 — which is how it was answered once. // // It matters because the two behave differently in exactly the case that matters: the HTML import is // bundled when the module graph loads and can never change, so installing a plugin would rebuild `build/` // and serve something else entirely. console.log( isProduction ? `[web] serving the built SPA from ${BUILD_DIR} — a plugin install rebuilds it` : '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used', ); /** * 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 }); try { // Same resolver as the two HTTP doors, so a key that works against /api works here too — a mobile app // holding one gets the same doors as a browser session, and a socket that only understood JWTs would // have made "signed in" and "can open this" 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 permission 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` permissions, not by being absent from an array someone has to maintain. if (!(await isWsProviderAllowed(user.id, provider, user.scope ?? null))) { 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 permission 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 permission 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); }, // A dev server's HMR socket, framed by the Preview panel. Must be in the route table rather than // behind the `/api/*` catch-all: Bun matches this table first, and an upgrade that reaches Hono is // an ordinary GET by then. '/api/dev-proxy/*': (req: Request, server: any) => { 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: '' }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); }, // 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 // 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, /* Top-level paths a plugin claims (`/identity/*`, …), handed to hono BEFORE the SPA fallback. They need naming here for the same reason `/dav` did: Bun matches this table first, and only the paths listed reach hono — anything else falls through to `/*`, which answers 200 with the React shell. A missing entry therefore looks like a working endpoint returning nonsense rather than a 404, which is among the least debuggable failures available. Built from the plugin set because `refreshPluginMounts` has already run by here (line ~204, well before `serve()`), so the claims are known. The cost is that adding or removing one needs a restart — this table is fixed once `serve()` is called. Stated rather than worked around: installing a plugin that claims a root path is the one install that is not complete until `pm2 restart officer`. */ ...Object.fromEntries( listPluginRootClaims().flatMap(({ segment }) => [ [`/${segment}`, (req: Request, server: unknown) => honoServer.fetch(req, server)], [`/${segment}/*`, (req: Request, server: unknown) => honoServer.fetch(req, server)], ]), ), ...spaRoutes, '/api': honoServer.fetch, // A CLOSURE, deliberately, and not the bound `honoServer.fetch`. // // Installing a plugin swaps the whole Hono app (`rebuildHonoApp` — Hono cannot add routes to a live // app, and cannot remove one at all). The bound method would capture whichever app existed when // `serve()` ran, so every rebuild after boot would be invisible and an install would silently do // nothing. Reading `honoServer` per request is what makes the reassignment the swap. '/api/*': (req: Request, server: unknown) => honoServer.fetch(req, server), }, websocket: { 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 // The browser relay left for `plugins/browser` on 2026-08-21. It ran a SECOND listener from this process, // which is why it was switched off here on 2026-08-13 — the platform serves one port. As a plugin it is a // sidecar, which is a process allowed its own listeners, so the objection no longer applies. // // The port and everything that depended on it moved with it, including the reason it cannot be // kernel-assigned: the extension stores the number by hand AND the number is an input to the token HMAC. // See plugins/browser/sidecar/protocol.ts. // 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'; import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager'; import { waitForDatabase } from 'officerdb'; // ── Startup work that needs the database, and waits for it ── // // Both of these query Postgres, and both used to be fired as bare promises with a `.catch()` that // logged. That is fine when the database is up and silently wrong when it is not — which is precisely // what a reboot looks like, with pm2's resurrect racing Docker starting the Postgres container. // // `cleanupOnStartup` is the one that matters: it marks jobs interrupted by the previous shutdown and // promotes the queued backlog. Fail it once and those jobs stay marked running forever, because the // only thing that would have corrected them has already run. Nothing retries and nothing complains // again — the log line scrolls past during boot and the jobs are simply stuck. // // Deliberately NOT awaited before serve(): the HTTP listener is already up by here, and holding it // closed for a minute would turn a database that is thirty seconds late into a reverse proxy serving // connection-refused instead of a page. Requests that need the database fail honestly in the meantime. void (async () => { if (!(await waitForDatabase())) { console.error('[startup] skipping queue init and pipeline cleanup — the database never answered'); return; } await initQueue().catch((err) => console.error('[queue] failed to initialize:', err)); await 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. Every trace of cliamp left on 2026-08-15 — the relay, the two socket routes and // the provider names — and now lives in `plugins/music/cliamp/`, parked rather than working. Bringing it // back means a plugin owning a websocket, which is a platform gap, not a music one. // Calendar and contacts became CORE on 2026-08-19, having been an app-store entry since 2026-08-13. // A fresh install gets radicale and the officer-caldav entry from setup; an EXISTING one gets neither // from a `git pull`, so it would come up with /calendar mounted and nothing behind it. Best-effort, // after serve(), never blocking: a held apt lock or a machine without passwordless sudo must not stop // Officer from starting, and when it cannot install it says what to run instead. void bootstrapCaldav(); // Pi check/install is handled by bootstrap.ts (imported above)