Files
jason.woltje e98909d907 Merge refactor 2bed0aebe into F3 (console restart, owner settings writes)
Conflicts kept both sides: rename stage B organization paths and the
relative-launch re-exec with F3 imports, F2 onboarding script and the
north-star fence exemption with F3 console-restart.js and ORG_OWNER_WRITE,
and the /api/config fields from both.
2026-11-09 13:41:20 -06:00

265 lines
18 KiB
JavaScript

// Row 194 (#1692, D7, T16): the Console's Agents routes. Launch, terminate, regenerate and veto each
// have instanceSend's boundary and make one call through agentWrite; delete joins add and change on
// /api/instances; the page follows a request at /api/agent/request. The tester listener lets launch,
// terminate and the request read through (D4.1) and refuses regenerate and veto with no bus call.
// Every write here uses a fixture writer; the bus verbs' own checks are in the bus tests.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { THREAD_DEFINITIONS } from '../../organization/src/vocabulary.mjs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { startTesterSocket } from '../src/tester-socket.mjs';
import { testerRequest } from '../../cli/src/tester-http.mjs';
import { BusReadError } from '../src/bus.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const REV = 'a'.repeat(64);
const ID = '0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
const read = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false, kept: false } }, definitions: {}, noHire: [], retired: [], ...extra });
const ANSWERS = {
'instance.launch': () => ({ request: ID }),
'instance.regenerate': () => ({ request: ID }),
'instance.end': () => ({ request: ID, runs: ['r-1'], cancelled: [], taken: [] }),
'veto.set': () => ({ ...read(), transaction: 't-1', restart: false }),
'instance.request': args => ({ request: args.request, action: 'launch', instance: 'coder', by: 'jason', at: '2026-10-20T00:00:00.000Z', state: 'closed', done: { run: 'r-2', transaction: null, refusal: null }, cancelled: null }),
};
async function setup(t, { on = true, agent = async (verb, args) => ANSWERS[verb](args), instances = async args => ({ request: ID, args }), tester = false } = {}) {
const calls = [], lines = [];
const options = {
log: line => lines.push(line),
agentWrite: agent ? async (verb, args) => { calls.push([verb, args]); return agent(verb, args); } : null,
instancesWrite: instances ? async args => { calls.push(['instances.set', args]); return instances(args); } : null,
};
// Row 196 (#1694): the tester listener serves only on its private socket under the data root.
if (tester) {
const dataRoot = mkdtempSync(join(tmpdir(), 'g194-tester-'));
t.after(() => rmSync(dataRoot, { recursive: true, force: true }));
const server = await startTesterSocket({ dataRoot, ...options });
t.after(() => new Promise(r => server.close(r)));
return { calls, lines, dataRoot };
}
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({ port: 0, sso: auth.sso, send: on ? async () => ({}) : null, ...options });
t.after(() => new Promise(r => server.close(r)));
const session = await auth.signIn(server.address().port);
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
}
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: (() => { try { return JSON.parse(text); } catch { return null; } })() }));
});
req.on('error', reject);
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
});
const get = (s, path, hs = s.headers) => send(s, path, undefined, hs, 'GET');
test('row 194 T16: each agent action makes one call with only its keys, logs the user and answers the request', async t => {
const s = await setup(t);
const launched = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.equal(launched.status, 200, launched.text);
assert.equal(launched.body.request, ID); assert.match(launched.body.at, /^\d{4}-/);
const regenerated = await send(s, '/api/agent/regenerate', { instance: 'coder' });
assert.equal(regenerated.status, 200, regenerated.text);
const ended = await send(s, '/api/agent/terminate', { instance: 'coder', reason: 'secret reason text' });
assert.equal(ended.status, 200, ended.text); assert.deepEqual(ended.body.runs, ['r-1']);
const bare = await send(s, '/api/agent/terminate', { instance: 'coder' });
assert.equal(bare.status, 200, bare.text);
const kept = await send(s, '/api/veto', { revision: REV, op: 'keep', name: 'coder' });
assert.equal(kept.status, 200, kept.text); assert.equal(kept.body.transaction, 't-1');
assert.deepEqual(s.calls, [
['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
['instance.end', { instance: 'coder', reason: 'secret reason text' }], ['instance.end', { instance: 'coder' }],
['veto.set', { revision: REV, op: 'keep', name: 'coder' }],
]);
assert.deepEqual(s.lines.map(l => l.replace(/^\S+ /, '')), [
`launch user="jason" instance="coder" done request=${ID}`, `regenerate user="jason" instance="coder" done request=${ID}`,
`terminate user="jason" instance="coder" done request=${ID}`, `terminate user="jason" instance="coder" done request=${ID}`,
'veto user="jason" instance="coder" done',
]);
assert.ok(s.lines.every(l => !l.includes('secret reason text')), 'a reason is never logged');
});
test('row 194 T16: the boundary refuses before any bus call', async t => {
const s = await setup(t);
const cases = [
['/api/agent/launch', { instance: 'coder' }, { ...s.headers, cookie: '__Host-mosaic_session=invalid' }, 401],
['/api/agent/launch', { instance: 'coder' }, { ...s.headers, origin: 'https://evil.example' }, 403],
['/api/agent/launch', { instance: 'coder' }, { ...s.headers, 'sec-fetch-site': 'cross-site' }, 403],
['/api/agent/launch', { instance: 'coder', extra: 'x' }, s.headers, 400],
['/api/agent/launch', {}, s.headers, 400],
['/api/agent/launch', { instance: 7 }, s.headers, 400],
['/api/agent/launch', ['coder'], s.headers, 400],
['/api/agent/launch', 'not json', s.headers, 400],
['/api/agent/launch', { instance: 'x'.repeat(17000) }, s.headers, 400],
['/api/agent/terminate', { instance: 'coder', reason: 3 }, s.headers, 400],
['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, s.headers, 400],
['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, s.headers, 400],
['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, s.headers, 400],
['/api/veto', { op: 'keep', name: 'coder' }, s.headers, 400],
];
for (const [path, body, hs, status] of cases) {
const r = await send(s, path, body, hs);
assert.equal(r.status, status, `${path} ${JSON.stringify(body).slice(0, 60)}: ${r.text}`);
}
for (const path of ['/api/agent/launch', '/api/agent/terminate', '/api/agent/regenerate', '/api/veto']) {
assert.equal((await get(s, path)).status, 405, path);
}
assert.equal((await send(s, '/api/agent/request', { request: ID })).status, 405);
assert.deepEqual(s.calls, []);
});
test('row 194 T16: read-only when send is off or no writer is wired; no route has a --send exception', async t => {
for (const options of [{ on: false }, { agent: null }]) {
const s = await setup(t, options);
for (const path of ['/api/agent/launch', '/api/agent/terminate', '/api/agent/regenerate', '/api/veto']) {
const r = await send(s, path, { instance: 'coder' });
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'read-only');
}
const r = await get(s, `/api/agent/request?id=${ID}`);
assert.equal(r.status, 403); assert.equal(r.body.error, 'read-only');
assert.deepEqual(s.calls, []);
}
});
test('row 194 T16: a bus refusal names its reason and nothing changed; an unknown outcome or a bad shape is never retried', async t => {
let answer;
const s = await setup(t, { agent: async () => answer() });
answer = () => { throw new BusReadError('no-route'); };
const refused = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.equal(refused.body.sent, false); assert.equal(refused.body.error, 'no-route');
assert.match(refused.body.message, /^no-route: .+\. Nothing changed\.$/);
assert.doesNotMatch(refused.body.message, /refused by the bus/, 'no-route has its own reason');
answer = () => { throw new BusReadError('instance-running'); };
const running = await send(s, '/api/agent/regenerate', { instance: 'coder' });
assert.equal(running.body.error, 'instance-running'); assert.equal(running.body.sent, false);
answer = () => { throw new BusReadError('outcome-unknown'); };
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
for (const [path, body, bad] of [
['/api/agent/launch', { instance: 'coder' }, { request: 'not-an-id' }],
['/api/agent/terminate', { instance: 'coder' }, { request: ID, runs: 'r-1', cancelled: [], taken: [] }],
['/api/veto', { revision: REV, op: 'nohire', name: 'coder' }, { revision: REV }],
// Row 194 b6 (webby-0094 B1): a veto answer without restart can't say whether the reload took.
['/api/veto', { revision: REV, op: 'nohire', name: 'coder' }, { ...read(), revision: REV, transaction: 't-1' }],
]) {
answer = () => bad;
const r = await send(s, path, body);
assert.deepEqual([r.status, r.body.error, r.body.sent], [503, 'outcome-unknown', 'unknown'], path);
}
assert.equal(s.calls.length, 7, 'one call per POST, nothing retried');
});
test('row 194 T16: the request read takes one id, adds the refusal reason and refuses an unexpected shape', async t => {
let answer = ANSWERS['instance.request'];
const s = await setup(t, { agent: async (verb, args) => answer(args) });
const closed = await get(s, `/api/agent/request?id=${ID}`);
assert.equal(closed.status, 200, closed.text);
assert.deepEqual([closed.body.state, closed.body.done.run, closed.body.reason], ['closed', 'r-2', undefined]);
answer = args => ({ ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal: 'no-route' } });
assert.match((await get(s, `/api/agent/request?id=${ID}`)).body.reason, /route/);
// Row 194 deviation 11 (sage-1620): a failed revoke says the file is unchanged and a retry is safe.
answer = args => ({ ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal: 'credential-revoke-failed' } });
assert.match((await get(s, `/api/agent/request?id=${ID}`)).body.reason, /business file is unchanged\. Tokens already revoked stay revoked .* delete again, which is safe$/);
answer = args => ({ ...ANSWERS['instance.request'](args), done: null, cancelled: { by: 'jason' } });
assert.ok((await get(s, `/api/agent/request?id=${ID}`)).body.reason);
answer = args => ({ ...ANSWERS['instance.request'](args), state: 'taken', done: null });
assert.equal((await get(s, `/api/agent/request?id=${ID}`)).body.state, 'taken');
assert.deepEqual(s.calls.map(c => c[0]), Array(5).fill('instance.request'));
for (const query of ['', '?id=nope', `?id=${ID}&id=${ID}`, `?id=${ID}&x=1`, `?id=${ID.toUpperCase()}`]) {
const r = await get(s, `/api/agent/request${query}`);
assert.equal(r.status, 400, query); assert.equal(r.body.error, 'invalid-request');
}
assert.equal(s.calls.length, 5, 'a bad id makes no call');
answer = () => { throw new BusReadError('unknown-request'); };
const unknown = await get(s, `/api/agent/request?id=${ID}`);
assert.equal(unknown.body.error, 'unknown-request');
for (const bad of [args => ({ ...ANSWERS['instance.request'](args), request: '0'.repeat(36) }), args => ({ ...ANSWERS['instance.request'](args), state: 'done' }), () => null]) {
answer = bad;
const r = await get(s, `/api/agent/request?id=${ID}`);
assert.deepEqual([r.status, r.body.error], [502, 'invalid-response']);
}
});
test('row 194 T16: delete joins add and change on /api/instances with only revision, instance and op', async t => {
const s = await setup(t);
const deleted = await send(s, '/api/instances', { revision: REV, instance: 'coder', op: 'delete' });
assert.equal(deleted.status, 200, deleted.text); assert.equal(deleted.body.request, ID);
assert.deepEqual(s.calls, [['instances.set', { revision: REV, instance: 'coder', op: 'delete' }]]);
assert.match(s.lines.at(-1), / instance user="jason" instance="coder" done request=/);
for (const extra of [{ definition: 'coder' }, { vars: {} }, { launch: false }]) {
const r = await send(s, '/api/instances', { revision: REV, instance: 'coder', op: 'delete', ...extra });
assert.equal(r.status, 400, JSON.stringify(extra));
}
assert.equal(s.calls.length, 1);
});
test('row 194 T16 D4.1: the tester listener launches, terminates and reads requests; regenerate, veto and delete are refused with no call', async t => {
const s = await setup(t, { tester: true });
const send = (s, path, body) => testerRequest({ dataRoot: s.dataRoot, path, method: 'POST', body });
assert.equal((await send(s, '/api/agent/launch', { instance: 'coder' })).status, 200);
assert.equal((await send(s, '/api/agent/terminate', { instance: 'coder' })).status, 200);
assert.equal((await testerRequest({ dataRoot: s.dataRoot, path: `/api/agent/request?id=${ID}` })).status, 200);
for (const [path, body] of [['/api/agent/regenerate', { instance: 'coder' }], ['/api/veto', { revision: REV, op: 'keep', name: 'coder' }],
['/api/instances', { revision: REV, instance: 'coder', op: 'delete' }]]) {
const r = await send(s, path, body);
assert.equal(r.status, 403, path);
assert.equal(r.body.sent, false, path);
}
assert.deepEqual(s.calls.map(c => c[0]), ['instance.launch', 'instance.end', 'instance.request']);
assert.ok(s.lines.every(l => l.includes('user="tester"')));
});
test('row 194 N5: agents.js is served as JavaScript and index.html loads it once', async t => {
const s = await setup(t);
const js = await get(s, '/agents.js');
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
// F3 adds the agent restart receipt and the thread test that Settings > Routing uses.
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section, restartMarkup, isThread: definition => threadDefinitions\.has\(definition\) \}\)/);
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./);
const html = await get(s, '/');
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
// duckworth-0165 C1: a direct answer of outcome-unknown-revoked is an open outcome, never "Nothing changed".
test('row 239 T6: a delete closed -revoked says the tokens may already be revoked, and outcome-unknown-revoked is an open outcome', async t => {
let refusal = 'revision-conflict-revoked';
const s = await setup(t, { agent: async (verb, args) => { if (verb !== 'instance.request') throw new BusReadError('outcome-unknown-revoked'); return { ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal } }; } });
const reason = async () => (await get(s, `/api/agent/request?id=${ID}`)).body.reason;
assert.match(await reason(), /business file changed after the agent's tokens were revoked .*\. The tokens may already be revoked .* delete again, which is safe$/);
refusal = 'outcome-unknown-revoked';
assert.match(await reason(), /tokens may already be revoked and the write's outcome is unknown\. Read the role instances again before trying$/);
refusal = 'revision-conflict';
assert.match(await reason(), /the business file changed since you read it/);
assert.doesNotMatch(await reason(), /may already be revoked/);
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
assert.doesNotMatch(unknown.body.message, /Nothing changed/);
});
test('closed launch refusals explain the code and the next step', async t => {
let refusal='skill-not-enabled';
const s=await setup(t,{agent:async (verb,args)=>({...ANSWERS['instance.request'](args),done:{run:null,transaction:null,refusal}})});
for(const [code,hint] of [['skill-not-enabled',/scripts\/skill\.sh activate/],['not-thread-launch',/thread message/],['unsupported-harness',/harness/i],['no-route',/route/i],['proxy-account-unavailable',/proxy/i]]) {
refusal=code;const answer=(await get(s,`/api/agent/request?id=${ID}`)).body;
assert.equal(answer.done.refusal,code);assert.match(answer.reason,hint);
}
refusal='new-refusal-code';assert.equal((await get(s,`/api/agent/request?id=${ID}`)).body.reason,refusal);
const named=await setup(t,{agent:async (verb,args)=>({...ANSWERS['instance.request'](args),done:{refusal:'skill-not-enabled',skill:'ms-sdlc'}})});
assert.match((await get(named,`/api/agent/request?id=${ID}`)).body.reason,/activate ms-sdlc/);
});
// The browser cannot import the server vocabulary, so detect drift in its local copy.
test('browser thread definitions match the business vocabulary', () => {
const source = readFileSync(new URL('../src/public/agents.js', import.meta.url), 'utf8');
const literal = source.match(/const threadDefinitions = new Set\((\[[^\]]*\])\)/)?.[1];
assert.ok(literal, 'thread definition list is present');
assert.deepEqual(Array.from(runInNewContext(literal)).sort(), [...THREAD_DEFINITIONS].sort());
});