import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { connect } from 'node:net'; import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs'; import { Broker, BusError } from '../src/broker.mjs'; import { REFUSALS, refusalGuidance } from '../src/refusals.mjs'; import { Store } from '../src/store.mjs'; import { busOrganization } from '../src/organization-context.mjs'; import { serve } from '../src/server.mjs'; import { Client } from '../src/client.mjs'; import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs'; import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs'; import { railsText } from '../../organization/src/rails.mjs'; import { GUARD_CODES } from '../../library/src/reply-protocol.mjs'; const SECRET = 'sk-test-NOT-A-SECRET-1234'; function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'rails-')); const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES }); const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })])); writeFileSync(business.roles.coder.credentials.gitea.file, SECRET); const resolved = resolve(business), store = new Store(root); const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } }); const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' }); broker.request(cap, { verb: 'role.claim' }); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); return { root, business, resolved, resolve, broker, store, cap }; } test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => { const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args }); const before = f.store.get('SELECT count(*) n FROM events').n; assert.deepEqual(call({}), f.resolved.coder.rails); const result = call({}); result.actions.length = 0; assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy'); const business = structuredClone(f.business); business.roles.coder.vars['limits.authority'] = ['message.send']; const narrowed = f.resolve(business); f.broker.reloadBusiness('acme', busOrganization(business, narrowed)); assert.deepEqual(call({}), narrowed.coder.rails); assert.equal(f.store.get('SELECT count(*) n FROM events').n, before); assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0); }); test('my_rails cannot select another identity or expose credential refs and file/call values', t => { const f = fixture(t); for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) { assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' }); } const rails = f.broker.request(f.cap, { verb: 'my_rails' }); const text = JSON.stringify(rails) + railsText(rails); for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value); const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' }); }); function raw(path, request) { return new Promise((resolve, reject) => { const socket = connect(path); let out = ''; socket.on('error', reject); socket.on('connect', () => socket.end(JSON.stringify(request) + '\n')); socket.on('data', b => out += b); socket.on('end', () => resolve(JSON.parse(out))); }); } test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => { const f = fixture(t), path = join(f.root, 'bus.sock'); const server = await serve({ broker: f.broker, path }); t.after(() => server.close()); const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } }; const refused = await raw(path, request); assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found'); assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']); assert.ok(!JSON.stringify(refused).includes(SECRET)); await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => { assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true; }); const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery', question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } }); const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } }); assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/); assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0); // Exercise an actual cross-role action without its required decision, not just resolve. const live = busOrganization(f.business, f.resolved); live.roles.coder.authority.crossRole.push('role.end'); f.broker.reloadBusiness('acme', live); const decisions = f.store.get('SELECT count(*) n FROM decisions').n; const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } }); assert.equal(required.ok, false); assert.equal(required.error, 'decision-required'); assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']); assert.ok(!JSON.stringify(required).includes(SECRET)); assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions); }); test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => { const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8'); // Balance each call (including ternaries), so a following statement cannot masquerade // as a fail code. Every literal branch, with either quote style, must be in the table. for (const match of source.matchAll(/\bfail\s*\(/g)) { let depth = 1, i = match.index + match[0].length; while (depth && i < source.length) { const char = source[i++]; if (char === "'" || char === '"') { const start = i - 1; let value = ''; while (i < source.length && source[i] !== char) { if (source[i] === "\\") { i += 2; continue; } value += source[i++]; } i++; if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value); } else if (char === '(') depth++; else if (char === ')') depth--; } assert.equal(depth, 0, 'balanced fail call'); } // Finite delegated fail(code) sources must be covered as well as literal branches. for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork', 'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) { assert.ok(Object.hasOwn(REFUSALS, code), code); } for (const [code, guidance] of Object.entries(REFUSALS)) { assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code); assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance); assert.ok(!JSON.stringify(guidance).includes(SECRET)); } assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code')); }); test('finite forwarded project, library and request errors have static next steps', () => { const modules = [ ...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)), ...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs')) .map(name => new URL(`../../library/src/${name}`, import.meta.url)), ...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)), ]; for (const file of modules) { const source = readFileSync(file, 'utf8'); for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) { assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`); } } assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/); assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/); assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/); });