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
JavaScript
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