@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
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.711",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.711",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.711",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -446,33 +446,11 @@ function parseArgv(argv = []) {
|
|
|
446
446
|
return { name, question, outArg, asJson, error: null };
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
* PURE: takes the registry row, returns `{ decision, reason }`.
|
|
455
|
-
*/
|
|
456
|
-
function gateFor(row) {
|
|
457
|
-
if (!row) return { decision: 'no-go', reason: 'no such agent in the registry' };
|
|
458
|
-
if (row.trigger_type !== 'on-demand') {
|
|
459
|
-
return {
|
|
460
|
-
decision: 'no-go',
|
|
461
|
-
reason: `trigger_type is '${row.trigger_type}', not 'on-demand' — this agent is dispatched by an event, not asked a question`,
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
// Checked before `enabled` even though the schema's flagged_not_armed CHECK
|
|
465
|
-
// makes a flagged row necessarily disabled: the two states have the same
|
|
466
|
-
// verdict and completely different fixes, and the reason is what the caller
|
|
467
|
-
// acts on.
|
|
468
|
-
if (isNonEmptyString(row.scope_violation)) {
|
|
469
|
-
return { decision: 'no-go', reason: `disarmed by the scope wall: ${row.scope_violation}` };
|
|
470
|
-
}
|
|
471
|
-
if (row.enabled !== true) {
|
|
472
|
-
return { decision: 'no-go', reason: 'the definition is present in the registry but not enabled' };
|
|
473
|
-
}
|
|
474
|
-
return { decision: 'go', reason: null };
|
|
475
|
-
}
|
|
449
|
+
// THE GATE lives in the module, not here: the same question is asked by
|
|
450
|
+
// POST /agents/:name/invoke on the other side of the trust boundary, and a gate
|
|
451
|
+
// with two implementations has two behaviours the day one is edited. Required
|
|
452
|
+
// directly from a script the way agents-sync requires the validator.
|
|
453
|
+
const { gateFor } = require('../../modules/agents/lib/gate.js');
|
|
476
454
|
|
|
477
455
|
// ---- the chain -------------------------------------------------------------
|
|
478
456
|
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.711'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// tests/agents_authoring.mjs — the decisions behind the agent WRITE surface
|
|
2
|
+
// (task 1002493, goal 1000038 Phase 2a).
|
|
3
|
+
//
|
|
4
|
+
// Four rules, each a way an authoring surface turns into an escalation, plus the
|
|
5
|
+
// pickup counter a server-side fire delivers its answer through. All pure — no
|
|
6
|
+
// database, no HTTP, no model. The routes that consume these are asserted
|
|
7
|
+
// structurally in tests/agents_routes.mjs.
|
|
8
|
+
//
|
|
9
|
+
// In tests/ rather than modules/agents/tests/ for the reason task 1002487 gave:
|
|
10
|
+
// `agents` is default:false, so a module-local suite would never run.
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/agents_authoring.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const authoring = require('../modules/agents/lib/authoring.js');
|
|
19
|
+
const { gateFor, ON_DEMAND } = require('../modules/agents/lib/gate.js');
|
|
20
|
+
const { createAnswerHold } = require('../modules/agents/lib/answer-hold.js');
|
|
21
|
+
const { createFireBudget, MAX_TRACKED_BUILDERS } = require('../modules/agents/lib/fire-budget.js');
|
|
22
|
+
const validate = require('../modules/agents/lib/validate.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function t(name, fn) {
|
|
27
|
+
try { fn(); passed += 1; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed += 1; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DB_ROW = Object.freeze({
|
|
32
|
+
name: 'librarian', title: 'The Librarian', persona: 'P',
|
|
33
|
+
trigger_type: 'on-demand', trigger_spec: {}, model_tier: 'default',
|
|
34
|
+
scope_modules: [], scope_paths: [], scope_violation: null,
|
|
35
|
+
source: 'db', provenance: 'instance', author_rank: 'metic', enabled: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log('\nrule 1 — authority fields are never read from the request:');
|
|
39
|
+
|
|
40
|
+
t('every server-owned field is refused BY NAME, with the reason', () => {
|
|
41
|
+
for (const field of Object.keys(authoring.SERVER_OWNED_FIELDS)) {
|
|
42
|
+
const hits = authoring.forbiddenFields({ name: 'x', [field]: 'anything' });
|
|
43
|
+
assert.equal(hits.length, 1, `${field} was not refused`);
|
|
44
|
+
assert.equal(hits[0].field, field);
|
|
45
|
+
assert.ok(hits[0].reason.length > 0, `${field} was refused without saying why`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
t('author_rank is on that list, and the reason names the live-rank rule', () => {
|
|
50
|
+
const [hit] = authoring.forbiddenFields({ author_rank: 'archon' });
|
|
51
|
+
assert.equal(hit.field, 'author_rank');
|
|
52
|
+
assert.match(hit.reason, /live DB rank/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
t('a clean body is clean, and a non-object body is not an error to report here', () => {
|
|
56
|
+
assert.deepEqual(authoring.forbiddenFields({ name: 'x', persona: 'p' }), []);
|
|
57
|
+
assert.deepEqual(authoring.forbiddenFields(null), []);
|
|
58
|
+
assert.deepEqual(authoring.forbiddenFields('nope'), []);
|
|
59
|
+
assert.deepEqual(authoring.forbiddenFields([{ author_rank: 'archon' }]), []);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
t('the authorable set and the server-owned set do not overlap', () => {
|
|
63
|
+
// A field in both would be decided in two places, and the one that ran last
|
|
64
|
+
// would win silently.
|
|
65
|
+
for (const f of authoring.AUTHORABLE_FIELDS) {
|
|
66
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(authoring.SERVER_OWNED_FIELDS, f),
|
|
67
|
+
`${f} is both authorable and server-owned`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
console.log('\nrule 3 — a DB-authored definition may not reach a protected surface:');
|
|
72
|
+
|
|
73
|
+
const PROTECTED_MODULES = ['government', 'lifecycle'];
|
|
74
|
+
const IS_PROTECTED = (p) => p.startsWith('src/bongos/') || p.startsWith('migrations/');
|
|
75
|
+
|
|
76
|
+
t('a protected MODULE is a hit', () => {
|
|
77
|
+
const h = authoring.protectedScopeHits({ scope_modules: ['government', 'copy-desk'] },
|
|
78
|
+
{ protectedModules: PROTECTED_MODULES, isProtectedPath: IS_PROTECTED });
|
|
79
|
+
assert.equal(h.protected, true);
|
|
80
|
+
assert.deepEqual(h.modules, ['government']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
t('a protected PATH is a hit — the same surface, spelled the other way', () => {
|
|
84
|
+
const h = authoring.protectedScopeHits({ scope_paths: ['src/bongos/auth.js', 'public/app.css'] },
|
|
85
|
+
{ protectedModules: PROTECTED_MODULES, isProtectedPath: IS_PROTECTED });
|
|
86
|
+
assert.equal(h.protected, true);
|
|
87
|
+
assert.deepEqual(h.paths, ['src/bongos/auth.js']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
t('a clean scope is clean, and so is no scope at all', () => {
|
|
91
|
+
const ctx = { protectedModules: PROTECTED_MODULES, isProtectedPath: IS_PROTECTED };
|
|
92
|
+
assert.equal(authoring.protectedScopeHits({ scope_modules: ['copy-desk'], scope_paths: ['docs/'] }, ctx).protected, false);
|
|
93
|
+
assert.equal(authoring.protectedScopeHits({}, ctx).protected, false);
|
|
94
|
+
assert.equal(authoring.protectedScopeHits(null, ctx).protected, false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
t('FAIL-CLOSED: declared paths with NO matcher are all hits', () => {
|
|
98
|
+
// "Nobody checked" must not read as "nothing was protected" — the same rule
|
|
99
|
+
// the validator's scope_paths wall applies (task 1002492).
|
|
100
|
+
const h = authoring.protectedScopeHits({ scope_paths: ['anything/at/all'] }, { protectedModules: [] });
|
|
101
|
+
assert.equal(h.protected, true);
|
|
102
|
+
assert.deepEqual(h.paths, ['anything/at/all']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
t('FAIL-CLOSED: a throwing matcher is a hit, and only an explicit false clears a path', () => {
|
|
106
|
+
const ctx = (fn) => ({ protectedModules: [], isProtectedPath: fn });
|
|
107
|
+
assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => { throw new Error('boom'); })).protected, true);
|
|
108
|
+
assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => undefined)).protected, true);
|
|
109
|
+
assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => null)).protected, true);
|
|
110
|
+
assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => 'yes')).protected, true);
|
|
111
|
+
assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => false)).protected, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
t('this wall is STRICTER than the validator, which is the point', () => {
|
|
115
|
+
// An Archon clears validate.js's Metic+ rank floor on a protected scope...
|
|
116
|
+
const verdict = validate.validateAgentDefinition(
|
|
117
|
+
{ ...DB_ROW, scope_modules: ['government'] },
|
|
118
|
+
{ authorRank: 'archon', allowedModules: ['government'], protectedModules: PROTECTED_MODULES, isProtectedPath: () => false },
|
|
119
|
+
);
|
|
120
|
+
assert.equal(verdict.ok, true, 'the rank floor admits an Archon');
|
|
121
|
+
// ...and still cannot author it over HTTP, because the reviewed path and the
|
|
122
|
+
// unreviewed one do not get the same wall.
|
|
123
|
+
assert.equal(
|
|
124
|
+
authoring.protectedScopeHits(verdict.value, { protectedModules: PROTECTED_MODULES, isProtectedPath: () => false }).protected,
|
|
125
|
+
true,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log('\nrule 4 — the sync owns its own rows:');
|
|
130
|
+
|
|
131
|
+
t('a file-sourced definition is not editable here, and the reason names the file', () => {
|
|
132
|
+
const w = authoring.writableByApi({ ...DB_ROW, source: 'file', source_path: '.claude/agents/historian.md' });
|
|
133
|
+
assert.equal(w.ok, false);
|
|
134
|
+
assert.match(w.reason, /\.claude\/agents\/historian\.md/);
|
|
135
|
+
assert.match(w.reason, /silently undone/);
|
|
136
|
+
// ...and it points at the one write that DOES work on a file row.
|
|
137
|
+
assert.match(w.reason, /enable\/disable/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
t('a db-authored definition is editable; a missing one is not', () => {
|
|
141
|
+
assert.equal(authoring.writableByApi(DB_ROW).ok, true);
|
|
142
|
+
assert.equal(authoring.writableByApi(null).ok, false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.log('\nthe merge — a PATCH is judged as a whole definition:');
|
|
146
|
+
|
|
147
|
+
t('unsent fields come from the stored row', () => {
|
|
148
|
+
const merged = authoring.mergeForValidation(DB_ROW, { title: 'Renamed' });
|
|
149
|
+
assert.equal(merged.title, 'Renamed');
|
|
150
|
+
assert.equal(merged.persona, 'P');
|
|
151
|
+
assert.equal(merged.trigger_type, 'on-demand');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
t('a partial trigger change is caught BECAUSE the whole definition is re-judged', () => {
|
|
155
|
+
// Switch to an event trigger without sending trigger_spec. Validating the
|
|
156
|
+
// delta alone would see nothing wrong; validating the merged definition sees a
|
|
157
|
+
// trigger that can never fire.
|
|
158
|
+
const merged = authoring.mergeForValidation(DB_ROW, { trigger_type: 'event' });
|
|
159
|
+
const verdict = validate.validateAgentDefinition(merged, { allowedModules: [], protectedModules: [], authorRank: 'metic' });
|
|
160
|
+
assert.equal(verdict.ok, false);
|
|
161
|
+
assert.ok(verdict.errors.some((e) => e.code === 'trigger_event_missing'), verdict.errors.map((e) => e.code).join(','));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
t('a server-owned key in the patch cannot ride in through the merge', () => {
|
|
165
|
+
const merged = authoring.mergeForValidation(DB_ROW, { enabled: true, author_rank: 'archon', source: 'file' });
|
|
166
|
+
assert.equal(merged.enabled, undefined);
|
|
167
|
+
assert.equal(merged.author_rank, undefined);
|
|
168
|
+
assert.equal(merged.source, undefined);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
t('an explicit null clears an optional field rather than being ignored', () => {
|
|
172
|
+
const merged = authoring.mergeForValidation({ ...DB_ROW, title: 'The Librarian' }, { title: null });
|
|
173
|
+
assert.equal(merged.title, null);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
console.log('\nthe gate — shared with the CLI, so the two cannot drift:');
|
|
177
|
+
|
|
178
|
+
t('off means off on the on-demand path too', () => {
|
|
179
|
+
assert.equal(gateFor({ ...DB_ROW, enabled: true }).decision, 'go');
|
|
180
|
+
const off = gateFor({ ...DB_ROW, enabled: false });
|
|
181
|
+
assert.equal(off.decision, 'no-go');
|
|
182
|
+
assert.match(off.reason, /not enabled/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
t('an event agent cannot be asked, a flagged one is named as flagged, a missing one is named', () => {
|
|
186
|
+
assert.match(gateFor({ ...DB_ROW, enabled: true, trigger_type: 'event' }).reason, /not 'on-demand'/);
|
|
187
|
+
assert.match(gateFor({ ...DB_ROW, scope_violation: 'reaches src/bongos/auth.js' }).reason, /scope wall/);
|
|
188
|
+
assert.match(gateFor(null).reason, /no such agent/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
t("the gate's trigger-type constant matches the validator's vocabulary", () => {
|
|
192
|
+
assert.ok(validate.TRIGGER_TYPES.includes(ON_DEMAND),
|
|
193
|
+
'gate.js spells a trigger type the validator does not admit');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
console.log('\nthe answer hold — a pickup counter, not a database:');
|
|
197
|
+
|
|
198
|
+
t('an answer is held for the caller and survives being read twice', () => {
|
|
199
|
+
const hold = createAnswerHold();
|
|
200
|
+
hold.put(7, 'the answer');
|
|
201
|
+
assert.equal(hold.take(7), 'the answer');
|
|
202
|
+
// A caller polling while the fire is in flight must not consume it with the
|
|
203
|
+
// first successful poll.
|
|
204
|
+
assert.equal(hold.take(7), 'the answer');
|
|
205
|
+
assert.equal(hold.take('7'), 'the answer', 'the id is keyed by value, not by type');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
t('nothing held is null, not an error', () => {
|
|
209
|
+
const hold = createAnswerHold();
|
|
210
|
+
assert.equal(hold.take(99), null);
|
|
211
|
+
assert.equal(hold.take(null), null);
|
|
212
|
+
assert.equal(hold.take(undefined), null);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
t('a runless put is refused rather than keyed under "null"', () => {
|
|
216
|
+
const hold = createAnswerHold();
|
|
217
|
+
assert.equal(hold.put(null, 'x'), false);
|
|
218
|
+
assert.equal(hold.put(undefined, 'x'), false);
|
|
219
|
+
assert.equal(hold.size(), 0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
t('it EXPIRES — an uncollected answer is a copy of what the agent read, sitting in memory', () => {
|
|
223
|
+
let clock = 1000;
|
|
224
|
+
const hold = createAnswerHold({ ttlMs: 500, now: () => clock });
|
|
225
|
+
hold.put(1, 'early');
|
|
226
|
+
clock += 499;
|
|
227
|
+
assert.equal(hold.take(1), 'early');
|
|
228
|
+
clock += 2;
|
|
229
|
+
assert.equal(hold.take(1), null);
|
|
230
|
+
assert.equal(hold.size(), 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
t('it is BOUNDED — a loop of invocations cannot grow the process', () => {
|
|
234
|
+
const hold = createAnswerHold({ maxHeld: 3 });
|
|
235
|
+
for (let i = 1; i <= 10; i++) hold.put(i, `a${i}`);
|
|
236
|
+
assert.equal(hold.size(), 3);
|
|
237
|
+
assert.equal(hold.take(1), null, 'the oldest went first');
|
|
238
|
+
assert.equal(hold.take(10), 'a10', 'the newest is still there');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
t('re-putting an entry moves it to the BACK of the eviction queue', () => {
|
|
242
|
+
// Without the delete-then-set, a refreshed entry keeps its original position
|
|
243
|
+
// and can be evicted while it is the newest thing here.
|
|
244
|
+
const hold = createAnswerHold({ maxHeld: 2 });
|
|
245
|
+
hold.put(1, 'a');
|
|
246
|
+
hold.put(2, 'b');
|
|
247
|
+
hold.put(1, 'a-again');
|
|
248
|
+
hold.put(3, 'c');
|
|
249
|
+
assert.equal(hold.take(2), null, '2 was the oldest once 1 was refreshed');
|
|
250
|
+
assert.equal(hold.take(1), 'a-again');
|
|
251
|
+
assert.equal(hold.take(3), 'c');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
console.log('\nthe fire budget — a blast radius, not an accounting:');
|
|
255
|
+
|
|
256
|
+
t('a builder gets exactly `limit` fires per window, then is refused with a retry', () => {
|
|
257
|
+
let clock = 0;
|
|
258
|
+
const budget = createFireBudget({ limit: 3, windowMs: 1000, now: () => clock });
|
|
259
|
+
for (let i = 0; i < 3; i++) assert.equal(budget.check(5).ok, true, `fire ${i + 1}`);
|
|
260
|
+
const refused = budget.check(5);
|
|
261
|
+
assert.equal(refused.ok, false);
|
|
262
|
+
assert.ok(refused.retryAfterSeconds > 0, 'a refusal has to say when to come back');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
t('the window SLIDES — it is a rate, not a quota that never refills', () => {
|
|
266
|
+
let clock = 0;
|
|
267
|
+
const budget = createFireBudget({ limit: 2, windowMs: 1000, now: () => clock });
|
|
268
|
+
budget.check(5); budget.check(5);
|
|
269
|
+
assert.equal(budget.check(5).ok, false);
|
|
270
|
+
clock += 1001;
|
|
271
|
+
assert.equal(budget.check(5).ok, true, 'the oldest fire aged out of the window');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
t('the ceiling is per BUILDER — one caller cannot exhaust another', () => {
|
|
275
|
+
const budget = createFireBudget({ limit: 1, windowMs: 1000, now: () => 0 });
|
|
276
|
+
assert.equal(budget.check(5).ok, true);
|
|
277
|
+
assert.equal(budget.check(5).ok, false);
|
|
278
|
+
assert.equal(budget.check(6).ok, true, 'a different builder has their own window');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
t('FAIL-CLOSED on an unidentified caller — never a shared "unknown" bucket', () => {
|
|
282
|
+
// A shared key is worse than no limit: one caller could exhaust it for
|
|
283
|
+
// everyone. An unattributable fire is what the ledger exists to prevent.
|
|
284
|
+
const budget = createFireBudget({ limit: 5, windowMs: 1000, now: () => 0 });
|
|
285
|
+
for (const id of [null, undefined, '']) assert.equal(budget.check(id).ok, false, String(id));
|
|
286
|
+
assert.equal(budget.size(), 0, 'no bucket was created for an unidentified caller');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
t('a numeric and a string builder id are the same builder', () => {
|
|
290
|
+
const budget = createFireBudget({ limit: 1, windowMs: 1000, now: () => 0 });
|
|
291
|
+
assert.equal(budget.check(5).ok, true);
|
|
292
|
+
assert.equal(budget.check('5').ok, false, 'pg returns bigints as strings — these must not be two windows');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
t('the bucket map is bounded, and eviction forgives the QUIETEST builder', () => {
|
|
296
|
+
let clock = 0;
|
|
297
|
+
const budget = createFireBudget({ limit: 1, windowMs: 1_000_000, now: () => (clock += 1) });
|
|
298
|
+
for (let i = 0; i < MAX_TRACKED_BUILDERS + 10; i++) budget.check(i);
|
|
299
|
+
assert.ok(budget.size() <= MAX_TRACKED_BUILDERS);
|
|
300
|
+
// The most recent spender keeps their bucket; the least recent is the one let go.
|
|
301
|
+
assert.equal(budget.check(MAX_TRACKED_BUILDERS + 9).ok, false, 'the newest spender is still tracked');
|
|
302
|
+
assert.equal(budget.check(0).ok, true, 'the quietest was forgiven, not the busiest');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
306
|
+
if (failed > 0) process.exit(1);
|
package/tests/agents_routes.mjs
CHANGED
|
@@ -20,6 +20,7 @@ const require = createRequire(import.meta.url);
|
|
|
20
20
|
const routes = require('../modules/agents/routes/agents.js');
|
|
21
21
|
const validate = require('../modules/agents/lib/validate.js');
|
|
22
22
|
const api = require('../src/module-api.js');
|
|
23
|
+
const catalog = require('../modules/government/catalog.js');
|
|
23
24
|
|
|
24
25
|
const { serializeDefinition } = routes;
|
|
25
26
|
|
|
@@ -128,28 +129,88 @@ t('null arrays degrade to [], and a missing row serializes to null', () => {
|
|
|
128
129
|
assert.equal(serializeDefinition(undefined), null);
|
|
129
130
|
});
|
|
130
131
|
|
|
131
|
-
console.log('\nthe gate —
|
|
132
|
+
console.log('\nthe gate — nothing here is anonymous, and every WRITE is rank-gated:');
|
|
132
133
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
// The surface, spelled out. A route added without a row here fails the roster
|
|
135
|
+
// check below, which is the point: a new verb must state its floor deliberately
|
|
136
|
+
// rather than inherit whatever its neighbour happened to have.
|
|
137
|
+
//
|
|
138
|
+
// The third column is the AUTHORITY ATOM a write gates on, or 'builder' for a
|
|
139
|
+
// self-gated read. Atoms rather than ranks because BV1.R105 requires it of every
|
|
140
|
+
// modules/*/routes gate — tests/government_require_permission.mjs fails the
|
|
141
|
+
// build on a module route that still spells requireRank.
|
|
142
|
+
const SURFACE = Object.freeze([
|
|
143
|
+
['get', '/agents', 'builder'],
|
|
144
|
+
['get', '/agents/:name', 'builder'],
|
|
145
|
+
['post', '/agents', 'agent.author'],
|
|
146
|
+
['patch', '/agents/:name', 'agent.author'],
|
|
147
|
+
['delete', '/agents/:name', 'agent.author'],
|
|
148
|
+
['post', '/agents/:name/enable', 'agent.arm'],
|
|
149
|
+
['post', '/agents/:name/disable', 'agent.arm'],
|
|
150
|
+
['post', '/agents/:name/invoke', 'builder'],
|
|
151
|
+
['get', '/agent-runs/:id', 'builder'],
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
t('the mounted surface is exactly the declared one — a new route needs a row here', () => {
|
|
155
|
+
const mounted = routes().stack.filter((l) => l.route)
|
|
156
|
+
.map((l) => [Object.keys(l.route.methods)[0], l.route.path]);
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
mounted.map((m) => m.join(' ')).sort(),
|
|
159
|
+
SURFACE.map(([m, p]) => `${m} ${p}`).sort(),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
t('every route mounts requireBuilder — nothing on this surface is anonymous', () => {
|
|
164
|
+
for (const layer of routes().stack.filter((l) => l.route)) {
|
|
140
165
|
const handlers = layer.route.stack.map((s) => s.handle);
|
|
141
166
|
assert.ok(handlers.includes(api.requireBuilder),
|
|
142
|
-
`${layer.route.path} does not mount requireBuilder
|
|
167
|
+
`${layer.route.path} does not mount requireBuilder`);
|
|
143
168
|
assert.ok(handlers.length >= 2, `${layer.route.path} has no handler behind its gate`);
|
|
144
169
|
}
|
|
145
170
|
});
|
|
146
171
|
|
|
147
|
-
t('
|
|
148
|
-
// The
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
172
|
+
t('EVERY WRITE carries a gate behind requireBuilder, and the reads carry none', () => {
|
|
173
|
+
// The wall this whole task is about: a write verb with no authority gate would
|
|
174
|
+
// carry no author-rank clamp and no scope wall, because both of those run
|
|
175
|
+
// inside a handler only a permitted caller reaches.
|
|
176
|
+
const layers = routes().stack.filter((l) => l.route);
|
|
177
|
+
for (const [method, path, atom] of SURFACE) {
|
|
178
|
+
const layer = layers.find((l) => l.route.path === path && l.route.methods[method]);
|
|
179
|
+
assert.ok(layer, `${method} ${path} is not mounted`);
|
|
180
|
+
const handlers = layer.route.stack.map((s) => s.handle);
|
|
181
|
+
// requirePermission returns a fresh closure per call, so it is identified by
|
|
182
|
+
// POSITION: gate, then handler. A self-gated read has exactly two layers.
|
|
183
|
+
assert.equal(handlers.length >= 3, atom !== 'builder',
|
|
184
|
+
`${method} ${path} should be ${atom === 'builder' ? 'self-gated' : `gated on ${atom}`} and is not`);
|
|
185
|
+
assert.equal(handlers[0], api.requireBuilder, `${method} ${path}: requireBuilder must come first`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
t('each write names its atom, and no gate has regressed to requireRank', () => {
|
|
190
|
+
// Two failures this catches. A modules/*/routes gate spelling requireRank
|
|
191
|
+
// fails BV1.R105 outright; and an atom the catalog does not declare makes
|
|
192
|
+
// requirePermission deny everyone, so a typo'd key reads as a locked route
|
|
193
|
+
// rather than an open one — fail-closed, but silent without this.
|
|
194
|
+
const raw = require('node:fs').readFileSync(new URL('../modules/agents/routes/agents.js', import.meta.url), 'utf8');
|
|
195
|
+
// Comments stripped first: the header explains WHY these are atoms and not
|
|
196
|
+
// ranks, and naming requireRank in that explanation must not read as using it.
|
|
197
|
+
const src = raw.replace(/^\s*\/\/.*$/gm, '');
|
|
198
|
+
assert.doesNotMatch(src, /requireRank\(/, 'a module route gate must be an authority atom (BV1.R105)');
|
|
199
|
+
for (const atom of new Set(SURFACE.map(([, , a]) => a).filter((a) => a !== 'builder'))) {
|
|
200
|
+
const uses = SURFACE.filter(([, , a]) => a === atom).length;
|
|
201
|
+
const mounted = [...src.matchAll(new RegExp(`api\\.requirePermission\\('${atom}'\\)`, 'g'))].length;
|
|
202
|
+
assert.equal(mounted, uses, `${atom}: ${uses} routes declared, ${mounted} gates mounted`);
|
|
203
|
+
assert.ok(catalog.byKey(atom), `${atom} is not declared in the permission catalog — requirePermission would deny everyone`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
t('the two agent atoms are operational and seed at the Metic floor', () => {
|
|
208
|
+
// Operational (system:false) so an instance can delegate them from the
|
|
209
|
+
// Government tab; Metic so this admits exactly who the rank gate would have.
|
|
210
|
+
for (const key of ['agent.author', 'agent.arm']) {
|
|
211
|
+
const perm = catalog.byKey(key);
|
|
212
|
+
assert.equal(perm.system, false, `${key} must be delegable`);
|
|
213
|
+
assert.equal(perm.floor, 'metic', `${key} must seed at the Metic floor`);
|
|
153
214
|
}
|
|
154
215
|
});
|
|
155
216
|
|