@bongos/core 1.19.710 → 1.19.711
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +59 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +16 -0
- package/clients/bongos-client/index.cjs +16 -0
- package/clients/bongos-client/index.d.ts +24 -0
- package/clients/bongos-client/index.mjs +16 -0
- package/docs/api/openapi.json +506 -3
- package/docs/api-reference.md +14 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/agents/lib/answer-hold.js +91 -0
- package/modules/agents/lib/authoring.js +155 -0
- package/modules/agents/lib/fire-budget.js +109 -0
- package/modules/agents/lib/gate.js +64 -0
- package/modules/agents/routes/agents.js +494 -0
- package/modules/agents/spawn.js +12 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +16 -0
- package/modules/government/catalog.js +10 -0
- package/modules/government/migrations/government_013_agent_atoms.sql +66 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/agent-invoke.js +5 -27
- package/src/module-api.js +1 -1
- package/tests/agents_authoring.mjs +306 -0
- package/tests/agents_routes.mjs +76 -15
- package/tests/agents_write_routes.mjs +461 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// tests/agents_write_routes.mjs — what the agent WRITE handlers actually do
|
|
2
|
+
// (task 1002493, goal 1000038 Phase 2a).
|
|
3
|
+
//
|
|
4
|
+
// tests/agents_routes.mjs proves the SHAPE of the surface — which verbs exist and
|
|
5
|
+
// which of them carry a rank gate. This drives the handlers themselves against a
|
|
6
|
+
// fake pool and a fake grade port, so the behaviour the task is judged on is
|
|
7
|
+
// asserted rather than inferred: authority fields refused, a protected scope
|
|
8
|
+
// refused at any rank, an edit landing IN PLACE, and — the one an operator cares
|
|
9
|
+
// about most — disabling actually stopping a fire.
|
|
10
|
+
//
|
|
11
|
+
// The gates are skipped deliberately: a handler is invoked directly with an
|
|
12
|
+
// already-authenticated `req.builder`, because whether requireRank is mounted is
|
|
13
|
+
// a structural question the other file answers, and whether the handler behaves
|
|
14
|
+
// is this one's. No database, no HTTP, no model call.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/agents_write_routes.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
process.env.NODE_ENV = 'test';
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
const api = require('../src/module-api.js');
|
|
25
|
+
const routes = require('../modules/agents/routes/agents.js');
|
|
26
|
+
|
|
27
|
+
let pass = 0;
|
|
28
|
+
let fail = 0;
|
|
29
|
+
const t = async (name, fn) => {
|
|
30
|
+
try { await fn(); pass += 1; console.log(` ok ${name}`); }
|
|
31
|
+
catch (e) { fail += 1; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---- fakes ----------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const ARMED = Object.freeze({
|
|
37
|
+
id: 7, name: 'historian', title: 'Historian', persona: 'PERSONA',
|
|
38
|
+
trigger_type: 'on-demand', trigger_spec: {}, model_tier: 'default',
|
|
39
|
+
scope_modules: [], scope_paths: [], scope_violation: null,
|
|
40
|
+
source: 'db', provenance: 'instance', author_rank: 'metic', author_builder_id: 5,
|
|
41
|
+
source_path: null, last_synced_at: null, enabled: true,
|
|
42
|
+
created_at: 'T0', updated_at: 'T0',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function fakePool({ definition = ARMED, runId = 42 } = {}) {
|
|
46
|
+
const calls = [];
|
|
47
|
+
return {
|
|
48
|
+
calls,
|
|
49
|
+
async query(sql, params) {
|
|
50
|
+
calls.push({ sql, params });
|
|
51
|
+
// ANCHORED to SELECT. `/FROM agents_definitions/` alone also matches
|
|
52
|
+
// `DELETE FROM agents_definitions`, so the delete branch below was never
|
|
53
|
+
// reached and the delete test passed whether or not the file-ownership
|
|
54
|
+
// wall was there — found by removing that wall and watching only the PATCH
|
|
55
|
+
// test go red.
|
|
56
|
+
if (/^\s*SELECT[\s\S]*FROM agents_definitions/.test(sql)) {
|
|
57
|
+
return { rows: definition && (!params || definition.name === params[0]) ? [definition] : [] };
|
|
58
|
+
}
|
|
59
|
+
if (/^\s*INSERT INTO agents_runs/.test(sql)) return { rows: [{ id: runId }] };
|
|
60
|
+
if (/^\s*INSERT INTO agents_definitions/.test(sql)) return { rows: [{ ...ARMED, name: params[0], enabled: false }] };
|
|
61
|
+
if (/^\s*UPDATE agents_definitions/.test(sql)) return { rows: [{ ...definition, enabled: /enabled = true/.test(sql) }], rowCount: 1 };
|
|
62
|
+
if (/^\s*DELETE FROM agents_definitions/.test(sql)) return { rows: [], rowCount: 1 };
|
|
63
|
+
if (/FROM agents_runs/.test(sql)) {
|
|
64
|
+
// The ownership filter is params[1]; the fake honours it so the IDOR test
|
|
65
|
+
// exercises the real WHERE clause rather than a stub that ignores it.
|
|
66
|
+
if (params && params.length > 1 && String(params[1]) !== String(5)) return { rows: [] };
|
|
67
|
+
return { rows: [{ id: runId, agent_name: 'historian', trigger_ref: 'on-demand:a-builder', trigger_type: 'on-demand', gate_decision: 'go', gate_reason: null, status: 'ok', error_code: null, model: 'opus', cost_usd: '0.5000', duration_ms: 900, output_ref: null, created_at: 'T0', updated_at: 'T1' }] };
|
|
68
|
+
}
|
|
69
|
+
return { rows: [], rowCount: 0 };
|
|
70
|
+
},
|
|
71
|
+
sqlMatching(re) { return this.calls.filter((c) => re.test(c.sql)); },
|
|
72
|
+
insertedRun() {
|
|
73
|
+
const c = this.calls.find((x) => /^\s*INSERT INTO agents_runs/.test(x.sql));
|
|
74
|
+
if (!c) return null;
|
|
75
|
+
const cols = c.sql.match(/\(([^)]+)\) VALUES/)[1].split(',').map((s) => s.trim());
|
|
76
|
+
return Object.fromEntries(cols.map((k, i) => [k, c.params[i]]));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fakeRes() {
|
|
82
|
+
const out = { status: 200, body: null, failed: null, headers: {} };
|
|
83
|
+
const res = {
|
|
84
|
+
headersSent: false,
|
|
85
|
+
set(k, v) { out.headers[k] = v; return res; },
|
|
86
|
+
status(code) { out.status = code; return res; },
|
|
87
|
+
json(body) { out.body = body; res.headersSent = true; return res; },
|
|
88
|
+
fail(code, statusOrOpts) {
|
|
89
|
+
const opts = statusOrOpts && typeof statusOrOpts === 'object' ? statusOrOpts : { status: statusOrOpts };
|
|
90
|
+
out.status = opts.status || 400;
|
|
91
|
+
out.failed = { code, message: opts.message || null, details: opts.details || null };
|
|
92
|
+
res.headersSent = true;
|
|
93
|
+
return res;
|
|
94
|
+
},
|
|
95
|
+
out,
|
|
96
|
+
};
|
|
97
|
+
return res;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const BUILDER = { id: 5, rank: 'metic', github_login: 'a-builder' };
|
|
101
|
+
const req = (over = {}) => ({ builder: BUILDER, params: {}, body: {}, query: {}, ...over });
|
|
102
|
+
|
|
103
|
+
// The LAST layer of a route is the handler; the ones before it are the gates,
|
|
104
|
+
// which tests/agents_routes.mjs asserts separately.
|
|
105
|
+
function handler(router, method, path) {
|
|
106
|
+
const layer = router.stack.filter((l) => l.route)
|
|
107
|
+
.find((l) => l.route.path === path && l.route.methods[method]);
|
|
108
|
+
assert.ok(layer, `${method} ${path} is not mounted`);
|
|
109
|
+
const stack = layer.route.stack;
|
|
110
|
+
return stack[stack.length - 1].handle;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Stub the doorway for the duration — module-api's lazy getters are writable.
|
|
114
|
+
const realPool = Object.getOwnPropertyDescriptor(api, 'pool');
|
|
115
|
+
const realResolveOptional = api.resolveOptional;
|
|
116
|
+
const realBranding = api.branding;
|
|
117
|
+
api.branding = () => ({ models: { subagentDefault: 'opus', subagentRoutine: 'haiku' } });
|
|
118
|
+
|
|
119
|
+
let lastPrompt = null;
|
|
120
|
+
api.resolveOptional = (port) => (port === 'grade' ? {
|
|
121
|
+
runSubagentCached: async ({ prompt }) => { lastPrompt = prompt; return { stdout: 'THE ANSWER', cost_usd: 0.5 }; },
|
|
122
|
+
} : null);
|
|
123
|
+
|
|
124
|
+
// ---- invoke: the gate, and what "disable" buys an operator -----------------
|
|
125
|
+
|
|
126
|
+
console.log('\ninvoke — a refusal is a ledger row, and off means off:');
|
|
127
|
+
|
|
128
|
+
await t('a DISABLED agent is refused 409, the model is never called, and the no-go is RECORDED', async () => {
|
|
129
|
+
const pool = fakePool({ definition: { ...ARMED, enabled: false } });
|
|
130
|
+
api.pool = pool;
|
|
131
|
+
lastPrompt = null;
|
|
132
|
+
const res = fakeRes();
|
|
133
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(req({ params: { name: 'historian' } }), res);
|
|
134
|
+
|
|
135
|
+
assert.equal(res.out.status, 409);
|
|
136
|
+
assert.equal(res.out.failed.code, 'agent_refused');
|
|
137
|
+
assert.match(res.out.failed.message, /not enabled/);
|
|
138
|
+
assert.equal(res.out.failed.details.run_id, '42', 'the refusal is on the record and the caller is told where');
|
|
139
|
+
|
|
140
|
+
const row = pool.insertedRun();
|
|
141
|
+
assert.equal(row.gate_decision, 'no-go');
|
|
142
|
+
assert.equal(row.status, 'skipped');
|
|
143
|
+
assert.equal(row.model, null, 'a no-go spawned nothing, so it has no model');
|
|
144
|
+
assert.equal(lastPrompt, null, 'the model was never called');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await t('an EVENT agent cannot be asked a question, and that refusal is recorded too', async () => {
|
|
148
|
+
const pool = fakePool({ definition: { ...ARMED, trigger_type: 'event', trigger_spec: { event: 'task.shipped' } } });
|
|
149
|
+
api.pool = pool;
|
|
150
|
+
const res = fakeRes();
|
|
151
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(req({ params: { name: 'historian' } }), res);
|
|
152
|
+
assert.equal(res.out.status, 409);
|
|
153
|
+
assert.match(res.out.failed.message, /not 'on-demand'/);
|
|
154
|
+
assert.equal(pool.insertedRun().gate_decision, 'no-go');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await t('an ARMED agent answers 202 with a run id and a poll URL, never 200', async () => {
|
|
158
|
+
const pool = fakePool();
|
|
159
|
+
api.pool = pool;
|
|
160
|
+
const res = fakeRes();
|
|
161
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(
|
|
162
|
+
req({ params: { name: 'historian' }, body: { input: 'why?' } }), res,
|
|
163
|
+
);
|
|
164
|
+
assert.equal(res.out.status, 202, 'a 60-120s fire cannot be a synchronous 200');
|
|
165
|
+
assert.equal(res.out.body.run_id, '42');
|
|
166
|
+
assert.equal(res.out.body.poll, '/agent-runs/42');
|
|
167
|
+
const row = pool.insertedRun();
|
|
168
|
+
assert.equal(row.gate_decision, 'go');
|
|
169
|
+
assert.equal(row.status, 'pending');
|
|
170
|
+
assert.equal(row.trigger_ref, 'on-demand:a-builder', 'attributed to the live session, not a flag');
|
|
171
|
+
assert.equal(row.requested_by_builder_id, 5);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await t('the persona and the caller\'s input BOTH reach the model', async () => {
|
|
175
|
+
api.pool = fakePool();
|
|
176
|
+
lastPrompt = null;
|
|
177
|
+
const res = fakeRes();
|
|
178
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(
|
|
179
|
+
req({ params: { name: 'historian' }, body: { input: 'why is it so?' } }), res,
|
|
180
|
+
);
|
|
181
|
+
// The fire continues behind the 202, so let the microtask queue drain.
|
|
182
|
+
await new Promise((r) => setImmediate(r));
|
|
183
|
+
assert.match(lastPrompt, /PERSONA/);
|
|
184
|
+
assert.match(lastPrompt, /why is it so\?/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await t('a body key the schema does not name is refused rather than ignored', async () => {
|
|
188
|
+
api.pool = fakePool();
|
|
189
|
+
const res = fakeRes();
|
|
190
|
+
// Stand in for the real validateOrRespond, which is what enforces `strict`.
|
|
191
|
+
await handler(routes(), 'post', '/agents/:name/invoke')(
|
|
192
|
+
req({ params: { name: 'BAD NAME' } }), res,
|
|
193
|
+
);
|
|
194
|
+
assert.equal(res.out.failed.code, 'invalid_agent_name');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---- create: the authority fields, and the stricter scope wall -------------
|
|
198
|
+
|
|
199
|
+
console.log('\ncreate — nothing in the body decides authority:');
|
|
200
|
+
|
|
201
|
+
await t('a body naming author_rank is REFUSED by name, not silently stripped', async () => {
|
|
202
|
+
api.pool = fakePool();
|
|
203
|
+
const res = fakeRes();
|
|
204
|
+
await handler(routes(), 'post', '/agents')(
|
|
205
|
+
req({ body: { name: 'x', persona: 'p', trigger_type: 'on-demand', author_rank: 'archon' } }), res,
|
|
206
|
+
);
|
|
207
|
+
assert.equal(res.out.status, 400);
|
|
208
|
+
assert.equal(res.out.failed.code, 'server_owned_field');
|
|
209
|
+
assert.match(res.out.failed.message, /author_rank/);
|
|
210
|
+
assert.match(res.out.failed.message, /live DB rank/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await t('`enabled: true` in a create body is refused — creating and arming are separate acts', async () => {
|
|
214
|
+
api.pool = fakePool();
|
|
215
|
+
const res = fakeRes();
|
|
216
|
+
await handler(routes(), 'post', '/agents')(
|
|
217
|
+
req({ body: { name: 'x', persona: 'p', trigger_type: 'on-demand', enabled: true } }), res,
|
|
218
|
+
);
|
|
219
|
+
assert.equal(res.out.failed.code, 'server_owned_field');
|
|
220
|
+
assert.match(res.out.failed.message, /enable/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await t('the stamped rank is the CALLER\'S live rank, and the row lands db/instance/disarmed', async () => {
|
|
224
|
+
const pool = fakePool();
|
|
225
|
+
api.pool = pool;
|
|
226
|
+
const res = fakeRes();
|
|
227
|
+
await handler(routes(), 'post', '/agents')(
|
|
228
|
+
req({ body: { name: 'librarian', persona: 'p', trigger_type: 'on-demand' } }), res,
|
|
229
|
+
);
|
|
230
|
+
assert.equal(res.out.status, 201, JSON.stringify(res.out.failed));
|
|
231
|
+
const [insert] = pool.sqlMatching(/INSERT INTO agents_definitions/);
|
|
232
|
+
assert.ok(insert, 'nothing was inserted');
|
|
233
|
+
assert.match(insert.sql, /'db','instance'/, "source and provenance are literals, not inputs");
|
|
234
|
+
assert.match(insert.sql, /,false\)/, 'a new definition lands DISARMED');
|
|
235
|
+
assert.equal(insert.params[8], 'metic', "author_rank is req.builder.rank");
|
|
236
|
+
assert.equal(insert.params[9], 5, 'author_builder_id is the session builder');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await t('a scope reaching a protected surface is refused 403 — at Metic AND at Archon', async () => {
|
|
240
|
+
for (const rank of ['metic', 'archon']) {
|
|
241
|
+
api.pool = fakePool();
|
|
242
|
+
const res = fakeRes();
|
|
243
|
+
await handler(routes(), 'post', '/agents')(
|
|
244
|
+
req({
|
|
245
|
+
builder: { ...BUILDER, rank },
|
|
246
|
+
body: { name: 'sneaky', persona: 'p', trigger_type: 'on-demand', scope_paths: ['src/bongos/auth.js'] },
|
|
247
|
+
}),
|
|
248
|
+
res,
|
|
249
|
+
);
|
|
250
|
+
assert.equal(res.out.status, 403, `${rank} was not refused`);
|
|
251
|
+
assert.equal(res.out.failed.code, 'scope_reaches_protected_surface');
|
|
252
|
+
assert.match(res.out.failed.message, /single call/);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---- edit: in place, and never over a file-owned row -----------------------
|
|
257
|
+
|
|
258
|
+
console.log('\nedit — one row per name, and the sync keeps its own:');
|
|
259
|
+
|
|
260
|
+
await t('a PATCH is an UPDATE, never an INSERT — editing does not make a second row', async () => {
|
|
261
|
+
const pool = fakePool();
|
|
262
|
+
api.pool = pool;
|
|
263
|
+
const res = fakeRes();
|
|
264
|
+
await handler(routes(), 'patch', '/agents/:name')(
|
|
265
|
+
req({ params: { name: 'historian' }, body: { title: 'Renamed' } }), res,
|
|
266
|
+
);
|
|
267
|
+
assert.equal(res.out.status, 200, JSON.stringify(res.out.failed));
|
|
268
|
+
assert.equal(pool.sqlMatching(/INSERT INTO agents_definitions/).length, 0);
|
|
269
|
+
const [update] = pool.sqlMatching(/UPDATE agents_definitions/);
|
|
270
|
+
assert.match(update.sql, /WHERE name = \$1 AND source = 'db'/,
|
|
271
|
+
'the source check is repeated structurally so a race cannot overwrite a file row');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await t('a FILE-owned definition is not editable here, and the reason names the file', async () => {
|
|
275
|
+
api.pool = fakePool({ definition: { ...ARMED, source: 'file', source_path: '.claude/agents/historian.md' } });
|
|
276
|
+
const res = fakeRes();
|
|
277
|
+
await handler(routes(), 'patch', '/agents/:name')(
|
|
278
|
+
req({ params: { name: 'historian' }, body: { title: 'Renamed' } }), res,
|
|
279
|
+
);
|
|
280
|
+
assert.equal(res.out.status, 409);
|
|
281
|
+
assert.equal(res.out.failed.code, 'agent_owned_by_file');
|
|
282
|
+
assert.match(res.out.failed.message, /historian\.md/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
await t('renaming is refused — the name is the identity every other surface keys on', async () => {
|
|
286
|
+
api.pool = fakePool();
|
|
287
|
+
const res = fakeRes();
|
|
288
|
+
await handler(routes(), 'patch', '/agents/:name')(
|
|
289
|
+
req({ params: { name: 'historian' }, body: { name: 'other' } }), res,
|
|
290
|
+
);
|
|
291
|
+
assert.equal(res.out.failed.code, 'agent_rename_unsupported');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await t('DELETE removes a db-authored definition and leaves the LEDGER alone', async () => {
|
|
295
|
+
const pool = fakePool();
|
|
296
|
+
api.pool = pool;
|
|
297
|
+
const res = fakeRes();
|
|
298
|
+
await handler(routes(), 'delete', '/agents/:name')(req({ params: { name: 'historian' } }), res);
|
|
299
|
+
assert.equal(res.out.status, 200, JSON.stringify(res.out.failed));
|
|
300
|
+
assert.equal(res.out.body.deleted, 'historian');
|
|
301
|
+
const [del] = pool.sqlMatching(/DELETE FROM agents_definitions/);
|
|
302
|
+
assert.match(del.sql, /AND source = 'db'/, 'the source check is repeated structurally against a race');
|
|
303
|
+
// What this agent spent and did outlives the definition it pointed at:
|
|
304
|
+
// agents_runs.definition_id is ON DELETE SET NULL with a denormalized name.
|
|
305
|
+
assert.equal(pool.sqlMatching(/DELETE FROM agents_runs/).length, 0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await t('DELETE refuses a file-owned row — the next deploy would just recreate it', async () => {
|
|
309
|
+
api.pool = fakePool({ definition: { ...ARMED, source: 'file', source_path: '.claude/agents/historian.md' } });
|
|
310
|
+
const res = fakeRes();
|
|
311
|
+
await handler(routes(), 'delete', '/agents/:name')(req({ params: { name: 'historian' } }), res);
|
|
312
|
+
assert.equal(res.out.status, 409);
|
|
313
|
+
assert.equal(res.out.failed.code, 'agent_owned_by_file');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---- arm / disarm: the operator act ---------------------------------------
|
|
317
|
+
|
|
318
|
+
console.log('\narm and disarm — the one write that works on a committed definition:');
|
|
319
|
+
|
|
320
|
+
await t('enable WORKS on a file-sourced definition — this is how a committed agent runs at all', async () => {
|
|
321
|
+
const pool = fakePool({ definition: { ...ARMED, source: 'file', source_path: '.claude/agents/historian.md', enabled: false } });
|
|
322
|
+
api.pool = pool;
|
|
323
|
+
const res = fakeRes();
|
|
324
|
+
await handler(routes(), 'post', '/agents/:name/enable')(req({ params: { name: 'historian' } }), res);
|
|
325
|
+
assert.equal(res.out.status, 200, JSON.stringify(res.out.failed));
|
|
326
|
+
assert.equal(res.out.body.agent.armed, true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await t('a FLAGGED definition cannot be armed, and the refusal quotes the wall', async () => {
|
|
330
|
+
api.pool = fakePool({ definition: { ...ARMED, enabled: false, scope_violation: 'scope_paths: "src/bongos/auth.js" reaches a protected surface' } });
|
|
331
|
+
const res = fakeRes();
|
|
332
|
+
await handler(routes(), 'post', '/agents/:name/enable')(req({ params: { name: 'historian' } }), res);
|
|
333
|
+
assert.equal(res.out.status, 409);
|
|
334
|
+
assert.equal(res.out.failed.code, 'agent_scope_flagged');
|
|
335
|
+
assert.match(res.out.failed.message, /protected surface/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await t('a definition with NO author_rank has no authority to act with', async () => {
|
|
339
|
+
api.pool = fakePool({ definition: { ...ARMED, enabled: false, author_rank: null } });
|
|
340
|
+
const res = fakeRes();
|
|
341
|
+
await handler(routes(), 'post', '/agents/:name/enable')(req({ params: { name: 'historian' } }), res);
|
|
342
|
+
assert.equal(res.out.status, 409);
|
|
343
|
+
assert.equal(res.out.failed.code, 'agent_has_no_author');
|
|
344
|
+
assert.match(res.out.failed.message, /ADR 0016/);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await t('the arming UPDATE re-checks both invariants in its own WHERE clause', async () => {
|
|
348
|
+
const pool = fakePool({ definition: { ...ARMED, enabled: false } });
|
|
349
|
+
api.pool = pool;
|
|
350
|
+
await handler(routes(), 'post', '/agents/:name/enable')(req({ params: { name: 'historian' } }), fakeRes());
|
|
351
|
+
const [update] = pool.sqlMatching(/UPDATE agents_definitions SET enabled = true/);
|
|
352
|
+
assert.match(update.sql, /scope_violation IS NULL/);
|
|
353
|
+
assert.match(update.sql, /author_rank IS NOT NULL/);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await t('disable works on any definition and reports it disarmed', async () => {
|
|
357
|
+
api.pool = fakePool();
|
|
358
|
+
const res = fakeRes();
|
|
359
|
+
await handler(routes(), 'post', '/agents/:name/disable')(req({ params: { name: 'historian' } }), res);
|
|
360
|
+
assert.equal(res.out.status, 200, JSON.stringify(res.out.failed));
|
|
361
|
+
assert.equal(res.out.body.agent.armed, false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ---- the ledger read ------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
console.log('\nthe ledger read — the row, and the answer while it is still held:');
|
|
367
|
+
|
|
368
|
+
await t('a run is served with its cost as a number and its answer beside it, not inside it', async () => {
|
|
369
|
+
const pool = fakePool();
|
|
370
|
+
api.pool = pool;
|
|
371
|
+
// Fire first so there is an answer to collect, then read the same router.
|
|
372
|
+
const router = routes();
|
|
373
|
+
await handler(router, 'post', '/agents/:name/invoke')(req({ params: { name: 'historian' } }), fakeRes());
|
|
374
|
+
await new Promise((r) => setImmediate(r));
|
|
375
|
+
await new Promise((r) => setImmediate(r));
|
|
376
|
+
|
|
377
|
+
const res = fakeRes();
|
|
378
|
+
await handler(router, 'get', '/agent-runs/:id')(req({ params: { id: '42' } }), res);
|
|
379
|
+
assert.equal(res.out.status, 200, JSON.stringify(res.out.failed));
|
|
380
|
+
const { run } = res.out.body;
|
|
381
|
+
assert.equal(run.id, '42');
|
|
382
|
+
assert.equal(run.cost_usd, 0.5, 'numeric, not the pg numeric string');
|
|
383
|
+
assert.equal(run.output_ref, null, 'the ledger holds a pointer, never the text');
|
|
384
|
+
assert.equal(run.answer, 'THE ANSWER', 'the answer travels beside the row, under its own key');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await t('a run from another router instance is not readable through this one', async () => {
|
|
388
|
+
api.pool = fakePool();
|
|
389
|
+
const res = fakeRes();
|
|
390
|
+
await handler(routes(), 'get', '/agent-runs/:id')(req({ params: { id: '42' } }), res);
|
|
391
|
+
assert.equal(res.out.body.run.answer, null, 'the hold is per-router; the LEDGER is the shared record');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await t('IDOR: another builder\'s run is filtered out IN SQL, and answers 404 not 403', async () => {
|
|
395
|
+
// The id is a sequential integer PK. Without the ownership filter any
|
|
396
|
+
// authenticated builder could walk the ledger and read someone else's held
|
|
397
|
+
// answer — model output generated from THEIR question — plus the github_login
|
|
398
|
+
// in trigger_ref. Filtering in the WHERE clause rather than after the read is
|
|
399
|
+
// the point: a post-read comparison is one early return away from leaking, and
|
|
400
|
+
// it would already have handed the row to hold.take() on the way past.
|
|
401
|
+
const pool = fakePool();
|
|
402
|
+
api.pool = pool;
|
|
403
|
+
const res = fakeRes();
|
|
404
|
+
await handler(routes(), 'get', '/agent-runs/:id')(
|
|
405
|
+
req({ params: { id: '42' }, builder: { ...BUILDER, id: 999 } }), res,
|
|
406
|
+
);
|
|
407
|
+
const [read] = pool.sqlMatching(/FROM agents_runs/);
|
|
408
|
+
assert.match(read.sql, /AND requested_by_builder_id = \$2/, 'the filter must be in SQL');
|
|
409
|
+
assert.deepEqual(read.params, [42, 999], 'filtered by the LIVE session builder, not by anything sent');
|
|
410
|
+
// 404, not 403: distinguishing "not yours" from "not there" makes the id space
|
|
411
|
+
// enumerable one bit at a time.
|
|
412
|
+
assert.equal(res.out.status, 404);
|
|
413
|
+
assert.equal(res.out.failed.code, 'agent_run_not_found');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
console.log('\nthe spend ceiling — a fire costs about a dollar and 202 means nobody waits:');
|
|
417
|
+
|
|
418
|
+
await t('a builder past the ceiling is refused 429 BEFORE anything is read or fired', async () => {
|
|
419
|
+
const pool = fakePool();
|
|
420
|
+
api.pool = pool;
|
|
421
|
+
routes.fireBudget.reset();
|
|
422
|
+
const router = routes();
|
|
423
|
+
const fire = handler(router, 'post', '/agents/:name/invoke');
|
|
424
|
+
const limit = routes.fireBudget.limit;
|
|
425
|
+
for (let i = 0; i < limit; i++) await fire(req({ params: { name: 'historian' } }), fakeRes());
|
|
426
|
+
|
|
427
|
+
// Let the backgrounded fires from the loop finish writing their ledger rows —
|
|
428
|
+
// the 202 returns before the UPDATE lands, so a snapshot taken too early keeps
|
|
429
|
+
// growing under the assertion below.
|
|
430
|
+
for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r));
|
|
431
|
+
const before = pool.calls.length;
|
|
432
|
+
const res = fakeRes();
|
|
433
|
+
await fire(req({ params: { name: 'historian' } }), res);
|
|
434
|
+
assert.equal(res.out.status, 429);
|
|
435
|
+
assert.equal(res.out.failed.code, 'rate_limited');
|
|
436
|
+
assert.equal(res.out.failed.details.scope, 'agent-fire');
|
|
437
|
+
assert.ok(res.out.failed.details.retry_after_seconds > 0);
|
|
438
|
+
assert.equal(pool.calls.length, before, 'a refused fire must not even read the registry');
|
|
439
|
+
routes.fireBudget.reset();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await t('the ceiling is per BUILDER — one builder cannot exhaust another\'s', async () => {
|
|
443
|
+
api.pool = fakePool();
|
|
444
|
+
routes.fireBudget.reset();
|
|
445
|
+
const fire = handler(routes(), 'post', '/agents/:name/invoke');
|
|
446
|
+
for (let i = 0; i < routes.fireBudget.limit; i++) {
|
|
447
|
+
await fire(req({ params: { name: 'historian' } }), fakeRes());
|
|
448
|
+
}
|
|
449
|
+
const other = fakeRes();
|
|
450
|
+
await fire(req({ params: { name: 'historian' }, builder: { ...BUILDER, id: 777 } }), other);
|
|
451
|
+
assert.equal(other.out.status, 202, 'a second builder still has their own window');
|
|
452
|
+
routes.fireBudget.reset();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ---- restore --------------------------------------------------------------
|
|
456
|
+
if (realPool) Object.defineProperty(api, 'pool', realPool);
|
|
457
|
+
api.resolveOptional = realResolveOptional;
|
|
458
|
+
api.branding = realBranding;
|
|
459
|
+
|
|
460
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
461
|
+
if (fail > 0) process.exit(1);
|