Files

282 lines
18 KiB
JavaScript

// 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);
});