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.

322 lines 14.9 kB
import { timingSafeEqual } from 'node:crypto'; /** * The browser bridge (#1237): the one endpoint an extension running in the user's own Claude * session posts to, so a question a cloud agent is parked on becomes visible in the dashboard. * * **Why this route carries its own token, unlike every other one.** The #1051 guard in * `startDashboard` only exists on a non-loopback bind, and what protects a loopback daemon is * the same-origin check on `/_rpc`: a page on another origin is refused outright. This * route is the first that is *meant* to be reached from another origin, so neither of those * protects it, and it demands `Authorization: Bearer <daemonToken>` unconditionally instead. * * **No CORS headers, on purpose.** An extension's service worker holding `host_permissions` * fetches without a preflight, so the bridge does not need `Access-Control-Allow-Origin` and * must not have it: a wildcard here would let any page the user visits post to their daemon. * The cost is that the extension has to post from its background worker rather than from the * content script, which is a line in the extension and a much better trade. * * **What it accepts is deliberately tiny.** One shape, fully validated, with no path, command, * prompt or free text anywhere in it. The worst a stolen token buys is a bogus question card in * someone's dashboard, which is the point: this is attached to a daemon that spawns processes. */ export const BRIDGE_PREFIX = '/_bridge'; /** * The extension version this daemon speaks (#1519). The extension states its own version on * every call, in this header, and a daemon expecting another refuses outright: a version-skewed * extension does not fail loudly, it half-works — missed messages, silently ignored fields — * which reads as a framework bug and burns a debugging session. The extension's manifest must * carry the same number; a test keeps the two in lockstep. */ export const EXPECTED_EXTENSION_VERSION = '0.8.1'; /** The header the extension states its version in. Lowercase, as node presents all headers. */ export const EXTENSION_VERSION_HEADER = 'x-tf-extension-version'; const MAX_BODY = 64 * 1024; const SESSION_ID = /^session_[A-Za-z0-9]{1,128}$/; const MAX_TITLE = 500; const MAX_LABEL = 300; const MAX_DETAIL = 500; const MAX_OPTIONS = 20; const MAX_EVENTS_BODY = 512 * 1024; const MAX_EVENT_BATCH = 50; const MAX_EVENT_TEXT = 8000; const MAX_SEQ = 10_000; /** Route a `/_bridge/*` request. A daemon with the bridge off 404s every route. */ export async function handleBridgeRequest(req, res, pathname, handlers) { if (!handlers) return end(res, 404, 'bridge not enabled'); seen(handlers, pathname, res); if (!authorized(req, handlers.token)) return end(res, 401, 'unauthorized'); // The version gate (#1519), behind the token so an unauthenticated caller learns nothing. if (handlers.expectedExtensionVersion !== undefined) { const raw = req.headers[EXTENSION_VERSION_HEADER]; const got = (typeof raw === 'string' && raw ? raw : 'unknown').slice(0, 32); const blocked = got !== handlers.expectedExtensionVersion; handlers.extensionVersion?.(got, blocked); if (blocked) { return end(res, 426, `extension v${got} does not match the v${handlers.expectedExtensionVersion} this daemon expects: update the extension (pull the repo, then reload it at chrome://extensions) and retry`); } } if (pathname === `${BRIDGE_PREFIX}/ping`) { if (req.method !== 'GET') return end(res, 405, 'method not allowed', { allow: 'GET' }); return end(res, 200, 'ok'); } if (pathname === `${BRIDGE_PREFIX}/question`) return handleQuestion(req, res, handlers); if (pathname === `${BRIDGE_PREFIX}/sessions`) return handleAgents(req, res, handlers); if (pathname === `${BRIDGE_PREFIX}/events`) return handleEvents(req, res, handlers); if (pathname === `${BRIDGE_PREFIX}/hello`) return handleHello(req, res, handlers); if (pathname === `${BRIDGE_PREFIX}/answer`) return handleAnswer(req, res, handlers); if (pathname === `${BRIDGE_PREFIX}/answered`) return handleAnswered(req, res, handlers); end(res, 404, 'not found'); } /** * `Authorization: Bearer <token>`, compared in constant time. Rejects before the body is read, * so an unauthenticated caller cannot make the daemon buffer anything. */ function authorized(req, token) { const header = req.headers.authorization; if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false; const given = Buffer.from(header.slice('Bearer '.length)); const expected = Buffer.from(token); // timingSafeEqual throws on a length mismatch, which would itself leak the length. if (given.length !== expected.length) return false; return timingSafeEqual(given, expected); } /** `POST /_bridge/question`: validate hard, record, answer 204. */ async function handleQuestion(req, res, handlers) { if (req.method !== 'POST') return end(res, 405, 'method not allowed', { allow: 'POST' }); let body; try { body = await readJsonBody(req, MAX_BODY); } catch (err) { return end(res, 400, err.message); } const question = validate(body, (handlers.now ?? (() => new Date()))()); if (typeof question === 'string') return end(res, 400, question); handlers.record(question); end(res, 204, ''); } /** * Every field checked, with a reason on rejection. Unknown keys are dropped rather than * refused, so a newer extension posting an extra field still works against an older daemon. */ function validate(body, now) { if (typeof body !== 'object' || body === null) return 'body must be an object'; const raw = body; const sessionId = raw.sessionId; if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return 'sessionId must look like session_<id>'; const title = raw.title; if (typeof title !== 'string' || !title.trim() || title.length > MAX_TITLE) return `title must be a string of 1 to ${MAX_TITLE} characters`; if (!Array.isArray(raw.options) || raw.options.length === 0) return 'options must be a non-empty array'; if (raw.options.length > MAX_OPTIONS) return `options must hold at most ${MAX_OPTIONS} entries`; const options = []; for (const entry of raw.options) { if (typeof entry !== 'object' || entry === null) return 'each option must be an object'; const { label, detail } = entry; if (typeof label !== 'string' || !label.trim() || label.length > MAX_LABEL) return `each option needs a label of 1 to ${MAX_LABEL} characters`; if (detail !== undefined && (typeof detail !== 'string' || detail.length > MAX_DETAIL)) return `an option detail must be a string of at most ${MAX_DETAIL} characters`; options.push({ label, ...(typeof detail === 'string' && detail ? { detail } : {}) }); } const recommended = raw.recommended; if (recommended !== undefined && (typeof recommended !== 'string' || recommended.length > MAX_LABEL)) { return 'recommended must be a string naming one of the option labels'; } // A recommendation that names no option would render a default the user cannot see. if (typeof recommended === 'string' && recommended && !options.some(o => o.label === recommended)) { return 'recommended must match one of the option labels'; } return { sessionId, title, options, ...(typeof recommended === 'string' && recommended ? { recommended } : {}), receivedAt: now.toISOString(), }; } /** * `POST /_bridge/hello`: what the page half of the bridge is doing. * * Diagnosis kept needing a screenshot of a panel, which meant every wrong guess cost a round * trip through a person. This lets the extension say for itself which version is injected and * what its last scrape found, so the daemon can be asked instead. */ async function handleHello(req, res, handlers) { if (req.method !== 'POST') return end(res, 405, 'method not allowed', { allow: 'POST' }); let body; try { body = await readJsonBody(req, MAX_BODY); } catch (err) { return end(res, 400, err.message); } const raw = (typeof body === 'object' && body !== null ? body : {}); handlers.hello?.({ version: typeof raw.version === 'string' ? raw.version.slice(0, 32) : 'unknown', sessionId: typeof raw.sessionId === 'string' && SESSION_ID.test(raw.sessionId) ? raw.sessionId : undefined, note: typeof raw.note === 'string' ? raw.note.slice(0, 300) : '', at: (handlers.now ?? (() => new Date()))().toISOString(), }); end(res, 204, ''); } /** `POST /_bridge/events`: record a batch of transcript entries. */ async function handleEvents(req, res, handlers) { if (req.method !== 'POST') return end(res, 405, 'method not allowed', { allow: 'POST' }); if (!handlers.recordEvent) return end(res, 404, 'events not enabled'); let body; try { body = await readJsonBody(req, MAX_EVENTS_BODY); } catch (err) { return end(res, 400, err.message); } const events = validateEvents(body, (handlers.now ?? (() => new Date()))()); if (typeof events === 'string') return end(res, 400, events); for (const event of events) handlers.recordEvent(event); end(res, 204, ''); } /** * A batch, validated entry by entry. Rejects the whole batch on a bad entry rather than taking * the good ones: a partial accept would leave gaps in the sequence the reader cannot distinguish * from a message that has not arrived yet. */ function validateEvents(body, now) { if (typeof body !== 'object' || body === null) return 'body must be an object'; const raw = body; const sessionId = raw.sessionId; if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return 'sessionId must look like session_<id>'; if (!Array.isArray(raw.events) || raw.events.length === 0) return 'events must be a non-empty array'; if (raw.events.length > MAX_EVENT_BATCH) return `events must hold at most ${MAX_EVENT_BATCH} entries`; const out = []; for (const entry of raw.events) { if (typeof entry !== 'object' || entry === null) return 'each event must be an object'; const { seq, role, text } = entry; if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0 || seq > MAX_SEQ) return 'each event needs an integer seq'; if (role !== 'agent' && role !== 'user') return 'each event needs a role of agent or user'; if (typeof text !== 'string' || !text.trim()) return 'each event needs text'; out.push({ sessionId, seq, role, text: text.slice(0, MAX_EVENT_TEXT), receivedAt: now.toISOString() }); } return out; } /** * `GET /_bridge/answer?sessionId=...`: the answer the dashboard queued for that session (#1237). * * Always 200 with `{answer: ...}`, null when there is nothing to deliver, so the extension can * poll it blindly. Degrades to null on a daemon that wired no answer source, same as `sessions`. */ async function handleAnswer(req, res, handlers) { if (req.method !== 'GET') return end(res, 405, 'method not allowed', { allow: 'GET' }); const sessionId = new URL(req.url ?? '', 'http://bridge.invalid').searchParams.get('sessionId'); if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return end(res, 400, 'sessionId must look like session_<id>'); const answer = handlers.answer?.(sessionId); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ answer: answer ?? null })); } /** `POST /_bridge/answered`: what the extension's delivery attempt did. */ async function handleAnswered(req, res, handlers) { if (req.method !== 'POST') return end(res, 405, 'method not allowed', { allow: 'POST' }); let body; try { body = await readJsonBody(req, MAX_BODY); } catch (err) { return end(res, 400, err.message); } if (typeof body !== 'object' || body === null) return end(res, 400, 'body must be an object'); const { sessionId, id, ok, note } = body; if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return end(res, 400, 'sessionId must look like session_<id>'); if (typeof id !== 'string' || !id || id.length > 64) return end(res, 400, 'id must be the answer id'); if (typeof ok !== 'boolean') return end(res, 400, 'ok must be a boolean'); if (note !== undefined && typeof note !== 'string') return end(res, 400, 'note must be a string'); handlers.answered?.(sessionId, id, ok, typeof note === 'string' ? note.slice(0, 300) : undefined); end(res, 204, ''); } /** * `GET /_bridge/sessions`: the cloud sessions the extension should have a tab for. * * Answers an empty list rather than a 404 when the daemon wired no lister, so an extension * polling an older daemon degrades to doing nothing instead of reporting a fault. */ async function handleAgents(req, res, handlers) { if (req.method !== 'GET') return end(res, 405, 'method not allowed', { allow: 'GET' }); const sessions = handlers.sessions ? await handlers.sessions().catch(() => []) : []; res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ sessions })); } /** Read a JSON body, refusing anything past the cap rather than buffering it. */ function readJsonBody(req, maxBytes) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (chunk) => { size += chunk.length; if (size > maxBytes) { reject(new Error('body too large')); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(new Error('body must be JSON')); } }); req.on('error', () => reject(new Error('read failed'))); }); } function end(res, status, message, headers = {}) { res.writeHead(status, { 'content-type': 'text/plain', ...headers }); res.end(message); } /** Wrap a route so its outcome is recorded whatever it was. */ function seen(handlers, pathname, res) { if (!handlers.contact) return; res.once('finish', () => handlers.contact?.(pathname, res.statusCode)); } //# sourceMappingURL=bridge-endpoints.js.map