// Row 76 (#1570, lead decision 95): `serve --send`, a human message to a role from the Console. // Only with the preview flags; POST /api/bus/message needs the header on this request, its own // Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker // refuses `to` and the body. An outcome the transport can't confirm is unknown and never retried. // Every other write is still 403 read-only. Nothing here starts the real human transport. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createServer, request } from 'node:http'; import { spawn, spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { startServer } from '../src/serve.mjs'; import { BusReadError, humanCall } from '../src/bus.mjs'; import { close } from './fixture.mjs'; import { broker, fixture, tmp } from '../../cli/tests/helpers.mjs'; const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason'; const preview = { host: NAME, header: HEADER, user: ID }; const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name)); const msg = JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }); // node:http, not fetch: fetch can't set Host or Sec-Fetch-Site. function post(port, { method = 'POST', path = '/api/bus/message', headers = ok, body = msg } = {}) { return new Promise((resolve, reject) => { const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) }; const req = request({ host: '127.0.0.1', port, method, path, headers: { host: NAME, ...length, ...headers } }, res => { let text = ''; res.on('data', c => (text += c)); res.on('end', () => resolve({ status: res.statusCode, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text })); }); req.on('error', reject); req.end(body); }); } // A board that counts what reaches it, a bus that counts its reads, and a send that records // each call and answers from `answer`. async function setup(t, options = {}) { const boardHits = []; const upstream = createServer((req, res) => { boardHits.push(`${req.method} ${req.url}`); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); }); await new Promise(r => upstream.listen(0, '127.0.0.1', r)); t.after(() => close(upstream)); const busCalls = [], sends = [], lines = []; const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }])); const state = { answer: async () => ({ id: 'msg-0001' }) }; const send = async args => { sends.push(args); return state.answer(args); }; const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, send, log: l => lines.push(l), ...options }); t.after(() => close(web)); return { port: web.address().port, boardHits, busCalls, sends, lines, state }; } test('startServer takes send only as a function and only with the preview mode', async () => { for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: null }, { send: 'yes', preview }, { send: {}, preview }]) { const result = await startServer({ port: 0, ...options }).catch(err => err); if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${Object.keys(options)}`); } assert.match(result.message, /--send needs the preview mode/); } }); test('the CLI refuses --send without the preview flags, and without a configured bus', t => { const root = tmp(t, 'webui-send-cli-'); const env = { ...process.env, MOSAIC_CONFIG: join(root, 'no-such-config.json') }; for (const [flags, why] of [ [['--send'], /^refused: --send needs the preview mode/], [['--send', '--public-host', NAME, '--auth-header', HEADER], /^refused: preview mode needs/], [['--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], /^refused: --send needs the bus configured \(/], [['--send', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID], /^refused: --send needs the bus configured \(/], ]) { const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000, env }); assert.equal(result.status, 2, flags.join(' ')); assert.match(result.stderr, why, flags.join(' ')); assert.equal(result.stderr.includes(root), false, 'the refusal names the config path'); assert.equal(result.stdout, ''); } }); test('the CLI with --send and a configured bus sends through the human transport and logs one line', async t => { // No bus host is running, so the send is a definite refusal: no-bus-host, nothing written. const root = tmp(t, 'webui-send-cli-'); const { env } = fixture(root); const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); t.after(() => child.kill()); let out = ''; const port = await new Promise((resolve, reject) => { child.stdout.on('data', c => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); }); child.on('exit', code => reject(new Error(`exited ${code}: ${out}`))); }); assert.match(out, new RegExp(`^Preview: https://${NAME}/ through the proxy, signed-in user from ${HEADER}, reads and human messages to roles \\(--send\\); one line per send below$`, 'm')); const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'secret-body-text' }) }); assert.equal(r.status, 503); assert.deepEqual(r.body, { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false }); const SEND = /^\d{4}-\d\d-\d\dT[\d:.]+Z send /m; for (let i = 0; i < 100 && !SEND.test(out); i++) await new Promise(res => setTimeout(res, 20)); const sendLines = out.split('\n').filter(l => SEND.test(l)); assert.equal(sendLines.length, 1, out); assert.match(sendLines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" error=no-bus-host$/); assert.equal(out.includes('secret-body-text'), false); }); test('without --send the route is read-only like every other write, and config says no send', async t => { const { port, sends, lines } = await setup(t, { send: null }); const r = await post(port); assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }); const config = await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined }); assert.equal(config.body.send, false); assert.deepEqual(sends, []); assert.deepEqual(lines, []); }); test('with --send every other write is still 403 read-only, and nothing reaches the board or bus', async t => { const { port, sends, lines, boardHits, busCalls } = await setup(t); for (const [method, path] of [['POST', '/api/seen'], ['POST', '/api/reply'], ['POST', '/api/bus/inbox'], ['POST', '/api/bus/message/x'], ['POST', '/api/bus/messages'], ['POST', '/'], ['PUT', '/api/seen'], ['DELETE', '/api/reply'], ['PATCH', '/api/bus/inbox']]) { const r = await post(port, { method, path }); assert.equal(r.status, 403, `${method} ${path}`); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }, `${method} ${path}`); } assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]); }); test('config says send only on the public name with --send on', async t => { const { port } = await setup(t); assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined })).body.send, true); assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { host: `127.0.0.1:${port}` }, body: undefined })).body.send, false); }); test('a send without the signed-in user is 401 and is neither sent nor logged, loopback included', async t => { const { port, sends, lines } = await setup(t); for (const [why, headers] of [ ['no header', without(HEADER)], ['another user', { ...ok, [HEADER]: 'mallory' }], ['the id with a suffix', { ...ok, [HEADER]: ID + 'x' }], ['two values', { ...ok, [HEADER]: [ID, ID] }], ['loopback without the header', { ...without(HEADER), host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}` }], ['localhost, no Origin, no header', { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', host: `localhost:${port}` }], ]) { const r = await post(port, { headers }); assert.equal(r.status, 401, why); assert.deepEqual(r.body, { error: 'sign-in required' }, why); } // Loopback with the header is a proxy that rewrote Host: refused before the route (row 74). const rewritten = await post(port, { headers: { ...ok, host: `127.0.0.1:${port}` } }); assert.equal(rewritten.status, 403); assert.equal(rewritten.body.error, 'proxy Host refused'); assert.deepEqual(sends, []); assert.deepEqual(lines, []); }); test('each request check refuses before anything is sent, and logs the refusal without the body', async t => { const { port, sends, lines } = await setup(t); const cases = [ ['no Origin', { headers: without('origin') }, 403, 'cross-origin request refused'], ['no Sec-Fetch-Site', { headers: without('sec-fetch-site') }, 403, 'cross-site request refused'], ...['cross-site', 'same-site', 'none', 'Same-Origin', 'same-origin, same-origin'].map(v => [`Sec-Fetch-Site ${v}`, { headers: { ...ok, 'sec-fetch-site': v } }, 403, 'cross-site request refused']), ['two Sec-Fetch-Site', { headers: { ...ok, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'], ['no Content-Type', { headers: without('content-type') }, 400, 'invalid-request'], ['text/plain', { headers: { ...ok, 'content-type': 'text/plain' } }, 400, 'invalid-request'], ['form', { headers: { ...ok, 'content-type': 'application/x-www-form-urlencoded' }, body: 'to=coder&body=hi' }, 400, 'invalid-request'], ['oversize', { body: JSON.stringify({ to: 'coder', body: 'x'.repeat(16384) }) }, 400, 'invalid-request'], ['an extra key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', class: 'REQUEST' }) }, 400, 'invalid-request'], ['the business key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', business: 'other' }) }, 400, 'invalid-request'], ['no to', { body: JSON.stringify({ body: 'secret-body-text' }) }, 400, 'invalid-request'], ['no body', { body: JSON.stringify({ to: 'coder' }) }, 400, 'invalid-request'], ['not JSON', { body: '{"to":' }, 400, 'invalid-request'], ['null', { body: 'null' }, 400, 'invalid-request'], ['an array', { body: '["coder","hi"]' }, 400, 'invalid-request'], ['a string', { body: '"hi"' }, 400, 'invalid-request'], ]; for (const [why, req, status, error] of cases) { const r = await post(port, req); assert.equal(r.status, status, why); assert.equal(r.body.error, error, why); assert.equal(r.body.sent, false, why); } // An Origin that isn't this one is refused by the shared check, before the route. for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, 'null']) { const r = await post(port, { headers: { ...ok, origin } }); assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }, origin); } assert.equal((await post(port, { method: 'GET', body: undefined })).status, 405); assert.equal((await post(port, { method: 'PUT' })).status, 405); assert.deepEqual(sends, []); assert.equal(lines.length, cases.length); for (const l of lines) assert.match(l, /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to=("coder"|none) error=[a-z -]+$/, l); assert.equal(lines.some(l => l.includes('secret-body-text')), false); // 16 KiB fits: the body limit is the broker's beyond that. const fits = JSON.stringify({ to: 'coder', body: 'x'.repeat(16384 - JSON.stringify({ to: 'coder', body: '' }).length) }); assert.equal(Buffer.byteLength(fits), 16384); assert.equal((await post(port, { body: fits })).status, 200); }); test('a send passes exactly to, body and in_reply_to, answers the id, and logs it', async t => { const { port, sends, lines, boardHits, busCalls } = await setup(t); const r = await post(port, { body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) }); assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-0001'); assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/); assert.deepEqual(Object.keys(r.body), ['id', 'at']); const reply = await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }) }); assert.equal(reply.status, 200); // Values go to the broker as given, whatever their type: it is the one that refuses them. assert.equal((await post(port, { body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200); assert.equal((await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: '' }) })).status, 200); assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }, { to: 'pm', body: 'yes', in_reply_to: '' }]); assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body'], ['to', 'body', 'in_reply_to']]); assert.equal(lines.length, 4); assert.match(lines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" id=msg-0001$/); assert.match(lines[2], / to=none id=msg-0001$/); assert.equal(lines.some(l => l.includes('secret-body-text')), false); assert.deepEqual(boardHits, []); assert.deepEqual(busCalls, []); }); test('a crafted role name cannot forge a log line', async t => { const { port, lines, state } = await setup(t); state.answer = async () => { throw new BusReadError('unknown-role'); }; const to = 'coder error=x\n2026-01-01T00:00:00.000Z send user="jason" to="pm" id=forged' + 'y'.repeat(100); await post(port, { body: JSON.stringify({ to, body: 'hi' }) }); assert.equal(lines.length, 1); assert.equal(lines[0].includes('\n'), false); assert.match(lines[0], / error=unknown-role$/); assert.ok(lines[0].includes(` to=${JSON.stringify(to.slice(0, 64))} error=`)); // The cut is at 64 characters: a 65-character role loses its last one. await post(port, { body: JSON.stringify({ to: 'r'.repeat(64) + 's', body: 'hi' }) }); assert.match(lines[1], new RegExp(` to="${'r'.repeat(64)}" error=unknown-role$`)); }); test('a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', async t => { const { port, sends, lines, state } = await setup(t); for (const [answer, status, error, sent] of [ [new BusReadError('unknown-role'), 400, 'unknown-role', false], [new BusReadError('invalid-request'), 400, 'invalid-request', false], [new BusReadError('message-not-found'), 400, 'message-not-found', false], [new BusReadError('request-too-large'), 400, 'request-too-large', false], [new BusReadError('human-required'), 403, 'human-required', false], [new BusReadError('read-only'), 403, 'read-only', false], [new BusReadError('no-bus-host'), 503, 'no-bus-host', false], [new BusReadError('something-new'), 502, 'something-new', false], [new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], [new BusReadError('invalid-response'), 503, 'outcome-unknown', 'unknown'], [new BusReadError('response-too-large'), 503, 'outcome-unknown', 'unknown'], [Object.assign(new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), { code: 'ENOENT' }), 503, 'outcome-unknown', 'unknown'], [{ id: '' }, 503, 'outcome-unknown', 'unknown'], [null, 503, 'outcome-unknown', 'unknown'], [{ id: '../x' }, 503, 'outcome-unknown', 'unknown'], ]) { const before = sends.length; state.answer = async () => { if (answer instanceof Error) throw answer; return answer; }; const r = await post(port); const label = answer instanceof Error ? answer.code ?? answer.message : JSON.stringify(answer); assert.equal(sends.length, before + 1, `${label}: sent ${sends.length - before} times`); assert.equal(r.status, status, label); assert.equal(r.body.error, error, label); assert.equal(r.body.sent, sent, label); assert.doesNotMatch(JSON.stringify(r.body), /home|mosaic-dev|\.sock/); if (sent === 'unknown') assert.match(r.body.message, /may have been sent; nothing was retried|may have been sent/, label); assert.match(lines.at(-1), new RegExp(` error=${error}$`), label); } }); // The same protocol as the real transport: a script standing in for human-cli.mjs. const FAKE = ` import { readFileSync, appendFileSync } from 'node:fs'; const request = JSON.parse(readFileSync(0, 'utf8')); appendFileSync(process.env.FAKE_LOG, JSON.stringify(request) + '\\n'); if (request.args.to === 'nobody') { process.stderr.write('unknown-role\\n'); process.exit(2); } if (request.args.to === 'crash') process.exit(1); process.stdout.write(JSON.stringify({ id: 'msg-fake-1', request: 'req-1' })); `; test('through the human transport the request is exactly the business, message.send and the keys', async t => { const root = tmp(t, 'webui-send-transport-'); const cli = join(root, 'fake-human-cli.mjs'), log = join(root, 'requests.jsonl'); writeFileSync(cli, FAKE); const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, env: { ...process.env, FAKE_LOG: log }, timeoutMs: 5000 }); const { port, lines } = await setup(t, { send: args => call('message.send', args) }); const r = await post(port); assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-fake-1'); const refused = await post(port, { body: JSON.stringify({ to: 'nobody', body: 'hi' }) }); assert.deepEqual([refused.status, refused.body.error, refused.body.sent], [400, 'unknown-role', false]); const crashed = await post(port, { body: JSON.stringify({ to: 'crash', body: 'hi' }) }); assert.deepEqual([crashed.status, crashed.body.error, crashed.body.sent], [503, 'outcome-unknown', 'unknown']); const { readFileSync } = await import('node:fs'); const requests = readFileSync(log, 'utf8').trim().split('\n').map(l => JSON.parse(l)); assert.deepEqual(requests, [ { business: 'demo', verb: 'message.send', args: { to: 'coder', body: 'Please look at row 76.' } }, { business: 'demo', verb: 'message.send', args: { to: 'nobody', body: 'hi' } }, { business: 'demo', verb: 'message.send', args: { to: 'crash', body: 'hi' } }, ]); assert.equal(lines.length, 3); }); test('against a real broker: the message is written once as a human send, and its refusals map', async t => { const { b, human, transport } = broker(t); const call = transport('demo'); // The transport's refusal shape, as humanCall gives it. const send = async args => { try { return await call('message.send', args); } catch (e) { throw new BusReadError(e.code); } }; const { port } = await setup(t, { send }); const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) }); assert.equal(r.status, 200); const messages = () => b.request(human, { verb: 'trail', args: { subject: r.body.id } }).filter(row => row.table === 'messages'); assert.deepEqual(messages().map(m => [m.id, m.from_role, m.from_run, m.to_role, m.class, m.body]), [[r.body.id, 'human', 'human-cli', 'coder', 'INFO', 'Please look at row 76.']]); for (const [body, status, error] of [ [{ to: 'no-such-role', body: 'hi' }, 400, 'unknown-role'], [{ to: 'coder', body: 'x'.repeat(32769) }, 400, 'invalid-request'], [{ to: 'coder', body: '' }, 400, 'invalid-request'], [{ to: 7, body: 'hi' }, 400, 'unknown-role'], [{ to: 'coder', body: 'hi', in_reply_to: 'msg-none' }, 400, 'message-not-found'], ]) { // The 32769-character body is past the Console's 16 KiB too; the Console refuses that one first. const res = await post(port, { body: JSON.stringify(body) }); assert.deepEqual([res.status, res.body.error, res.body.sent], [status, error, false], JSON.stringify(body).slice(0, 60)); } assert.equal(messages().length, 1); assert.equal(b.request(human, { verb: 'trail', args: { subject: 'msg-none' } }).length, 0); });