Files
stack/packages/webui/tests/agent-levels-write-http.test.mjs
jason.woltje d0cfebe286 fix(cli): M3 re-check: provider denials answer 403; A2 test owner carries rootOwner (#1873)
The rehearsal provider matches routes on the parsed pathname, so a
query string no longer turns authorize or token into a 404. Authorize
and token answer 403 to any method before the GET check, and only
discovery and the JWKS answer 405 to another method. R20 now sends a
POST to token and a GET to authorize with a query, and expects 403 for
both.

The upstream A2 agent-levels write test stubbed an owner session
without rootOwner. M3 sets the hook's owner flag from rootOwner, as
sso.mjs grants it, so the stub now carries rootOwner: true, as
system-types-http does. No check changes.
2026-11-10 21:38:17 -06:00

188 lines
13 KiB
JavaScript

// #1873 A2: the owner's Settings > Agents writes, POST /api/agents/root and
// POST /api/orgs/<org>/agent-levels/projects/<project>, through serve.mjs's hook fence with the real
// root and organization writers on a temporary config directory.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { startServer } from '../src/serve.mjs';
import { agentLevelsWriteRoutes } from '../src/agent-levels-write.mjs';
import { close } from './fixture.mjs';
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
// `who` is the session the fence sees: the owner, a granted user (the hook fence's owner flag
// false), or nobody. `send` is the Console's --send.
async function fixture(t, { send = true, granted = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'alw-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
const trail = [];
// M3 (#1873): the hook fence's owner flag is the session's root authority (rootOwner), as sso.mjs sets it.
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub', name: 'jason', rootOwner: true };
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => session, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async id => id === 'demo' ? { busHealthy: true } : null,
members: async () => [],
...(granted ? { grantFor: () => null } : {}),
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: send ? async () => ({}) : null, organizations, log: () => {},
routes: [agentLevelsWriteRoutes({ configDir: root, rolesDir, trail: entry => trail.push(entry) })] });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
return { base, root, file, trail };
}
function call(f, path, method = 'GET', body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text), text]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const ROOT = '/api/agents/root';
const PROJECT = '/api/orgs/demo/agent-levels/projects/stack';
test('the owner adds, patches and removes a root agent; each write answers a receipt and a trail entry with no value', async t => {
const f = await fixture(t);
const [s0, read0] = await call(f, ROOT);
assert.equal(s0, 200); assert.deepEqual(read0, { revision: ABSENT, state: 'absent', agents: [], writable: true });
const [s1, added, text1] = await call(f, ROOT, 'POST', { revision: ABSENT, name: 'researcher', entry: { definition: 'research', vars: { model: 'claude-opus-5-5' } } });
assert.equal(s1, 200, text1);
assert.equal(added.outcome, 'done'); assert.equal(added.receipt.verb, 'root.agent.set'); assert.deepEqual(added.receipt.changed, ['added']);
assert.equal(added.receipt.revisionBefore, ABSENT);
assert.doesNotMatch(text1, /claude-opus/);
const [, read1] = await call(f, ROOT);
assert.equal(read1.revision, added.receipt.revisionAfter);
assert.deepEqual(read1.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: [], excludeOrgs: [] }]);
const [s2, patched] = await call(f, ROOT, 'POST', { revision: read1.revision, name: 'researcher', entry: { vars: { model: null, 'limits.network': 'none' }, excludeOrgs: ['home'] } });
assert.equal(s2, 200); assert.deepEqual([...patched.receipt.changed].sort(), ['excludeOrgs', 'vars.limits.network', 'vars.model']);
const [s3, removed] = await call(f, ROOT, 'POST', { revision: patched.receipt.revisionAfter, name: 'researcher', entry: null });
assert.equal(s3, 200); assert.equal(removed.receipt.verb, 'root.agent.remove');
assert.deepEqual(f.trail.map(e => [e.action, e.agent, e.outcome]), [['agents.root.set', 'researcher', 'done'], ['agents.root.set', 'researcher', 'done'], ['agents.root.remove', 'researcher', 'done']]);
assert.ok(f.trail.every(e => e.by?.sub === 'owner-sub' && e.by.iss === 'https://issuer.example'));
const trailText = JSON.stringify(f.trail) + readFileSync(join(f.root, 'agents-receipts.jsonl'), 'utf8');
assert.doesNotMatch(trailText, /claude-opus|"none"|home"/);
});
test('refusals: wrong method, cross-site, missing Origin, non-owner, read-only, oversized, bad keys, stale revision', async t => {
const f = await fixture(t);
const ok = { revision: ABSENT, name: 'researcher', entry: { definition: 'research' } };
// Only GET and POST are registered: serve.mjs answers any other method before a writer runs.
for (const method of ['PUT', 'PATCH', 'DELETE']) {
for (const path of [ROOT, PROJECT]) {
const [status, body] = await call(f, path, method, ok);
assert.ok(status === 403 || status === 404 || status === 405, `${method} ${path}: ${status}`); assert.ok(body.error, method);
}
}
assert.equal((await call(f, ROOT, 'POST', ok, { 'sec-fetch-site': 'cross-site' }))[0], 403);
assert.equal((await call(f, ROOT, 'POST', ok, { origin: 'http://evil.example' }))[0], 403);
assert.equal((await call(f, PROJECT, 'POST', ok, { 'sec-fetch-site': 'same-site' }))[0], 403);
const [big, bigBody] = await call(f, ROOT, 'POST', { ...ok, entry: { definition: 'research', vars: { pad: 'x'.repeat(5000) } } });
assert.equal(big, 413); assert.equal(bigBody.error, 'invalid-request');
for (const bad of [{ ...ok, extra: 1 }, { revision: 'nope', name: 'researcher', entry: null }, { ...ok, name: '../x' }, { ...ok, entry: { definition: 'research', root: '/srv' } }, { ...ok, entry: { vars: [] } }]) {
const [status, body] = await call(f, ROOT, 'POST', bad);
assert.equal(status, 400, JSON.stringify(bad)); assert.equal(body.error, 'invalid-request');
}
assert.equal((await call(f, ROOT, 'POST', 'not json'))[0], 400);
assert.equal(existsSync(join(f.root, 'agents.json')), false, 'no refusal wrote the file');
const [, made] = await call(f, ROOT, 'POST', ok);
const before = readFileSync(join(f.root, 'agents.json'), 'utf8');
const [stale, staleBody] = await call(f, ROOT, 'POST', { ...ok, entry: { definition: 'reviewer' } });
assert.equal(stale, 409); assert.equal(staleBody.error, 'revision-conflict');
// An invalid value: the key is named, the value never travels.
const [invalid, invalidBody, invalidText] = await call(f, ROOT, 'POST', { revision: made.receipt.revisionAfter, name: 'researcher', entry: { vars: { model: SECRET.repeat(10) } } });
assert.equal(invalid, 400); assert.equal(invalidBody.error, 'agents-invalid'); assert.match(invalidBody.message, /model/);
assert.ok(!invalidText.includes(SECRET), invalidText);
const [chief, chiefBody] = await call(f, ROOT, 'POST', { revision: made.receipt.revisionAfter, name: 'boss', entry: { definition: 'chief-of-staff' } });
assert.equal(chief, 400); assert.match(chiefBody.message, /chief-of-staff/);
assert.equal(readFileSync(join(f.root, 'agents.json'), 'utf8'), before);
assert.ok(!JSON.stringify(f.trail).includes(SECRET));
const readOnly = await fixture(t, { send: false });
const [ro, roBody] = await call(readOnly, ROOT, 'POST', ok);
assert.equal(ro, 403); assert.equal(roBody.error, 'read-only');
const [, roRead] = await call(readOnly, ROOT);
assert.equal(roRead.writable, false);
});
test('a non-owner session never reaches the writers', async t => {
const f = await fixture(t);
const ok = { revision: ABSENT, name: 'researcher', entry: { definition: 'research' } };
// The fence: a Host other than the named one.
assert.equal((await call(f, ROOT, 'POST', ok, { host: 'other.example' }))[0], 403);
// The route itself, as the fence calls it for a granted user or the tester listener (owner false),
// and for no session at all.
const handlers = [];
agentLevelsWriteRoutes({ configDir: f.root, rolesDir: join(f.root, 'roles') })({ add: (method, route, handler) => handlers.push([method, route, handler]) });
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner-sub' };
for (const [method, route, handler] of handlers.filter(([m]) => m === 'POST')) {
const path = typeof route === 'string' ? route : PROJECT;
for (const context of [{ path, orgId: 'demo', session, owner: false, send: true }, { path, orgId: 'demo', session: null, owner: true, send: true }]) {
const res = { status: 0, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
await handler({ method, headersDistinct: {}, [Symbol.asyncIterator]: async function* () {} }, res, context);
assert.equal(res.status, 403, `${path} ${JSON.stringify(context)}`); assert.equal(res.body.error, 'owner-only');
}
}
assert.equal(existsSync(join(f.root, 'agents.json')), false);
});
test('project entries: define, narrow, exclude and remove repaint the read; a widening narrow, the chief and a stale revision refuse', async t => {
const f = await fixture(t);
const [, r0] = await call(f, ROOT);
await call(f, ROOT, 'POST', { revision: r0.revision, name: 'scout', entry: { definition: 'research' } });
const [s0, p0] = await call(f, PROJECT);
assert.equal(s0, 200); assert.deepEqual({ ...p0, revision: undefined }, { organization: 'demo', project: 'stack', revision: undefined, library: false, agents: [], writable: true });
const set = async (name, entry) => { const [, now] = await call(f, PROJECT); return call(f, PROJECT, 'POST', { revision: now.revision, name, entry }); };
const [d, define, defineText] = await set('checker', { definition: 'fact-checker', vars: { model: 'claude-sonnet-5-5' } });
assert.equal(d, 200, defineText);
assert.equal(define.receipt.verb, 'project.agents.set'); assert.equal(define.receipt.form, 'define');
assert.equal(define.receipt.revisionBefore, p0.revision); assert.notEqual(define.receipt.revisionAfter, p0.revision);
assert.doesNotMatch(defineText, new RegExp(f.root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.equal((await set('coder', { vars: { 'limits.network': 'none' } }))[1].receipt.form, 'narrow');
assert.equal((await set('scout', { exclude: true }))[1].receipt.form, 'exclude');
const [, listed] = await call(f, PROJECT);
assert.deepEqual(listed.agents, [
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
{ name: 'coder', form: 'narrow', varKeys: ['limits.network'] },
{ name: 'scout', form: 'exclude' },
]);
const before = readFileSync(f.file, 'utf8');
const [w, widen, widenText] = await set('coder', { vars: { model: SECRET } });
assert.equal(w, 400); assert.equal(widen.error, 'project-agents-invalid'); assert.match(widen.message, /can only narrow/);
assert.ok(!widenText.includes(SECRET));
for (const name of ['chief', 'assistant']) {
const [c, chief] = await set(name, { exclude: true });
assert.equal(c, 400, name); assert.equal(chief.error, 'project-agents-invalid');
}
const [stale, staleBody] = await call(f, PROJECT, 'POST', { revision: p0.revision, name: 'checker', entry: null });
assert.equal(stale, 409); assert.equal(staleBody.error, 'revision-conflict');
const [missing, missingBody] = await call(f, '/api/orgs/demo/agent-levels/projects/nope', 'POST', { revision: listed.revision, name: 'scout', entry: { exclude: true } });
assert.equal(missing, 404); assert.equal(missingBody.error, 'project-not-found');
assert.equal((await call(f, '/api/orgs/other/agent-levels/projects/stack'))[0], 404);
// A name the writers can't take is a 400 that names the field, never the value, on both routes.
for (const name of ['BadName', 'a'.repeat(65)]) {
for (const [route, entry] of [[PROJECT, { definition: 'research' }], [ROOT, { definition: 'research' }]]) {
const [s, body, text] = await call(f, route, 'POST', { revision: listed.revision, name, entry });
assert.equal(s, 400, `${route} ${name}: ${text}`); assert.equal(body.error, 'invalid-request'); assert.match(body.message, /^name must be an id/);
assert.ok(!text.includes(name), text);
}
}
assert.equal(readFileSync(f.file, 'utf8'), before);
for (const name of ['checker', 'coder', 'scout']) assert.equal((await set(name, null))[1].receipt.form, 'remove');
assert.deepEqual((await call(f, PROJECT))[1].agents, []);
const forms = f.trail.filter(e => e.action === 'project.agents.set').map(e => `${e.agent}:${e.form}:${e.outcome}`);
assert.deepEqual(forms.slice(0, 3), ['checker:define:done', 'coder:narrow:done', 'scout:exclude:done']);
assert.ok(forms.includes('coder:narrow:project-agents-invalid'));
assert.ok(f.trail.every(e => !JSON.stringify(e).includes(SECRET) && !JSON.stringify(e).includes('claude-sonnet')));
});