Files
stack/packages/webui/tests/f3-controls.test.mjs
jason.woltje 0a73279d62 fix(webui): F3 review round: instances trail line, honest dev-mode README
The instances route now passes the owner trail note, so an organization
instance change appends the actions.jsonl line the README promises (P3).

The README claimed no agent reaches the F3 controls. On a dev local owner
Console that is false: every local request acts as the owner, which the
pinned startup warning already says. The README now states it and names
sign-in as the boundary; a local-agent peer refusal is a follow-up (P1).
2026-11-09 14:07:52 -06:00

285 lines
18 KiB
JavaScript

// F3: the owner's Console restart and the organization settings writes, through the real server.
// Each control is refused without the human, refused from another origin, works for the dev local
// owner and writes the action trail. No test reaches a live Console, a real config or a secret.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { consoleAccess } from '../src/access.mjs';
import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
const REVISION = 'a'.repeat(64);
const REQUEST = '10000000-0000-4000-8000-000000000001';
const ROUTES = { assistant: { harness: 'claude', model: 'opus', thinking: 'high' } };
const ROUTES_ANSWER = { revision: 'b'.repeat(64), harnesses: [], models: [], levels: {}, instances: { assistant: ROUTES.assistant } };
function call(target, { method = 'GET', path, headers = {}, body }) {
return new Promise((resolve, reject) => {
const all = { ...headers };
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
const req = request({ ...target, method, path, headers: all }, res => {
let raw = '';
res.on('data', c => { raw += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json, raw }); });
});
req.on('error', reject);
req.end(body);
});
}
const scratch = t => { const root = mkdtempSync(join(tmpdir(), 'f3-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
const lines = dir => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
const spy = () => { const calls = []; let hit; const once = new Promise(r => { hit = r; }); const fn = () => { calls.push(Date.now()); hit(); }; return { fn, calls, once }; };
// A loopback Console under the dev local owner with --send; `opts` overrides any option.
async function devConsole(t, opts = {}) {
const root = scratch(t), dir = join(root, 'console'), restart = spy();
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir), ...opts });
t.after(() => close(server));
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
const write = (path, body = '{}', headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path, body, headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
const read = async path => (await call({ host: '127.0.0.1', port }, { path, headers: { host } })).body;
return { port, host, origin, dir, root, restart, write, read };
}
const FENCE = port => [
[{ origin: null }, 'cross-origin request refused'],
[{ origin: 'http://evil.example' }, 'cross-origin request refused'],
[{ origin: `http://localhost:${port + 1}` }, 'cross-origin request refused'],
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
[{ host: `evil.example:${port}` }, 'non-local Host refused'],
[{ 'x-mosaic-tester': '1' }, 'tester listener required'],
];
test('the dev owner restarts a supervised Console once, after the answer, and the trail names them', async t => {
const c = await devConsole(t);
const config = await c.read('/api/config');
assert.equal(config.consoleRestart, true);
assert.match(config.boot, /^[0-9a-f-]{36}$/);
const r = await c.write('/api/console/restart');
assert.equal(r.status, 202, r.raw);
assert.deepEqual(r.body, { outcome: 'restarting', boot: config.boot });
await c.restart.once;
assert.equal(c.restart.calls.length, 1);
const trail = lines(c.dir);
assert.equal(trail.length, 1);
const [{ at, ...entry }] = trail;
assert.ok(Number.isFinite(Date.parse(at)));
assert.deepEqual(entry, { by: DEV, action: 'console.restart', outcome: 'restarting', boot: config.boot });
assert.equal(statSync(join(c.dir, 'actions.jsonl')).mode & 0o777, 0o600);
assert.equal(statSync(c.dir).mode & 0o777, 0o700);
});
test('a Console restart from another origin, site, Host or the tester header is refused and leaves no trail', async t => {
const c = await devConsole(t);
for (const [headers, error] of FENCE(c.port)) {
const r = await c.write('/api/console/restart', '{}', headers);
assert.equal(r.status, 403, JSON.stringify(headers));
assert.equal(r.body.error, error, JSON.stringify(headers));
}
assert.equal(c.restart.calls.length, 0);
assert.deepEqual(lines(c.dir), []);
});
test('without the signed-in owner there is no Console restart', async t => {
const root = scratch(t), dir = join(root, 'console'), restart = spy();
// A loopback Console with no sign-in has no owner at all.
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(anon));
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/console/restart', body: '{}', headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal((await call({ host: '127.0.0.1', port: ap }, { path: '/api/config', headers: { host: ah } })).body.consoleRestart, false);
// Under --sso: signed out, a granted user, then the signed-in owner.
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(web));
const port = web.address().port, target = { host: '127.0.0.1', port };
const post = cookie => call(target, { method: 'POST', path: '/api/console/restart', body: '{}', headers: s.writeHeaders(cookie) });
const out = await post(null);
assert.equal(out.status, 401); assert.equal(out.body.error, 'sign-in required');
const alice = await s.signIn(port, { sub: 'alice-sub' });
const granted = await post(alice);
assert.equal(granted.status, 403); assert.equal(granted.body.error, 'owner-only');
assert.equal(restart.calls.length, 0);
assert.deepEqual(lines(dir), []);
const owner = await s.signIn(port);
assert.equal((await call(target, { path: '/api/config', headers: s.readHeaders(owner) })).body.consoleRestart, true);
const ok = await post(owner);
assert.equal(ok.status, 202, ok.raw);
await restart.once;
const [entry] = lines(dir);
assert.equal(entry.by.provider, 'writer'); assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.by.name, 'jason');
assert.equal(entry.action, 'console.restart');
});
test('the tester listener never restarts the Console', async t => {
const root = scratch(t), dir = join(root, 'console'), restart = spy(), socketPath = join(root, 'tester.sock');
const server = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(server));
const headers = { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const r = await call({ socketPath }, { method: 'POST', path: '/api/console/restart', body: '{}', headers });
assert.equal(r.status, 403); assert.equal(r.body.error, 'owner-only');
assert.equal((await call({ socketPath }, { path: '/api/config', headers: { host: 'mosaic-tester.invalid' } })).body.consoleRestart, false);
assert.equal(restart.calls.length, 0);
assert.deepEqual(lines(dir), []);
});
test('a Console without --send, its supervisor or a safe trail says so and stays up', async t => {
const readOnly = await devConsole(t, { send: null });
assert.equal((await readOnly.read('/api/config')).consoleRestart, false);
const ro = await readOnly.write('/api/console/restart');
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
const detached = await devConsole(t, { restart: null });
assert.equal((await detached.read('/api/config')).consoleRestart, false);
const d = await detached.write('/api/console/restart');
assert.equal(d.status, 409); assert.equal(d.body.error, 'not-supervised');
assert.deepEqual(lines(detached.dir), []);
const noTrail = await devConsole(t, { trail: null });
assert.equal((await noTrail.read('/api/config')).consoleRestart, false);
const n = await noTrail.write('/api/console/restart');
assert.equal(n.status, 503); assert.equal(n.body.error, 'trail-unavailable');
const broken = await devConsole(t, { trail: () => { throw new Error('disk full'); } });
const b = await broken.write('/api/console/restart');
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
// A trail directory that is a symlink is unsafe; the restart is refused, nothing is written.
const root = scratch(t), real = join(root, 'real');
mkdirSync(real, { mode: 0o700 }); symlinkSync(real, join(root, 'console'));
const linked = await devConsole(t, { trail: actionTrail(join(root, 'console')) });
const l = await linked.write('/api/console/restart');
assert.equal(l.status, 500); assert.equal(l.body.error, 'trail-unavailable');
assert.equal(existsSync(join(real, 'actions.jsonl')), false);
for (const c of [readOnly, detached, noTrail, broken, linked]) assert.equal(c.restart.calls.length, 0);
});
test('the Console restart body is exactly {} as JSON', async t => {
const c = await devConsole(t);
for (const [body, headers, status] of [
['{}', { 'content-type': 'text/plain' }, 415],
['{}', { 'content-type': null }, 415],
['{"now":true}', {}, 400],
['[]', {}, 400],
['nope', {}, 400],
['', {}, 400],
]) {
const r = await c.write('/api/console/restart', body, headers);
assert.equal(r.status, status, `${body} ${JSON.stringify(headers)}`);
}
assert.equal(c.restart.calls.length, 0);
assert.deepEqual(lines(c.dir), []);
});
// The organization settings writes. `alpha` answers; `beta` is offline.
function orgs() {
const routed = [], launched = [], changed = [];
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
resolve: async id => (['alpha', 'beta'].includes(id) ? {
busHealthy: id === 'alpha', send: async () => ({}),
routesWrite: async args => { routed.push([id, args]); return ROUTES_ANSWER; },
agentWrite: async (verb, args) => { launched.push([id, verb, args]); return { request: REQUEST }; },
instancesWrite: async args => { changed.push([id, args]); return { revision: REVISION, restart: false, definitions: {}, instances: { coder: {} } }; },
} : null),
};
return { organizations, routed, launched, changed };
}
test('the dev owner changes an organization agent route and restarts it, and the trail names the organization', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const alpha = await c.read('/api/orgs/alpha/config');
assert.equal(alpha.send, false); assert.equal(alpha.settingsSend, true); assert.equal(alpha.orgSetup, true);
assert.equal((await c.read('/api/orgs/beta/config')).settingsSend, false, 'an offline organization offers no settings writes');
const r = await c.write('/api/orgs/alpha/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }));
assert.equal(r.status, 200, r.raw);
assert.deepEqual(o.routed, [['alpha', { revision: REVISION, routes: ROUTES }]]);
const launch = await c.write('/api/orgs/alpha/agent/launch', JSON.stringify({ instance: 'assistant' }));
assert.equal(launch.status, 200, launch.raw);
assert.deepEqual(o.launched, [['alpha', 'instance.launch', { instance: 'assistant' }]]);
const trail = lines(c.dir).map(({ at, ...e }) => e);
assert.deepEqual(trail, [
{ by: DEV, org: 'alpha', action: 'routes.set', instances: ['assistant'], outcome: 'done' },
{ by: DEV, org: 'alpha', action: 'instance.launch', instance: 'assistant', outcome: `done request=${REQUEST}` },
]);
// The limits route passes the gate and is refused by its own check: alpha has no settings transport.
const limits = await c.write('/api/orgs/alpha/settings', '{}');
assert.equal(limits.status, 403); assert.equal(limits.body.error, 'read-only');
assert.equal((await c.write('/api/orgs/missing/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }))).status, 404);
});
test('F3 review P3: an organization instance change appends its trail line, as the README promises', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const r = await c.write('/api/orgs/alpha/instances', JSON.stringify({ revision: REVISION, instance: 'coder', op: 'change', vars: {} }));
assert.equal(r.status, 200, r.raw);
assert.deepEqual(o.changed, [['alpha', { revision: REVISION, instance: 'coder', op: 'change', vars: {} }]]);
assert.deepEqual(lines(c.dir).map(({ at, ...e }) => e), [
{ by: DEV, org: 'alpha', action: 'instances.set', op: 'change', instance: 'coder', outcome: 'done restart=false' },
]);
});
test('organization settings writes from another origin or site are refused before the bus', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
for (const [headers] of FENCE(c.port)) {
for (const path of ['/api/orgs/alpha/routes', '/api/orgs/alpha/agent/terminate']) {
const r = await c.write(path, path.endsWith('routes') ? body : JSON.stringify({ instance: 'assistant' }), headers);
assert.equal(r.status, 403, `${path} ${JSON.stringify(headers)}`);
}
}
assert.deepEqual(o.routed, []); assert.deepEqual(o.launched, []);
});
test('organization settings writes need the signed-in owner: no sign-in, the tester listener and a granted user are refused', async t => {
const o = orgs(), root = scratch(t), dir = join(root, 'console');
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(anon));
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(a.status, 404); assert.equal(a.body.error, 'org-unavailable');
const socketPath = join(root, 'tester.sock');
const tester = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(tester));
const tr = await call({ socketPath }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(tr.status, 404); assert.equal(tr.body.error, 'org-unavailable');
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(web));
const port = web.address().port, target = { host: '127.0.0.1', port };
const alice = await s.signIn(port, { sub: 'alice-sub' });
const g = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(alice) });
assert.equal(g.status, 403); assert.equal(g.body.error, 'owner-only');
assert.deepEqual(o.routed, []);
assert.deepEqual(lines(dir), []);
// The signed-in owner on a deployed (non-dev) Console reaches the organization the same way.
const owner = await s.signIn(port);
const ok = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(owner) });
assert.equal(ok.status, 200, ok.raw);
assert.equal(o.routed.length, 1);
const [entry] = lines(dir);
assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.org, 'alpha'); assert.equal(entry.action, 'routes.set');
});