UNPKG

framework

Version:

The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.

212 lines 11.7 kB
import { createServer } from 'node:http'; import { timingSafeEqual } from 'node:crypto'; import { serveClientBundle } from './static.js'; import { BROWSER_PROXY_PREFIX, handleBrowserProxy } from './browser-proxy.js'; import { makeRpcMount, RPC_PREFIX, isSameOriginRequest, isExpectedHost } from './rpc-serve.js'; import { requestPathname } from '../request-path.js'; import { handleRelayRequest, RELAY_PREFIX } from './relay-endpoints.js'; import { BRIDGE_PREFIX, EXPECTED_EXTENSION_VERSION, handleBridgeRequest } from './bridge-endpoints.js'; import { bridgeQuestions } from './bridge-store.js'; /** * Start the localhost dashboard: a tiny `node:http` server that serves the built SPA (#405) and * mounts its RPC surface at `/_rpc` — the calls and the live-event stream. The dashboard reads the * agent's `.the-framework/events.jsonl` over that stream and steers it through `control.jsonl`, so * there is no in-process event stream here; the server is a static-bundle + RPC host. The RPCs run * in the daemon's own process, so `sendStart` / `sendAddProject` call the daemon's own closures via * {@link DashboardOptions.onStart} / {@link DashboardOptions.onAddProject}. */ export function startDashboard(opts) { const host = opts.host ?? '127.0.0.1'; const port = opts.port ?? 4200; const clientBundleDir = opts.clientBundleDir; // A broken install ships no built bundle (the published package always does), so serve // 503 for everything rather than stand up a half-wired mount. Returning here lets the // main path below treat the bundle, mount, and quota as present with no re-checks. if (!clientBundleDir) { const server = createServer((_req, res) => { res.writeHead(503, { 'content-type': 'text/plain' }); res.end('the dashboard bundle is not installed'); }); return listenDashboard(server, host, port, () => closeServer(server)); } // The usage panel polls for the dashboard's whole life, not just during an agent: // it has to show where the account stands while nothing is running (#533). const quota = opts.quota; const rpcMount = makeRpcMount({ startAgent: opts.onStart, addProject: opts.onAddProject, eventsSource: opts.eventsSource, remote: opts.remote, preferences: opts.preferences, discord: opts.discord, autoPm: opts.autoPm, autoPmSweep: opts.autoPmSweep, projectErrors: opts.projectErrors, quota, }, // The bound host, so the mount can reject a rebound `Host`: a page on evil.com whose DNS // answers 127.0.0.1 is same-origin to the browser, so the Origin check alone lets it in. { host }); // The device-to-daemon relay endpoints (#1067): wired only when an events tail is supplied. // Fronted by the same token guard as every other route below. const relayHandlers = opts.relay ? { start: opts.onStart, tailEvents: opts.relay.tailEvents, ...(opts.relay.rpc ? { rpc: opts.relay.rpc } : {}) } : undefined; // The browser bridge (#1237). Off unless a token was supplied, and it carries that token itself // rather than riding the shared-token guard (#1051), which a loopback daemon does not have. const bridgeHandlers = opts.bridgeToken ? { token: opts.bridgeToken, // The version gate (#1519): a stale extension is refused loudly rather than half-working. expectedExtensionVersion: EXPECTED_EXTENSION_VERSION, extensionVersion: (got, blocked) => bridgeQuestions().recordVersion(got, EXPECTED_EXTENSION_VERSION, blocked), record: question => bridgeQuestions().record(question), contact: (route, status) => bridgeQuestions().recordContact(route, status), recordEvent: event => bridgeQuestions().recordEvent(event), hello: hello => bridgeQuestions().recordHello(hello), answer: sessionId => { const pending = bridgeQuestions().pendingAnswer(sessionId); return pending ? { id: pending.id, label: pending.label } : undefined; }, answered: (sessionId, id, ok, note) => bridgeQuestions().resolveAnswer(sessionId, id, ok, note), ...(opts.bridgeSessions ? { sessions: opts.bridgeSessions } : {}), } : undefined; const token = opts.token; const server = createServer((req, res) => { const pathname = requestPathname(req); if (pathname === undefined) { res.writeHead(400, { 'content-type': 'text/plain' }).end('bad request'); return; } // The browser bridge (#1237) is checked BEFORE the shared-token guard (#1051), and is the only route that is. // That guard's browser affordance is a `?token=` redirect meant for a human following a link, // which is meaningless to an extension posting JSON; the bridge presents its own bearer token // instead, so letting it past here costs nothing and skips a 302 it could not follow. if (pathname === BRIDGE_PREFIX || pathname.startsWith(`${BRIDGE_PREFIX}/`)) { void handleBridgeRequest(req, res, pathname, bridgeHandlers); return; } // #1051: one guard fronting every route on a non-loopback bind; a no-op when no token is set. if (token !== undefined && !authorizeDaemonRequest(req, res, token)) return; // The device relay (#1067): another daemon posts an agent here and streams its events back. // The token guard above is a no-op on a loopback bind, so — exactly like the RPC mount below — // the relay carries its own CSRF + DNS-rebinding guard, or a page the user merely visited could // POST /_relay/start to spawn an agent (the real device caller sends no Origin and a loopback // Host, so both checks pass it; only a browser's cross-origin/rebound request is turned away). if (pathname === RELAY_PREFIX || pathname.startsWith(`${RELAY_PREFIX}/`)) { if (!guardBrowserOrigin(req, res, host)) return; void handleRelayRequest(req, res, pathname, relayHandlers); return; } if (pathname === RPC_PREFIX || pathname.startsWith(`${RPC_PREFIX}/`)) { void rpcMount(req, res); return; } // The browser preview (#813) is proxied, not an RPC: it is an endless MJPEG body and a // raw input POST, neither of which is a call. It carries the same guard as the RPCs: a raw // /browser/…/input POST steers the agent's Chrome, so a cross-origin or rebound caller must // not reach it (the dashboard's own <img>/fetch is same-origin and passes). if (pathname.startsWith(`${BROWSER_PROXY_PREFIX}/`)) { if (!guardBrowserOrigin(req, res, host)) return; void handleBrowserProxy(req, res) .then(handled => { if (!handled) void serveClientBundle(req, res, clientBundleDir); }) // Whatever the proxy throws must not become an unhandled rejection that kills the // daemon (#938); tear the socket down rather than leave the request hanging. .catch(() => res.destroy()); return; } void serveClientBundle(req, res, clientBundleDir); }); return listenDashboard(server, host, port, async () => { // Stop polling with the server: the poller outlives every agent by design, // so nothing else would ever end it. quota.stop(); await closeServer(server); }); } /** Bind the server and resolve a {@link Dashboard} handle; rejects if the port is already taken. */ function listenDashboard(server, host, port, close) { return new Promise((resolvePromise, rejectPromise) => { server.once('error', rejectPromise); server.listen(port, host, () => { server.removeListener('error', rejectPromise); const address = server.address(); resolvePromise({ url: `http://${host}:${address.port}`, close }); }); }); } function closeServer(server) { // Force-close keep-alive + streaming sockets (e.g. an open /_relay/events body, #1067) so close() resolves instead of waiting on them. server.closeAllConnections(); return new Promise(resolvePromise => server.close(() => resolvePromise())); } /** * The CSRF + DNS-rebinding guard the RPC mount applies, lifted to the routes that dispatch outside * it — the device relay and the browser-preview proxy. Both are state-changing (spawn an agent, * steer its Chrome) and both are wired unconditionally on a loopback bind, where the shared-token * guard is a no-op, so without this a page the user merely visited reaches them. Returns true to * admit the request; on rejection it has already answered 403. */ function guardBrowserOrigin(req, res, host) { if (isSameOriginRequest(req) && isExpectedHost(req, host)) return true; res.writeHead(403, { 'content-type': 'text/plain' }).end('forbidden'); return false; } /** The cookie a bootstrapped browser carries on every same-origin request (#1051). */ const DAEMON_COOKIE = 'fw_daemon'; /** * The non-loopback bind guard (#1051): a request needs a valid `fw_daemon` cookie or a matching * `?token=`, else 401. A valid `?token=` sets the cookie and 302s to the clean path so the token * leaves the URL bar, history, and Referer after one hop; the cookie then rides RPC, the events * Channel, and the MJPEG `<img>` screencast alike (all same-origin), which a bearer header cannot * reach. Returns true to admit the request, false once it has answered (401 or the redirect). */ function authorizeDaemonRequest(req, res, token) { // Safe to re-parse: requestPathname already parsed this same url without throwing (#938). const url = new URL(req.url ?? '/', 'http://localhost'); const queryToken = url.searchParams.get('token'); if (queryToken !== null && tokensMatch(queryToken, token)) { url.searchParams.delete('token'); const query = url.searchParams.toString(); res.writeHead(302, { // Lax, not Strict: the device-hop (#1052) is a cross-origin top-level nav, and a Strict cookie set on it is withheld from the redirect right after, so the clean path 401s. Lax still rides top-level GET navs; CSRF stays covered by the same-origin check on /_rpc. 'set-cookie': `${DAEMON_COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/`, location: url.pathname + (query ? `?${query}` : ''), }); res.end(); return false; } const cookieToken = readCookie(req.headers.cookie, DAEMON_COOKIE); if (cookieToken !== undefined && tokensMatch(cookieToken, token)) return true; res.writeHead(401, { 'content-type': 'text/plain' }); res.end('unauthorized'); return false; } /** Constant-time token compare (#1051). The length check first, since `timingSafeEqual` throws on * unequal-length buffers and a length mismatch cannot be a match anyway. */ function tokensMatch(a, b) { const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && timingSafeEqual(ab, bb); } /** One cookie's value out of a `Cookie` header, or `undefined`. */ function readCookie(header, name) { if (!header) return undefined; for (const part of header.split(';')) { const eq = part.indexOf('='); if (eq !== -1 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); } return undefined; } //# sourceMappingURL=server.js.map