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