@bongos/core 1.19.710 → 1.19.712
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 +62 -27
- 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 +4 -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/scripts/gds/sandbox-stage.js +56 -12
- 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/tests/profile_route.mjs +0 -1
- package/tests/sandbox_stage.mjs +111 -30
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.712",
|
|
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.712",
|
|
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.712",
|
|
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
|
|
|
@@ -44,10 +44,27 @@ const HEALTH_POLL_MS = 2_000;
|
|
|
44
44
|
// GAME-ONLY entry (src/preview-server.js, ADR 0052), so only game-rendering paths
|
|
45
45
|
// are previewable — GDS/internal-tooling diffs have nothing to look at there.
|
|
46
46
|
// src/preview-server.js + server.js are the entrypoints that render the game.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
47
|
+
// The GAME CLIENT directories the sandbox renders. These MOVED in the module
|
|
48
|
+
// carve (ADR 0083, BV1.R49 / task 1418): public/ -> modules/game/public/,
|
|
49
|
+
// src/world/ -> modules/game/world/, src/rooms/ -> modules/game/rooms/. The
|
|
50
|
+
// pre-carve spelling sat here matching NOTHING, and staging still reported
|
|
51
|
+
// success because the two entrypoints below kept syncing — so a builder staged,
|
|
52
|
+
// saw 'synced previewable surfaces', and got a sandbox serving whatever client
|
|
53
|
+
// was already on /workspace (task 1002062; found when a new /studio page 404'd).
|
|
54
|
+
const PREVIEWABLE_DIRS = ['modules/game/public', 'modules/game/world', 'modules/game/rooms'];
|
|
55
|
+
// The single directory the preview serves AT THE URL ROOT: modules/game/public/x
|
|
56
|
+
// is served as /x. pickVerifyTarget maps between the two, so it must be exactly
|
|
57
|
+
// one dir — hence its own constant rather than PREVIEWABLE_DIRS[0].
|
|
58
|
+
const PREVIEWABLE_STATIC_DIR = 'modules/game/public';
|
|
59
|
+
// The entrypoints that render the game. A sparse box (STARTER_SPARSE_PATHS) may
|
|
60
|
+
// legitimately lack these, which is why a miss here is not on its own an error.
|
|
61
|
+
const PREVIEWABLE_ENTRYPOINTS = ['server.js', 'src/preview-server.js'];
|
|
62
|
+
// DERIVED, all three of them. They used to be three hand-maintained copies of the
|
|
63
|
+
// same list, and the carve updated none of them; deriving removes the drift class
|
|
64
|
+
// rather than just this instance of it.
|
|
65
|
+
const PREVIEWABLE_PREFIXES = PREVIEWABLE_DIRS.map((d) => `${d}/`);
|
|
66
|
+
const PREVIEWABLE_FILES = new Set(PREVIEWABLE_ENTRYPOINTS);
|
|
67
|
+
const PREVIEWABLE_PATHSPECS = [...PREVIEWABLE_DIRS, ...PREVIEWABLE_ENTRYPOINTS];
|
|
51
68
|
|
|
52
69
|
// On a box, box-game-preview.service has WorkingDirectory=/workspace and serves
|
|
53
70
|
// /workspace/public — the MAIN checkout. A Claude session, though, runs in a git
|
|
@@ -316,6 +333,11 @@ function syncWorktreeToServed(deps = {}) {
|
|
|
316
333
|
// then overlay the session branch's content.
|
|
317
334
|
let synced = false;
|
|
318
335
|
let lastErr = null;
|
|
336
|
+
// task 1002062: this collapsed to a single boolean, so a run that synced ONLY
|
|
337
|
+
// the entrypoints and missed every client directory reported plain success.
|
|
338
|
+
// Track them by name — the caller warns on a missed DIRECTORY, which is the
|
|
339
|
+
// shape a stale pathspec list takes.
|
|
340
|
+
const missed = [];
|
|
319
341
|
for (const ps of PREVIEWABLE_PATHSPECS) {
|
|
320
342
|
try {
|
|
321
343
|
run('git', ['-C', servedRoot, 'checkout', '--', ps], { stdio: 'ignore' });
|
|
@@ -327,12 +349,16 @@ function syncWorktreeToServed(deps = {}) {
|
|
|
327
349
|
synced = true;
|
|
328
350
|
} catch (e) {
|
|
329
351
|
lastErr = e; // pathspec absent in the branch (sparse/missing) — skip it
|
|
352
|
+
missed.push(ps);
|
|
330
353
|
}
|
|
331
354
|
}
|
|
332
355
|
if (!synced) {
|
|
333
|
-
return { ok: false, synced: false, reason: `git checkout ${branch} -- <previewable> in ${servedRoot} synced nothing${lastErr ? ` (${lastErr.message})` : ''}` };
|
|
356
|
+
return { ok: false, synced: false, missed, reason: `git checkout ${branch} -- <previewable> in ${servedRoot} synced nothing${lastErr ? ` (${lastErr.message})` : ''}` };
|
|
334
357
|
}
|
|
335
|
-
|
|
358
|
+
// A missed ENTRYPOINT is normal on a sparse box. A missed client DIRECTORY means
|
|
359
|
+
// the sandbox is about to serve a client this stage never touched.
|
|
360
|
+
const missedDirs = missed.filter((m) => PREVIEWABLE_DIRS.includes(m));
|
|
361
|
+
return { ok: true, synced: true, branch, missed, missedDirs };
|
|
336
362
|
}
|
|
337
363
|
|
|
338
364
|
// Pick the smallest cheap-to-compare changed previewable static file to verify,
|
|
@@ -343,21 +369,26 @@ function pickVerifyTarget(deps = {}) {
|
|
|
343
369
|
const cwd = deps.cwd || process.cwd();
|
|
344
370
|
const f = deps.fs || fs;
|
|
345
371
|
try {
|
|
346
|
-
|
|
372
|
+
// task 1002062: this hardcoded 'public' three times, independently of the
|
|
373
|
+
// constants above, so it kept the pre-carve spelling even after they moved.
|
|
374
|
+
// Derived from PREVIEWABLE_STATIC_DIR now, so there is one place to change.
|
|
375
|
+
const staticDir = deps.staticDir || PREVIEWABLE_STATIC_DIR;
|
|
376
|
+
const prefix = `${staticDir}/`;
|
|
377
|
+
const out = run('git', ['status', '--porcelain', '--', staticDir], { cwd, encoding: 'utf8' });
|
|
347
378
|
let chosen = out
|
|
348
379
|
.split('\n')
|
|
349
380
|
.map((line) => line.slice(3).replace(/^"|"$/g, '').trim())
|
|
350
|
-
.find((rel) => rel && rel.startsWith(
|
|
381
|
+
.find((rel) => rel && rel.startsWith(prefix) && f.existsSync(path.join(cwd, rel)));
|
|
351
382
|
if (!chosen) {
|
|
352
383
|
// committed-only change: fall back to the diff vs the frozen origin/main
|
|
353
|
-
const diff = run('git', ['diff', '--name-only', 'origin/main...HEAD', '--',
|
|
384
|
+
const diff = run('git', ['diff', '--name-only', 'origin/main...HEAD', '--', staticDir], { cwd, encoding: 'utf8' });
|
|
354
385
|
chosen = diff
|
|
355
386
|
.split('\n')
|
|
356
387
|
.map((s) => s.trim())
|
|
357
|
-
.find((p) => p.startsWith(
|
|
388
|
+
.find((p) => p.startsWith(prefix) && f.existsSync(path.join(cwd, p)));
|
|
358
389
|
}
|
|
359
390
|
if (!chosen) return null;
|
|
360
|
-
return { relPath: chosen, urlPath: '/' + chosen.
|
|
391
|
+
return { relPath: chosen, urlPath: '/' + chosen.slice(prefix.length) };
|
|
361
392
|
} catch {
|
|
362
393
|
return null;
|
|
363
394
|
}
|
|
@@ -374,7 +405,7 @@ async function verifyServedFresh(deps = {}) {
|
|
|
374
405
|
const cwd = deps.cwd || process.cwd();
|
|
375
406
|
const base = (deps.healthUrl || HEALTH_URL).replace(/\/$/, '');
|
|
376
407
|
const target = pickVerifyTarget(deps);
|
|
377
|
-
if (!target) return { ok: true, skipped: true, reason:
|
|
408
|
+
if (!target) return { ok: true, skipped: true, reason: `no served static change to verify (e.g. only ${PREVIEWABLE_DIRS.filter((d) => d !== PREVIEWABLE_STATIC_DIR).join(' or ')} changed)` };
|
|
378
409
|
let want;
|
|
379
410
|
try {
|
|
380
411
|
want = f.readFileSync(path.join(cwd, target.relPath), 'utf8');
|
|
@@ -455,6 +486,16 @@ async function stage({ taskId } = {}, deps = {}) {
|
|
|
455
486
|
return { ok: false, hard: true, reason: `could not sync your worktree to the served checkout (${sync.reason}); the sandbox would show stale ${deps.servedRoot || BOX_SERVED_ROOT} code` };
|
|
456
487
|
}
|
|
457
488
|
if (sync.synced) console.log(` synced previewable surfaces from ${sync.branch} into ${deps.servedRoot || BOX_SERVED_ROOT}`);
|
|
489
|
+
// task 1002062 — the tell that went unsaid for 69 days. Staging "succeeded"
|
|
490
|
+
// while every game-client directory matched nothing, so the sandbox served a
|
|
491
|
+
// client the stage never touched and the only hint was a freshness-verify skip.
|
|
492
|
+
if (sync.missedDirs && sync.missedDirs.length) {
|
|
493
|
+
console.warn(
|
|
494
|
+
` ⚠ these previewable directories matched NOTHING in ${sync.branch}: ${sync.missedDirs.join(', ')}`
|
|
495
|
+
+ ' — the sandbox will serve whatever client is already on the served checkout.'
|
|
496
|
+
+ ' If this repo carved or renamed them, PREVIEWABLE_DIRS in sandbox-stage.js is stale.'
|
|
497
|
+
);
|
|
498
|
+
}
|
|
458
499
|
}
|
|
459
500
|
try {
|
|
460
501
|
ctx.restart(deps);
|
|
@@ -699,6 +740,9 @@ module.exports = {
|
|
|
699
740
|
BOX_ENV_PATH,
|
|
700
741
|
BOX_SERVED_ROOT,
|
|
701
742
|
PREVIEWABLE_PATHSPECS,
|
|
743
|
+
PREVIEWABLE_DIRS,
|
|
744
|
+
PREVIEWABLE_ENTRYPOINTS,
|
|
745
|
+
PREVIEWABLE_STATIC_DIR,
|
|
702
746
|
sessionCheckoutRoot,
|
|
703
747
|
syncWorktreeToServed,
|
|
704
748
|
pickVerifyTarget,
|
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.712'; // 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
|
|