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.
177 lines • 8.2 kB
JavaScript
import { hostnameFromHostHeader, isLoopbackHost } from '../loopback-host.js';
import { setDashboardContext } from '../dashboard-rpc/context.js';
import { RPC_HANDLERS, RPC_EVENT_STREAM } from '../dashboard-rpc/index.js';
import { errorMessage } from '../error-message.js';
/**
* CSRF guard for the state-changing RPCs. A browser attaches an `Origin`
* header to every cross-site request, so we reject any POST whose Origin is not this
* same server (or a loopback host) — otherwise a page on `evil.com` could `fetch()` the
* localhost dashboard and spawn/steer an agent. An absent Origin means a non-browser caller
* (curl, the test suite) with no ambient session to abuse, so it passes. Lives here beside
* the mount, its only caller.
*/
export function isSameOriginRequest(req) {
const origin = req.headers.origin;
if (!origin)
return true;
const host = req.headers.host;
if (host && (origin === `http://${host}` || origin === `https://${host}`))
return true;
let hostname;
try {
hostname = new URL(origin).hostname;
}
catch {
return false; // malformed Origin: treat as cross-origin
}
return isLoopbackHost(hostname);
}
/**
* DNS-rebinding guard, the other half of the CSRF check above. A page on `evil.com` whose DNS
* re-answers as `127.0.0.1` is *same-origin* with this server as far as the browser is concerned,
* so its `fetch()` takes the passing branch of {@link isSameOriginRequest} — and every RPC behind
* the mount, `sendStart` included, is reachable from a page the user merely visited.
*
* The `Host` header is what still gives the attacker away: it carries the name the browser was
* asked for (`evil.com`), not the address it resolved to. So when we are bound to loopback, the
* only `Host` a real user's browser can send is a loopback one (or the bound address itself) —
* anything else is a rebound name and is rejected. An absent `Host` is rejected too when we are
* enforcing: HTTP/1.1 requires it, and every browser sends it.
*
* A non-loopback bind (`--host`, #1051) is reached by a hostname we cannot predict, so there is
* no allowlist to check against; that case gates behind the shared daemon token instead. Hosts
* that never pass a bind host at all (the relay, which serves a public domain) are unaffected.
*/
export function isExpectedHost(req, boundHost) {
if (boundHost === undefined || !isLoopbackHost(boundHost))
return true;
const header = req.headers.host;
if (!header)
return false;
const hostname = hostnameFromHostHeader(header);
return isLoopbackHost(hostname) || hostname === boundHost;
}
/** Where the dashboard's RPCs live. One prefix, so the static handler can decline it by path. */
export const RPC_PREFIX = '/_rpc';
/** Read a request body, bounded so a bad caller cannot make the daemon buffer without limit. */
async function readBody(req, limit = 4 * 1024 * 1024) {
let size = 0;
const chunks = [];
for await (const chunk of req) {
const buf = chunk;
size += buf.length;
if (size > limit)
throw new Error('request body too large');
chunks.push(buf);
}
return Buffer.concat(chunks).toString('utf8');
}
function sendJson(res, status, body) {
const text = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(text);
}
/**
* Stream an agent's events as Server-Sent Events (#405). One JSON value per `data:` line; the
* response ending IS the clean close the client distinguishes from a dropped connection.
*
* This was a Telefunc Channel. The Channel gave serialization, reconnect and typing over a
* WebSocket-shaped abstraction; what the dashboard actually uses is "push me lines until I go
* away", and it brought its own reconnect-with-backoff on top (#948/#1383) because the Channel's
* did not distinguish a dead daemon from a finished stream.
*/
async function serveEventStream(req, res, url) {
const projectId = url.searchParams.get('projectId') ?? '';
const agentId = url.searchParams.get('agentId') ?? undefined;
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-store',
connection: 'keep-alive',
// The daemon is behind nothing, but a proxy in front of a `--host` bind would otherwise buffer.
'x-accel-buffering': 'no',
});
let finished = false;
const stop = await RPC_EVENT_STREAM(projectId, agentId, value => {
res.write(`data: ${JSON.stringify(value)}\n\n`);
}, () => {
finished = true;
res.end();
});
if (!stop) {
// Nothing to stream (unknown project): end cleanly, which the client reads as "done", not
// "lost" — the same distinction the Channel's clean-vs-errored close carried.
res.end();
return;
}
if (finished)
return; // the source was exhausted before it was even wired
const finish = () => {
stop();
res.end();
};
req.on('close', finish);
req.on('error', finish);
}
/**
* Mount the dashboard's RPC surface (#405) on the daemon's `node:http` server: `POST /_rpc/<name>`
* for the calls, `GET /_rpc/events` for the live stream. It runs in the daemon process, so
* `sendStart` reaches the daemon's own `startAgent` through the wired {@link DashboardContext}.
*
* Cross-origin POSTs are rejected (CSRF: a page on evil.com must not steer or start a session), as
* are requests carrying someone else's `Host` when we are bound to loopback (DNS rebinding: the
* same page must not reach us by pointing its own name at `127.0.0.1`). Pass `opts.host` — the
* address the server is bound to — to enable that second check. Returns whether the request was
* the RPC surface's.
*
* This replaced Telefunc (F3), which required a build-time transform over every `.telefunc.ts`
* file, a registration table pinning each RPC to the client-baked key of the *dashboard* source
* path it was re-exported from, and a request-context indirection for wiring that never varied
* per request. What it bought over this was type-safety across a package boundary that A7 removed.
*/
export function makeRpcMount(context, opts = {}) {
setDashboardContext(context);
return async (req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
if (url.pathname !== RPC_PREFIX && !url.pathname.startsWith(`${RPC_PREFIX}/`))
return false;
if (!isSameOriginRequest(req)) {
res.writeHead(403, { 'content-type': 'text/plain' });
res.end('cross-origin request forbidden');
return true;
}
if (!isExpectedHost(req, opts.host)) {
res.writeHead(403, { 'content-type': 'text/plain' });
res.end('unexpected Host header');
return true;
}
const name = url.pathname.slice(RPC_PREFIX.length + 1);
try {
if (req.method === 'GET' && name === 'events') {
await serveEventStream(req, res, url);
return true;
}
const handler = RPC_HANDLERS[name];
if (req.method !== 'POST' || !handler) {
sendJson(res, 404, { error: `no such RPC: ${name}` });
return true;
}
const raw = await readBody(req);
const args = raw ? JSON.parse(raw) : [];
if (!Array.isArray(args)) {
sendJson(res, 400, { error: 'the request body must be a JSON array of arguments' });
return true;
}
sendJson(res, 200, { ret: await handler(...args) });
}
catch (err) {
// An RPC that throws is a failed call, not a dead daemon: answer it and stay up. Without
// this a rejected promise inside the mount became an unhandled rejection.
if (!res.headersSent)
sendJson(res, 500, { error: errorMessage(err) });
else
res.end();
}
return true;
};
}
//# sourceMappingURL=rpc-serve.js.map