// Row 76 (#1570, lead decision 95): `serve --sso --send`, a human message to a role from the Console. // POST /api/bus/message needs a verified OIDC session 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 { 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 { createSsoSession, SSO_HOST } from './helpers/sso-session.mjs'; import { broker, tmp } from '../../cli/tests/helpers.mjs'; const NAME = SSO_HOST; const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url)); const without = (headers, name) => Object.fromEntries(Object.entries(headers).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 = {}, 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 auth = await createSsoSession(t, { host: NAME, log: l => lines.push(l) }); const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, sso: auth.sso, send, log: l => lines.push(l), ...options }); t.after(() => close(web)); const port = web.address().port; const session = await auth.signIn(port); const headers = auth.writeHeaders(session); return { port, post: (request = {}) => post(port, { headers, ...request }), headers, session, auth, boardHits, busCalls, sends, lines, state }; } test('startServer takes send only as a function and only with verified OIDC', async t => { const auth = await createSsoSession(t, { host: NAME }); for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: { host: NAME, header: 'x-user', user: 'jason' } }, { send: 'yes', sso: auth.sso }, { send: {}, sso: auth.sso }]) { 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 --sso/); } }); test('the CLI refuses --send without --sso before loading 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 --sso/], [['--send', '--public-host', NAME, '--auth-header', 'x-user'], /^refused: preview mode needs/], [['--public-host', NAME, '--auth-header', 'x-user', '--allow-user', 'jason', '--send'], /^refused: --send needs --sso/], [['--send', '--public-host', NAME, '--auth-header', 'x-user', '--allow-user', 'jason'], /^refused: --send needs --sso/], ]) { 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('without --send the route is read-only like every other write, and config says no send', async t => { const { post, sends, lines } = await setup(t, { send: null }); const r = await post(); assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }); const config = await post({ method: 'GET', path: '/api/config', 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 { post, 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({ 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 for the signed-in public name and refuses the loopback operator path', async t => { const { port, post, headers } = await setup(t); assert.equal((await post({ method: 'GET', path: '/api/config', body: undefined })).body.send, true); assert.equal((await post({ method: 'GET', path: '/api/config', headers: { ...headers, host: `127.0.0.1:${port}` }, body: undefined })).status, 403); }); test('a send without a verified OIDC session is 401 and is neither sent nor logged', async t => { const { post, headers, sends, lines } = await setup(t); for (const [why, candidateHeaders] of [ ['no cookie', without(headers, 'cookie')], ['a forged legacy identity header', { ...without(headers, 'cookie'), 'x-authentik-username': 'jason' }], ['an invalid session', { ...headers, cookie: '__Host-mosaic_session=invalid' }], ['two session cookies', { ...headers, cookie: `${headers.cookie}; ${headers.cookie}` }], ]) { const r = await post({ headers: candidateHeaders }); assert.equal(r.status, 401, why); assert.deepEqual(r.body, { error: 'sign-in required' }, why); } 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 { post, headers, sends, lines } = await setup(t); const cases = [ ['no Origin', { headers: without(headers, 'origin') }, 403, 'cross-origin request refused'], ['no Sec-Fetch-Site', { headers: without(headers, '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: { ...headers, 'sec-fetch-site': v } }, 403, 'cross-site request refused']), ['two Sec-Fetch-Site', { headers: { ...headers, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'], ['no Content-Type', { headers: without(headers, 'content-type') }, 400, 'invalid-request'], ['text/plain', { headers: { ...headers, 'content-type': 'text/plain' } }, 400, 'invalid-request'], ['form', { headers: { ...headers, '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(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({ headers: { ...headers, origin } }); assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }, origin); } assert.equal((await post({ method: 'GET', body: undefined })).status, 405); assert.equal((await post({ 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({ body: fits })).status, 200); }); test('a send passes exactly to, body and in_reply_to, answers the id, and logs it', async t => { const { post, sends, lines, boardHits, busCalls } = await setup(t); const r = await post({ 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({ 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({ body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200); assert.equal((await post({ 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 { post, 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({ 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({ 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 { post, 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(); 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 { post, lines } = await setup(t, { send: args => call('message.send', args) }); const r = await post(); assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-fake-1'); const refused = await post({ 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({ 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 { post } = await setup(t, { send }); const r = await post({ 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({ 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); });