Jason's Q168: a visual editor for the Console's project documents, held to row 148's rendering rules. Library. ProseMirror with prosemirror-markdown and markdown-it, chosen in the phase 1 library report (LIBRARY.md, REPORT.md in the packet). packages/webui/third_party/editor/ holds 19 upstream packages as source, byte for byte, each with its license, plus NOTICE and PROVENANCE.json. Build. packages/webui/editor/ holds the model, view, entry and build.mjs. esbuild 0.28.2 is an exact devDependency of packages/webui with its own lock; editor/toolchain.mjs checks the version and the binary's sha256 before any build. MOSAIC_REQUIRE_EDITOR_BUILD=1 turns a missing or different esbuild into a failure, and the rebuild test then checks the committed src/public/editor.js and editor.css match a fresh build. The built bundle is committed under Mos's conditions (mos-0755, mos-0765). Console. The docs view gets Visual and Source modes. A save still writes Markdown through the existing project write path, with the same stale-base and read-only handling. A mode switch reuses the draft and reads nothing from the project. Links and images keep row 148's target rules, and no raw HTML runs. serve.mjs serves /editor.js and /editor.css; index.html loads them. Owner Researcher, brief docs/plans/2026-10-16_q168-wysiwyg-editor.md. Round 5 is a rebase ontod88613bac(row 155); only docs/TOOLS.md, webui/README.md, public/bus.js and serve.mjs differ from the round before. Reviewers on candidate manifest 3a4bc21c...3b05: Dewey (security) approve 29027, Filbert approve 29026. Mos's decision 123 go for public.diff 902f0244 (handwritten part ea59079e): mos-0824. Sage gate on72c3fa598with MOSAIC_REQUIRE_EDITOR_BUILD=1: GATE_PASSED 25/25 (node 2397, selftests 422), 22:07:29Z to 22:13:50Z; the rebuild test ran and passed. Nothing live.
112 lines
7.6 KiB
JavaScript
112 lines
7.6 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createServer, get } from 'node:http';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer, boardURL, isLoopback } from '../src/serve.mjs';
|
|
import { fixture, close } from './fixture.mjs';
|
|
|
|
const post = (base, path, body, extra = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...extra }, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
|
|
|
test('loopback host and board origin fail closed', async () => {
|
|
for (const h of ['127.0.0.1', '127.2.3.4', '::1', 'localhost']) assert.equal(isLoopback(h), true);
|
|
for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
|
|
assert.equal(isLoopback(h), false);
|
|
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/);
|
|
}
|
|
for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
|
|
assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
|
|
assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
|
|
});
|
|
|
|
test('real board fixture passes through WebUI; assets and isolated seen/reply work', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
assert.equal(f.web.address().address, '127.0.0.1');
|
|
const data = await (await fetch(f.base + '/api/board')).json();
|
|
assert.equal(data.sessions.length, 4);
|
|
assert.equal(data.sessions.filter(r => r.waitingOnYou).length, 2);
|
|
assert.deepEqual(data.counts, { working: 1, waiting: 1, error: 1, offline: 1, idle: 0, unknown: 0 });
|
|
const r = data.sessions.find(r => r.agent === 'agent1');
|
|
const seen = await post(f.base, '/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: true });
|
|
assert.equal(seen.status, 200); assert.equal((await seen.json()).seen.length, 1);
|
|
const reply = await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'fixture reply' });
|
|
assert.equal((await reply.json()).delivered, true); assert.equal(f.captures.length, 1);
|
|
assert.match(f.captures[0].args.at(-1), /^fixture reply\n\(control-board:/);
|
|
for (const path of ['/', '/app.js', '/brand.js', '/console.css', '/live.css', '/editor.js', '/editor.css', '/shared/app.css', '/assets/fonts/manrope-400.woff2']) {
|
|
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
|
|
}
|
|
// #1542: the shell's tokens, sprite, stylesheet and mono fonts, each with its type.
|
|
for (const [path, type] of [['/tokens.css', 'text/css'], ['/shell.css', 'text/css'], ['/icons.svg', 'image/svg+xml'], ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2'])]) {
|
|
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.ok(response.headers.get('content-type').startsWith(type), path);
|
|
}
|
|
assert.equal((await fetch(f.base + '/../../AGENTS.md')).status, 404);
|
|
assert.equal((await fetch(f.base + '/api/reply')).status, 405);
|
|
assert.equal((await fetch(f.base + '/api/board', { method: 'OPTIONS' })).status, 405);
|
|
assert.equal((await post(f.base, '/api/reply', { agent: 'proj/agent1', text: 'blocked' }, { origin: 'https://evil.example' })).status, 403);
|
|
assert.equal(f.captures.length, 1);
|
|
const hostileHost = await new Promise((resolve, reject) => {
|
|
get(f.base + '/api/board', { headers: { host: 'evil.example' } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject);
|
|
});
|
|
assert.equal(hostileHost, 403);
|
|
} finally { await f.close(); }
|
|
});
|
|
|
|
test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => {
|
|
const requests = [];
|
|
const upstream = createServer(async (req, res) => {
|
|
let body = ''; for await (const c of req) body += c;
|
|
requests.push({ url: req.url, method: req.method, body });
|
|
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
|
|
if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
|
|
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
|
|
});
|
|
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
|
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}` });
|
|
const base = `http://127.0.0.1:${web.address().port}`;
|
|
try {
|
|
for (const path of ['/api/reply', '/api/seen']) {
|
|
const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }';
|
|
const res = await post(base, path, raw);
|
|
assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
|
|
assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
|
|
}
|
|
for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) {
|
|
try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
|
|
}
|
|
assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
|
|
assert.equal(requests.length, 2);
|
|
assert.equal((await fetch(base + '/api/board')).status, 502);
|
|
assert.equal(requests.length, 3);
|
|
// CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
|
|
for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
|
|
const res = await fetch(base + path);
|
|
assert.equal(res.status, 404, path);
|
|
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
|
|
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
|
|
}
|
|
const before = requests.length;
|
|
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
|
|
assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
|
|
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
|
|
assert.equal(requests.length, before, 'refused before the board');
|
|
// Only the conversation routes carry a query upstream.
|
|
await fetch(base + '/api/board?x=1');
|
|
assert.equal(requests.at(-1).url, '/api/board');
|
|
} finally { await close(web); await close(upstream); }
|
|
});
|
|
|
|
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
|
|
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
|
|
const url = `http://127.0.0.1:${probe.address().port}`; await close(probe);
|
|
const web = await startServer({ port: 0, board: url, timeout: 100 });
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
|
|
assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
|
|
} finally { await close(web); }
|
|
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) {
|
|
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../src/cli.mjs', import.meta.url)), ...args], { encoding: 'utf8' });
|
|
assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
|
|
}
|
|
});
|