Dewey's round 3 candidate, manifest
agents/dewey/work/queue-40/candidate-manifest-r3.sha256 (d0aa0ded,
27 files, checked OK in the canonical tree).
- WebUI inbox, tasks, agents and trail views, read-only over /api/bus.
The README says the bus proof ends at the Console process.
- CHAT-03 seal: the engine command is fixed, the engine environment is
explicit, SEAL_FLAGS has --no-approve, escalating is cleared on throw.
- Terminal input typed after Ctrl-T or Ctrl-O is held. Only the run whose
own parse set held drains it (T1), and #run catches errors per action.
- DEFERRED keeps N2 and moves F2 to done, citing T1.
Reviews: Filbert approve (comment 27011, rev 260), Darkwing approve
(27013, rev 264). Landing gate on 8cad7722 plus the candidate: webui 22,
conversation 161, control-board 124, every scripts/test-*.sh green,
test-task 98/0. Mutant Mr survives; its flows test is the first
follow-up row.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
124 lines
6.8 KiB
JavaScript
124 lines
6.8 KiB
JavaScript
import { createServer } from 'node:http';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { isIP } from 'node:net';
|
|
import { BusReadError, busStatus, subjectOk } from './bus.mjs';
|
|
|
|
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
|
export function isLoopback(host) {
|
|
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
|
|
}
|
|
export function boardURL(value) {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
|
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
|
|
}
|
|
// Avoid hostname resolution for localhost.
|
|
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
|
|
return url.origin;
|
|
}
|
|
const root = resolve(import.meta.dirname, 'public');
|
|
const files = new Map([
|
|
['/', ['index.html', 'text/html; charset=utf-8']],
|
|
...['app.js', 'brand.js', 'bus.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
|
...['shared/app.css', 'console.css', 'live.css', 'bus.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
|
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
|
]);
|
|
function json(res, status, body) {
|
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
async function body(req) {
|
|
if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new Error('Content-Type must be application/json');
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of req) {
|
|
size += chunk.length;
|
|
if (size > 4096) throw new Error('body larger than 4096 bytes');
|
|
chunks.push(chunk);
|
|
}
|
|
const bytes = Buffer.concat(chunks);
|
|
const value = JSON.parse(bytes.toString('utf8'));
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
|
|
return bytes;
|
|
}
|
|
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
|
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];
|
|
async function busRead(res, bus, verb, search) {
|
|
let subject;
|
|
if (verb === 'trail') {
|
|
subject = new URLSearchParams(search).get('subject');
|
|
if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
|
|
}
|
|
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
|
try {
|
|
const rows = await bus[verb](subject);
|
|
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
|
|
return json(res, 200, { rows, at: new Date().toISOString() });
|
|
} catch (err) {
|
|
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
|
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
|
}
|
|
}
|
|
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null } = {}) {
|
|
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
|
if (host === 'localhost') host = '127.0.0.1';
|
|
const upstream = boardURL(board);
|
|
const server = createServer(async (req, res) => {
|
|
res.setHeader('x-content-type-options', 'nosniff');
|
|
res.setHeader('referrer-policy', 'no-referrer');
|
|
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
|
let path, search;
|
|
try {
|
|
const authority = new URL(`http://${req.headers.host}`);
|
|
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
|
|
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
|
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
|
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
|
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' });
|
|
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
|
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
|
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
|
if (allowed) {
|
|
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
|
|
let bytes;
|
|
if (allowed === 'POST') {
|
|
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
|
}
|
|
try {
|
|
const response = await fetch(upstream + path + (conversation ? search : ''), {
|
|
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
|
|
redirect: 'error', signal: AbortSignal.timeout(timeout),
|
|
});
|
|
const text = await response.text();
|
|
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
|
|
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
return res.end(text);
|
|
} catch {
|
|
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
|
|
}
|
|
}
|
|
if (path.startsWith('/api/bus/')) {
|
|
const verb = path.slice('/api/bus/'.length);
|
|
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
|
|
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
|
return busRead(res, bus, verb, search);
|
|
}
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
|
if (path === '/api/config') return json(res, 200, { board: upstream });
|
|
if (path === '/healthz') return json(res, 200, { ok: true });
|
|
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
|
const file = files.get(path);
|
|
if (!file) return json(res, 404, { error: 'not found' });
|
|
try {
|
|
const content = readFileSync(resolve(root, file[0]));
|
|
res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-store' });
|
|
res.end(req.method === 'HEAD' ? undefined : content);
|
|
} catch { json(res, 500, { error: 'WebUI asset unavailable' }); }
|
|
});
|
|
return new Promise((resolvePromise, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); });
|
|
});
|
|
}
|