@bongos/core 1.19.874 → 1.19.876
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 +60 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +18 -0
- package/docs/api/openapi.json +218 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +35 -26
- package/docs/copy-registry.json +108 -27
- package/docs/module-api-changelog.md +4 -0
- package/modules/autonomy/db.js +74 -0
- package/modules/autonomy/fence.js +172 -0
- package/modules/autonomy/migrations/autonomy_001_autobongos_fence.sql +71 -0
- package/modules/autonomy/routes/autonomy.js +91 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/government/catalog.js +11 -0
- package/modules/government/migrations/government_016_autonomy_fence_manage.sql +37 -0
- package/modules/hall-ui/public/gate.html +14 -1
- package/modules/hall-ui/public/gate.js +117 -1
- package/modules/security/tests/artifact_scan.mjs +32 -9
- package/modules/security/tests/mirror.mjs +26 -4
- package/modules/security/tests/sanitize_rebuild.mjs +25 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-run.js +90 -15
- package/scripts/gds/run-unit-tests.js +30 -4
- package/src/module-api.js +1 -1
- package/tests/autobongos_fence.mjs +262 -0
- package/tests/autobongos_loop.mjs +120 -1
- package/tests/skip_is_not_pass.mjs +121 -0
- package/tests/tool_scan_engine.mjs +25 -4
- package/tests/unit_runner_teardown_abort.mjs +37 -0
|
@@ -39,10 +39,25 @@
|
|
|
39
39
|
const res = await api.request(method, `${API}${path}`, { body: body || {} });
|
|
40
40
|
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
|
|
41
41
|
const data = res.data || {};
|
|
42
|
-
if (!res.ok) throw new Error(data
|
|
42
|
+
if (!res.ok) throw new Error(errorText(data, path, res.status));
|
|
43
43
|
return data;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// errorText — the readable half of a failed write.
|
|
47
|
+
//
|
|
48
|
+
// `data.error` is the ADR 0116 envelope, an OBJECT of { code, message }, so the
|
|
49
|
+
// old `data.message || data.error` fell through to it and produced the literal
|
|
50
|
+
// string "[object Object]" in the toast. Caught rendering this page against a
|
|
51
|
+
// 501 (task 1003908) — the button reported a failure the operator could not read,
|
|
52
|
+
// which is barely better than reporting nothing.
|
|
53
|
+
function errorText(data, path, status) {
|
|
54
|
+
const env = data && data.error;
|
|
55
|
+
if (env && typeof env === 'object' && typeof env.message === 'string') return env.message;
|
|
56
|
+
if (typeof env === 'string') return env;
|
|
57
|
+
if (typeof data?.message === 'string') return data.message;
|
|
58
|
+
return `${path} → ${status}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
// ---- formatters ----------------------------------------------------------
|
|
47
62
|
|
|
48
63
|
const shortSha = (s) => (typeof s === 'string' ? s.slice(0, 7) : '');
|
|
@@ -222,6 +237,103 @@
|
|
|
222
237
|
if (body) body.textContent = `Available from the ${rankLabel('metic')} rank up.`;
|
|
223
238
|
}
|
|
224
239
|
|
|
240
|
+
// ---- the Autobongos fence (task 1003908, ADR 0307 §6) --------------------
|
|
241
|
+
//
|
|
242
|
+
// The kill switch and the goal allowlist for the unattended runner. On this page
|
|
243
|
+
// because the Gate is already where the owner decides what reaches main without
|
|
244
|
+
// a person in the loop; the held-PR list and this panel are the same question
|
|
245
|
+
// from two ends.
|
|
246
|
+
//
|
|
247
|
+
// ARCHON ONLY, and the panel simply is not rendered for anyone else. That is
|
|
248
|
+
// cosmetic: POST /autonomy/fence and the two allowlist routes carry
|
|
249
|
+
// `autonomy.fence.manage`, which the catalog declares system:true + archon, so
|
|
250
|
+
// it cannot be granted to a custom rank. The wall is the server's.
|
|
251
|
+
|
|
252
|
+
function renderFenceState(fence) {
|
|
253
|
+
const on = fence.enabled === true;
|
|
254
|
+
const el = document.getElementById('fence-state');
|
|
255
|
+
el.innerHTML = `
|
|
256
|
+
<div class="ov-foot" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
|
257
|
+
<strong>${on ? 'Running' : 'Stopped'}</strong>
|
|
258
|
+
<span>${on ? 'The runner may claim and work allowlisted goals.' : escapeHtml(fence.paused_reason || 'The runner is doing nothing.')}</span>
|
|
259
|
+
<button type="button" id="fence-toggle" class="btn">${on ? 'Stop the runner' : 'Start the runner'}</button>
|
|
260
|
+
</div>`;
|
|
261
|
+
document.getElementById('fence-toggle').addEventListener('click', async () => {
|
|
262
|
+
// Turning it OFF asks why. Stopping an unattended machine is the kind of act
|
|
263
|
+
// someone reconstructs weeks later, and "halted" answers none of their
|
|
264
|
+
// questions. Turning it ON needs no reason — the allowlist already is one.
|
|
265
|
+
let reason = null;
|
|
266
|
+
if (on) {
|
|
267
|
+
reason = window.prompt('Why are you stopping the runner? (shown to anyone who asks why it is idle)');
|
|
268
|
+
if (reason === null) return; // cancelled — not the same as an empty reason
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
await sendJson('POST', '/autonomy/fence', { enabled: !on, reason: reason || undefined });
|
|
272
|
+
toast(on ? 'Runner stopped.' : 'Runner started.');
|
|
273
|
+
await loadFence();
|
|
274
|
+
} catch (err) { toast(err.message || 'Could not change the switch.'); }
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderFenceGoals(goals) {
|
|
279
|
+
const el = document.getElementById('fence-goals');
|
|
280
|
+
const rows = (goals || []).map((g) => `
|
|
281
|
+
<li class="ov-row" style="display:flex;align-items:center;gap:12px;">
|
|
282
|
+
<a href="/builders#/goal/${encodeURIComponent(g.goal_id)}">Goal ${escapeHtml(String(g.goal_id))}</a>
|
|
283
|
+
<span style="flex:1;">${escapeHtml(g.note || '')}</span>
|
|
284
|
+
<button type="button" class="btn" data-revoke="${escapeHtml(String(g.goal_id))}">Remove</button>
|
|
285
|
+
</li>`).join('');
|
|
286
|
+
el.innerHTML = `
|
|
287
|
+
<h3 class="scroll__h" style="font-size:1rem;">Allowlisted goals</h3>
|
|
288
|
+
${goals && goals.length ? `<ul class="ov-rows">${rows}</ul>`
|
|
289
|
+
: emptyStateHtml('No goal is allowlisted, so the runner has nothing it may work on.')}
|
|
290
|
+
<form id="fence-add" class="ov-foot" style="display:flex;gap:8px;align-items:center;margin-top:12px;">
|
|
291
|
+
<label for="fence-goal-id">Allow goal</label>
|
|
292
|
+
<input type="number" id="fence-goal-id" min="1" step="1" required placeholder="1000119">
|
|
293
|
+
<input type="text" id="fence-goal-note" maxlength="500" placeholder="why (optional)">
|
|
294
|
+
<button type="submit" class="btn">Add</button>
|
|
295
|
+
</form>`;
|
|
296
|
+
|
|
297
|
+
document.getElementById('fence-add').addEventListener('submit', async (e) => {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
const goalId = Number(document.getElementById('fence-goal-id').value);
|
|
300
|
+
if (!Number.isSafeInteger(goalId) || goalId <= 0) { toast('Enter a goal number.'); return; }
|
|
301
|
+
try {
|
|
302
|
+
await sendJson('POST', '/autonomy/fence/goals', { goal_id: goalId, note: document.getElementById('fence-goal-note').value || undefined });
|
|
303
|
+
toast(`Goal ${goalId} allowlisted.`);
|
|
304
|
+
await loadFence();
|
|
305
|
+
} catch (err) { toast(err.message || 'Could not add that goal.'); }
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
for (const btn of el.querySelectorAll('[data-revoke]')) {
|
|
309
|
+
btn.addEventListener('click', async () => {
|
|
310
|
+
const goalId = btn.getAttribute('data-revoke');
|
|
311
|
+
try {
|
|
312
|
+
await sendJson('DELETE', `/autonomy/fence/goals/${encodeURIComponent(goalId)}`);
|
|
313
|
+
toast(`Goal ${goalId} removed.`);
|
|
314
|
+
await loadFence();
|
|
315
|
+
} catch (err) { toast(err.message || 'Could not remove that goal.'); }
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function loadFence() {
|
|
321
|
+
let fence;
|
|
322
|
+
try {
|
|
323
|
+
fence = await fetchJson('/autonomy/fence');
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err && err.kind === '401') return;
|
|
326
|
+
// A fence the HALL cannot read is only a rendering problem — the RUNNER
|
|
327
|
+
// reads it separately and refuses on the same failure. Say so plainly rather
|
|
328
|
+
// than showing a switch whose position is a guess.
|
|
329
|
+
document.getElementById('fence-state').innerHTML = emptyStateHtml('Could not read the runner switch just now.');
|
|
330
|
+
document.getElementById('fence-goals').innerHTML = '';
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
renderFenceState(fence);
|
|
334
|
+
renderFenceGoals(fence.goals);
|
|
335
|
+
}
|
|
336
|
+
|
|
225
337
|
async function init() {
|
|
226
338
|
// Confirm the viewer's rank for a friendly sealed notice; the server gates the page
|
|
227
339
|
// + every endpoint regardless (cosmetic defence-in-depth).
|
|
@@ -242,6 +354,10 @@
|
|
|
242
354
|
|
|
243
355
|
document.getElementById('gate-sub').textContent = 'Changes touching protected build surfaces, held for your approval.';
|
|
244
356
|
document.getElementById('gate-body').hidden = false;
|
|
357
|
+
if (rank === 'archon') {
|
|
358
|
+
document.getElementById('fence-panel').hidden = false;
|
|
359
|
+
await loadFence();
|
|
360
|
+
}
|
|
245
361
|
await load();
|
|
246
362
|
}
|
|
247
363
|
|
|
@@ -31,10 +31,35 @@ const { createQuarantine, siteRefusal, isSyncBacked, isInside, REPO_ROOT, CAPS }
|
|
|
31
31
|
const { buildManifest, verifyUnchanged, CapExceeded, KIND } = manifest;
|
|
32
32
|
const { validateTargetUrl, hardeningConfig, hardenedEnv, parseLsFiles, fetchArtifact, blockedAddress, assertHostResolvesPublic } = fetchMod;
|
|
33
33
|
|
|
34
|
+
|
|
35
|
+
// Test hook (task 1003993): forces the platform-cannot-symlink branch so the skip path is
|
|
36
|
+
// reachable on a box where symlinks work. Throws exactly where fs.symlinkSync would.
|
|
37
|
+
function symlinkOrRefuse(target, link) {
|
|
38
|
+
if (process.env.BONGOS_TEST_FORCE_NO_SYMLINK === '1') throw new Error('EPERM (forced by BONGOS_TEST_FORCE_NO_SYMLINK)');
|
|
39
|
+
fs.symlinkSync(target, link);
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
let passed = 0;
|
|
35
43
|
let failed = 0;
|
|
44
|
+
let skipped = 0;
|
|
45
|
+
|
|
46
|
+
// A case that did not run is NOT a case that passed (task 1003993). A bare `return` from a
|
|
47
|
+
// case that found its platform unable to do the thing left the harness seeing a callback that
|
|
48
|
+
// did not throw — so it printed `ok` and counted a pass for work never attempted. Returning
|
|
49
|
+
// skipCase(why) instead is the same one-line exit, and it tells the truth.
|
|
50
|
+
const SKIP = Symbol('skip');
|
|
51
|
+
const skipCase = (why) => ({ [SKIP]: why });
|
|
52
|
+
function noteSkip(name, why) {
|
|
53
|
+
skipped++;
|
|
54
|
+
console.log(` SKIP ${name}`);
|
|
55
|
+
console.log(` (not run: ${why})`);
|
|
56
|
+
}
|
|
36
57
|
async function test(name, fn) {
|
|
37
|
-
try {
|
|
58
|
+
try {
|
|
59
|
+
const r = await fn();
|
|
60
|
+
if (r && r[SKIP]) { noteSkip(name, r[SKIP]); return; }
|
|
61
|
+
passed++; console.log(` ok ${name}`);
|
|
62
|
+
}
|
|
38
63
|
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
39
64
|
}
|
|
40
65
|
|
|
@@ -182,10 +207,9 @@ await test('a symlink is recorded by its link text and its target is NEVER opene
|
|
|
182
207
|
fs.writeFileSync(secret, 'AKIA-SUPER-SECRET-TOKEN\n');
|
|
183
208
|
fs.writeFileSync(path.join(root, 'README.md'), 'hi\n');
|
|
184
209
|
try {
|
|
185
|
-
|
|
210
|
+
symlinkOrRefuse(secret, path.join(root, 'stolen'));
|
|
186
211
|
} catch {
|
|
187
|
-
|
|
188
|
-
return;
|
|
212
|
+
return skipCase('this platform/account cannot create symlinks');
|
|
189
213
|
}
|
|
190
214
|
const m = await buildManifest(root, { maxFiles: 100, maxBytes: 1e6 });
|
|
191
215
|
const link = m.entries.find((e) => e.path === 'stolen');
|
|
@@ -542,11 +566,10 @@ await test('symlinks count against maxFiles too — a cap on regular files only
|
|
|
542
566
|
const root = mkTmp();
|
|
543
567
|
fs.writeFileSync(path.join(root, 'a.txt'), 'a');
|
|
544
568
|
try {
|
|
545
|
-
|
|
546
|
-
|
|
569
|
+
symlinkOrRefuse(path.join(root, 'a.txt'), path.join(root, 'l1'));
|
|
570
|
+
symlinkOrRefuse(path.join(root, 'a.txt'), path.join(root, 'l2'));
|
|
547
571
|
} catch {
|
|
548
|
-
|
|
549
|
-
return;
|
|
572
|
+
return skipCase('this platform/account cannot create symlinks');
|
|
550
573
|
}
|
|
551
574
|
await assert.rejects(() => buildManifest(root, { maxFiles: 2, maxBytes: 1e6 }), /entry cap/);
|
|
552
575
|
});
|
|
@@ -588,5 +611,5 @@ await test('CAPS are finite — no accidental unlimited default', async () => {
|
|
|
588
611
|
|
|
589
612
|
for (const d of tmpDirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
590
613
|
|
|
591
|
-
console.log(`\nartifact_scan: ${passed} passed, ${failed} failed`);
|
|
614
|
+
console.log(`\nartifact_scan: ${passed} passed, ${failed} failed, ${skipped} skipped`);
|
|
592
615
|
if (failed > 0) process.exit(1);
|
|
@@ -35,14 +35,35 @@ const { scrubUntrusted, newFenceDelim, FENCE_MARKER, DANGEROUS_CHARS } = fenceLo
|
|
|
35
35
|
|
|
36
36
|
let passed = 0;
|
|
37
37
|
let failed = 0;
|
|
38
|
+
let skipped = 0;
|
|
39
|
+
|
|
40
|
+
// A case that did not run is NOT a case that passed (task 1003993). A bare `return` from a
|
|
41
|
+
// case that found its platform unable to do the thing left the harness seeing a callback that
|
|
42
|
+
// did not throw — so it printed `ok` and counted a pass for work never attempted. Returning
|
|
43
|
+
// skipCase(why) instead is the same one-line exit, and it tells the truth.
|
|
44
|
+
const SKIP = Symbol('skip');
|
|
45
|
+
const skipCase = (why) => ({ [SKIP]: why });
|
|
46
|
+
function noteSkip(name, why) {
|
|
47
|
+
skipped++;
|
|
48
|
+
console.log(` SKIP ${name}`);
|
|
49
|
+
console.log(` (not run: ${why})`);
|
|
50
|
+
}
|
|
38
51
|
async function test(name, fn) {
|
|
39
|
-
try {
|
|
52
|
+
try {
|
|
53
|
+
const r = await fn();
|
|
54
|
+
if (r && r[SKIP]) { noteSkip(name, r[SKIP]); return; }
|
|
55
|
+
passed++; console.log(` ok ${name}`);
|
|
56
|
+
}
|
|
40
57
|
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
// Symlink creation needs privileges on some Windows accounts — same skip idiom
|
|
44
61
|
// as artifact_scan.mjs so the suite stays green everywhere.
|
|
45
62
|
function canSymlink(dir) {
|
|
63
|
+
// Test hook (task 1003993): the skip branch is unreachable on any box that CAN symlink,
|
|
64
|
+
// which is every CI runner — exactly why the old fake-pass went unnoticed for so long.
|
|
65
|
+
// tests/skip_is_not_pass.mjs sets this to exercise the skip path anywhere.
|
|
66
|
+
if (process.env.BONGOS_TEST_FORCE_NO_SYMLINK === '1') return false;
|
|
46
67
|
const target = path.join(dir, 'symlink-probe-target');
|
|
47
68
|
const link = path.join(dir, 'symlink-probe-link');
|
|
48
69
|
try {
|
|
@@ -176,7 +197,7 @@ async function run() {
|
|
|
176
197
|
const rel = `docs/${rlo}evil.md`;
|
|
177
198
|
let wrote = true;
|
|
178
199
|
try { writePristine(pen, rel, 'body'); } catch { wrote = false; }
|
|
179
|
-
if (!wrote)
|
|
200
|
+
if (!wrote) return skipCase('this filesystem refuses bidi filenames');
|
|
180
201
|
const res = await buildMirror({
|
|
181
202
|
pristineDir: pen.pristineDir, mirrorDir: pen.mirrorDir, quarantineRoot: pen.root,
|
|
182
203
|
entries: [fileEntry(rel, 4)],
|
|
@@ -313,7 +334,8 @@ async function run() {
|
|
|
313
334
|
} finally { pen.cleanup(); }
|
|
314
335
|
});
|
|
315
336
|
} else {
|
|
316
|
-
|
|
337
|
+
noteSkip('a REAL on-disk symlink whose realpath escapes the pen is refused (defense in depth)',
|
|
338
|
+
'this platform/account cannot create symlinks');
|
|
317
339
|
}
|
|
318
340
|
|
|
319
341
|
// ---------- the index: the record downstream stages reduce over ----------
|
|
@@ -352,5 +374,5 @@ function* walkFiles(dir) {
|
|
|
352
374
|
}
|
|
353
375
|
|
|
354
376
|
await run();
|
|
355
|
-
console.log(`\nmirror: ${passed} passed, ${failed} failed`);
|
|
377
|
+
console.log(`\nmirror: ${passed} passed, ${failed} failed, ${skipped} skipped`);
|
|
356
378
|
if (failed > 0) process.exit(1);
|
|
@@ -25,11 +25,23 @@ const { excerpt } = require('../artifact-scan/detectors/shared.js');
|
|
|
25
25
|
|
|
26
26
|
let passed = 0;
|
|
27
27
|
let failed = 0;
|
|
28
|
+
let skipped = 0;
|
|
28
29
|
async function test(name, fn) {
|
|
29
30
|
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
30
31
|
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// A case that did not run is NOT a case that passed (task 1003993). This prints SKIP rather
|
|
35
|
+
// than `ok` and counts separately, so the difference between "verified" and "not attempted"
|
|
36
|
+
// survives into the summary line and into run-unit-tests.js's roll-up. It takes the same
|
|
37
|
+
// `name` the real case would have used, because "which coverage did I lose?" is the only
|
|
38
|
+
// question worth answering here.
|
|
39
|
+
function skip(name, why) {
|
|
40
|
+
skipped++;
|
|
41
|
+
console.log(` SKIP ${name}`);
|
|
42
|
+
console.log(` (not run: ${why})`);
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
const RAW_DANGEROUS_RE = new RegExp(
|
|
34
46
|
'[\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2060\\u2066-\\u2069\\ufeff]|[\\u{e0000}-\\u{e007f}]', 'u');
|
|
35
47
|
|
|
@@ -384,6 +396,11 @@ await test('a failed build leaves NO partial sanitized/ tree, and a retry is sti
|
|
|
384
396
|
// Symlink creation needs privileges on some Windows accounts — same skip idiom
|
|
385
397
|
// as mirror.mjs / artifact_scan.mjs so the suite stays green everywhere.
|
|
386
398
|
function canSymlink(dir) {
|
|
399
|
+
// A test hook, because the branch below is unreachable on any box that CAN symlink — which
|
|
400
|
+
// is every CI runner and most dev machines, and is precisely why the old fake-pass survived
|
|
401
|
+
// so long unnoticed. tests/skip_is_not_pass.mjs sets this to exercise the skip path on a
|
|
402
|
+
// platform where the real probe succeeds.
|
|
403
|
+
if (process.env.BONGOS_TEST_FORCE_NO_SYMLINK === '1') return false;
|
|
387
404
|
const target = path.join(dir, 'probe-target');
|
|
388
405
|
const link = path.join(dir, 'probe-link');
|
|
389
406
|
try {
|
|
@@ -434,9 +451,13 @@ function canSymlink(dir) {
|
|
|
434
451
|
} finally { pen.cleanup(); fs.rmSync(farAway, { recursive: true, force: true }); }
|
|
435
452
|
});
|
|
436
453
|
} else {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
454
|
+
// These two are the ONLY coverage of physical confinement, so losing them silently is
|
|
455
|
+
// how task 1003848 reached CI red while this box read 760/760: a change to isInside()
|
|
456
|
+
// collapsed the lexical/physical staging in sanitize.js, and the cases that would have
|
|
457
|
+
// caught it had been reporting `ok` without running for as long as anyone could remember.
|
|
458
|
+
const why = 'this platform/account cannot create symlinks';
|
|
459
|
+
skip('physical confinement: a real symlink the manifest calls a "file" is refused, and its target never reaches sanitized/', why);
|
|
460
|
+
skip('physical confinement: a symlink whose realpath escapes the QUARANTINE entirely is refused', why);
|
|
440
461
|
}
|
|
441
462
|
}
|
|
442
463
|
|
|
@@ -495,5 +516,5 @@ await test('an artifact with no docs still yields an honest spec — the no-docs
|
|
|
495
516
|
});
|
|
496
517
|
|
|
497
518
|
console.log('');
|
|
498
|
-
console.log(`${passed} passed, ${failed} failed`);
|
|
519
|
+
console.log(`${passed} passed, ${failed} failed, ${skipped} skipped`);
|
|
499
520
|
if (failed > 0) process.exit(1);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.876",
|
|
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.876",
|
|
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.876",
|
|
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",
|
|
@@ -6,15 +6,18 @@
|
|
|
6
6
|
// one worker → record. No model anywhere in this file; every branch it takes is
|
|
7
7
|
// a branch a person can read.
|
|
8
8
|
//
|
|
9
|
-
// node scripts/gds/autobongos-run.js
|
|
10
|
-
// node scripts/gds/autobongos-run.js --goal 1000119
|
|
11
|
-
// node scripts/gds/autobongos-run.js --
|
|
9
|
+
// node scripts/gds/autobongos-run.js # every allowlisted goal
|
|
10
|
+
// node scripts/gds/autobongos-run.js --goal 1000119 # narrow to one of them
|
|
11
|
+
// node scripts/gds/autobongos-run.js --max-tasks 5
|
|
12
|
+
// node scripts/gds/autobongos-run.js --dry-run # pick only, claim nothing
|
|
12
13
|
//
|
|
13
14
|
// SCOPE, and what deliberately is NOT here:
|
|
14
|
-
// · the ALLOWLIST
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// · the ALLOWLIST and the KILL SWITCH live in the DATABASE (task 1003908,
|
|
16
|
+
// ADR 0307 §6) and are read at step 0 of every iteration. `--goal` and
|
|
17
|
+
// AUTOBONGOS_GOALS survive only as a NARROWING filter — they can take goals
|
|
18
|
+
// away from the allowlist and can never add one, because a setting on this
|
|
19
|
+
// machine is editable by the very bypassPermissions worker the allowlist
|
|
20
|
+
// exists to bound.
|
|
18
21
|
// · CONFIRMING a ship against the ledger is autobongos-verify.js (task
|
|
19
22
|
// 1003903), called at step 6. A worker's `shipped` is a CLAIM — recorded here
|
|
20
23
|
// as `claims_shipped` so nothing can mistake it for a fact — and the ledger
|
|
@@ -33,6 +36,12 @@ const {
|
|
|
33
36
|
buildWorkerArgs, buildWorkerPrompt, classifyRun, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
|
|
34
37
|
} = require('./autobongos-loop.js');
|
|
35
38
|
const { verifyShip } = require('./autobongos-verify.js');
|
|
39
|
+
const fence = require('../../modules/autonomy/fence.js');
|
|
40
|
+
// The ADR 0043 protected-path registry, reused rather than re-derived. scripts/
|
|
41
|
+
// sits outside the kernel boundary, so this direct core require is allowed here
|
|
42
|
+
// where it would not be inside modules/ — which is exactly why fence.js takes the
|
|
43
|
+
// hits as an argument instead of matching them itself.
|
|
44
|
+
const { matchProtected } = require('../../src/bongos/permission-path-check');
|
|
36
45
|
|
|
37
46
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
38
47
|
|
|
@@ -51,6 +60,10 @@ function readArgv(argv) {
|
|
|
51
60
|
if (!out.goals.length && process.env.AUTOBONGOS_GOALS) {
|
|
52
61
|
out.goals = String(process.env.AUTOBONGOS_GOALS).split(',').map((g) => Number(g.trim())).filter(Boolean);
|
|
53
62
|
}
|
|
63
|
+
// NOTE the semantics changed in task 1003908: `out.goals` is a REQUESTED filter,
|
|
64
|
+
// intersected with the database allowlist. An empty list no longer means "refuse
|
|
65
|
+
// to run" — it means "every allowlisted goal" — because the thing that refuses
|
|
66
|
+
// when nothing is permitted is now the fence, which this machine cannot edit.
|
|
54
67
|
return out;
|
|
55
68
|
}
|
|
56
69
|
|
|
@@ -202,6 +215,33 @@ function worktreeName(taskId) {
|
|
|
202
215
|
return `autobongos-${taskId}-${crypto.randomBytes(3).toString('hex')}`;
|
|
203
216
|
}
|
|
204
217
|
|
|
218
|
+
// readFence — the two facts the fence is made of, in one place.
|
|
219
|
+
//
|
|
220
|
+
// BOTH come from the server. `GET /autonomy/fence` is the kill switch and the
|
|
221
|
+
// allowlist; `GET /me`'s `grader_bypass` is ADR 0041's global grader killswitch,
|
|
222
|
+
// resolved server-side from the server's own env. Neither is readable from a file
|
|
223
|
+
// on this machine, and that is the entire point — see modules/autonomy/fence.js.
|
|
224
|
+
//
|
|
225
|
+
// A failure of EITHER read returns the shape that refuses. An unreachable
|
|
226
|
+
// instance and a thrown kill switch are different facts, but they license the
|
|
227
|
+
// same action: do nothing.
|
|
228
|
+
async function readFence(deps = {}) {
|
|
229
|
+
const api = deps.api || await (require('./cli-lib').cliClient)();
|
|
230
|
+
let raw = null;
|
|
231
|
+
let graderBypassed = null;
|
|
232
|
+
try {
|
|
233
|
+
const r = await api.autonomy.getAutonomyFence();
|
|
234
|
+
if (r && r.ok) raw = r.data;
|
|
235
|
+
} catch (_) { /* unreadable → refuses below */ }
|
|
236
|
+
try {
|
|
237
|
+
const me = await api.me.getMe();
|
|
238
|
+
// Strictly a boolean or nothing. `undefined` from an older server is NOT
|
|
239
|
+
// `false`: it means this instance cannot tell us, which refuses.
|
|
240
|
+
if (me && me.ok && typeof me.data.grader_bypass === 'boolean') graderBypassed = me.data.grader_bypass;
|
|
241
|
+
} catch (_) { /* unreadable → refuses below */ }
|
|
242
|
+
return { raw, graderBypassed };
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
async function iteration(opts, deps = {}) {
|
|
206
246
|
const runCmd = deps.run || run;
|
|
207
247
|
const spawnWork = deps.spawnWorker || spawnWorker;
|
|
@@ -209,17 +249,53 @@ async function iteration(opts, deps = {}) {
|
|
|
209
249
|
const pick = deps.pickTask || pickTask;
|
|
210
250
|
const log = deps.record || record;
|
|
211
251
|
|
|
252
|
+
// 0. THE FENCE (task 1003908, ADR 0307 §6). Before the gauge, because three of
|
|
253
|
+
// its four gates make every task refusable and there is no sense paying for a
|
|
254
|
+
// capacity read to discover that. Before anything is picked, claimed or spawned,
|
|
255
|
+
// because a fence checked after the fact is not a fence.
|
|
256
|
+
const { raw, graderBypassed } = deps.readFence ? await deps.readFence(deps) : await readFence(deps);
|
|
257
|
+
// NOT named `run`. The module-level exec helper above is called `run`, and a
|
|
258
|
+
// `const run` anywhere in this function hoists into a Temporal Dead Zone that
|
|
259
|
+
// covers the WHOLE body — including `deps.run || run` at the top, which is the
|
|
260
|
+
// PRODUCTION call shape (main() passes no deps). That shadowing threw
|
|
261
|
+
// `ReferenceError: Cannot access 'run' before initialization` on the first line
|
|
262
|
+
// of every real iteration while every test stayed green, because the harness
|
|
263
|
+
// always injected deps.run. Caught by the grader on task 1003908; the test that
|
|
264
|
+
// now drives iteration() with no deps is the actual fix — this rename is half.
|
|
265
|
+
const gate = fence.decideRun({ fence: raw, requestedGoals: opts.goals, graderBypassed });
|
|
266
|
+
if (!gate.go) {
|
|
267
|
+
return log({ event: 'fenced', code: gate.code, reason: gate.reason, refused_goals: gate.refusedGoals || [] });
|
|
268
|
+
}
|
|
269
|
+
|
|
212
270
|
// 1. The gauge. Never start a task we cannot pay for out of the window.
|
|
213
271
|
const g = readGauge();
|
|
214
272
|
if (!g.decision.go) {
|
|
215
273
|
return log({ event: 'hold', go: false, unknown: !!g.decision.unknown, reason: g.decision.reason, sleep_until: g.decision.sleepUntil ?? null });
|
|
216
274
|
}
|
|
217
275
|
|
|
218
|
-
// 2. Pick
|
|
219
|
-
|
|
276
|
+
// 2. Pick — from the ALLOWLISTED goals, never from what the command line asked
|
|
277
|
+
// for. gate.goals is already the intersection.
|
|
278
|
+
const picked = await pick(gate.goals, deps.pickDeps || {});
|
|
220
279
|
if (picked.error) return log({ event: 'pick_failed', reason: picked.error });
|
|
221
280
|
if (picked.none) return log({ event: 'nothing_claimable', goals: opts.goals });
|
|
222
281
|
const task = picked.task;
|
|
282
|
+
|
|
283
|
+
// 2b. The per-TASK half of the fence. sequence.js's halt matrix already refuses
|
|
284
|
+
// a protected-path task, and this re-asks anyway — not from distrust of the
|
|
285
|
+
// matrix, but because the matrix is a shared component with its own callers and
|
|
286
|
+
// the fence must not be a property of whoever happens to be picking. A gate that
|
|
287
|
+
// only holds while a collaborator keeps calling it is not a gate.
|
|
288
|
+
//
|
|
289
|
+
// Both halves read the task's DECLARED touches[], which is a claim, not a fact —
|
|
290
|
+
// see the header of modules/autonomy/fence.js for why that is acceptable as the
|
|
291
|
+
// OUTER of three layers and would not be as the only one.
|
|
292
|
+
const taskGate = fence.decideTask({
|
|
293
|
+
task, goals: gate.goals, protectedHits: matchProtected(task.touches || []),
|
|
294
|
+
});
|
|
295
|
+
if (!taskGate.go) {
|
|
296
|
+
return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: task.id, goal_id: picked.goalId, globs: taskGate.globs || [] });
|
|
297
|
+
}
|
|
298
|
+
|
|
223
299
|
if (opts.dryRun) return log({ event: 'dry_run', task_id: task.id, title: task.title, goal_id: picked.goalId });
|
|
224
300
|
|
|
225
301
|
// 3. The task's own working tree.
|
|
@@ -290,11 +366,10 @@ async function iteration(opts, deps = {}) {
|
|
|
290
366
|
|
|
291
367
|
async function main(argv) {
|
|
292
368
|
const opts = readArgv(argv);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
369
|
+
// No "name a goal" refusal here any more. It used to be the only allowlist, and
|
|
370
|
+
// it lived on the machine being fenced; the database owns that now, and an empty
|
|
371
|
+
// `--goal` means "every allowlisted goal" rather than "anything claimable". The
|
|
372
|
+
// refusal did not disappear — it moved somewhere the runner host cannot edit.
|
|
298
373
|
for (let i = 0; i < opts.maxTasks; i++) {
|
|
299
374
|
const row = await iteration(opts);
|
|
300
375
|
if (opts.json) console.log(JSON.stringify(row));
|
|
@@ -314,4 +389,4 @@ if (require.main === module) {
|
|
|
314
389
|
});
|
|
315
390
|
}
|
|
316
391
|
|
|
317
|
-
module.exports = { readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, worktreeName };
|
|
392
|
+
module.exports = { readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, worktreeName, readFence };
|
|
@@ -213,12 +213,27 @@ const KNOWN_FAILING = new Set([
|
|
|
213
213
|
// suites set exitCode rather than hard-exiting, so they do not hit this.
|
|
214
214
|
const LIBUV_TEARDOWN_ABORT = /Assertion failed: !\(handle->flags & UV_HANDLE_CLOSING\)/;
|
|
215
215
|
|
|
216
|
+
// A suite that reports honest skips ends `N passed, 0 failed, M skipped` (task 1003993), so
|
|
217
|
+
// the optional suffix is part of a clean verdict. Without it, the first suite to start telling
|
|
218
|
+
// the truth about unrun coverage would have its clean verdict read as unrecognised and its
|
|
219
|
+
// Windows teardown abort counted as a real failure — a fix for one honesty bug creating another.
|
|
220
|
+
const CLEAN_VERDICT_RE = /\b[1-9]\d* passed, 0 failed(?:, \d+ skipped)?\s*$/;
|
|
221
|
+
|
|
222
|
+
// ` SKIP <name>` from a suite's own stdout. Counted here rather than parsed out of the summary
|
|
223
|
+
// line so a suite that prints skips without a summary suffix is still surfaced.
|
|
224
|
+
const SKIP_LINE_RE = /^\s*SKIP\b/;
|
|
225
|
+
|
|
226
|
+
function countSkips(stdout) {
|
|
227
|
+
if (!stdout) return 0;
|
|
228
|
+
return stdout.split('\n').filter((l) => SKIP_LINE_RE.test(l)).length;
|
|
229
|
+
}
|
|
230
|
+
|
|
216
231
|
function isCleanTeardownAbort(stdout, stderr) {
|
|
217
232
|
if (!LIBUV_TEARDOWN_ABORT.test(stderr)) return false;
|
|
218
233
|
// Line-leading only: a passing case titled "... is FAIL-CLOSED" is not a failure.
|
|
219
234
|
if (/^\s*FAIL\b/m.test(stdout)) return false;
|
|
220
235
|
const lastLine = stdout.trim().split('\n').pop();
|
|
221
|
-
return
|
|
236
|
+
return CLEAN_VERDICT_RE.test(lastLine);
|
|
222
237
|
}
|
|
223
238
|
|
|
224
239
|
// The failing tests as ONE line, for the `unit-report` commit status the workflow
|
|
@@ -427,9 +442,13 @@ function main() {
|
|
|
427
442
|
|
|
428
443
|
const failures = [];
|
|
429
444
|
const teardownAborts = [];
|
|
445
|
+
// A GREEN suite that skipped cases is the shape this roll-up used to hide: the suite said so
|
|
446
|
+
// on its own stdout, which was discarded on success, so the only visible line was `ok` and a
|
|
447
|
+
// session reading the roll-up could not tell exercised coverage from absent coverage.
|
|
448
|
+
const skipped = [];
|
|
430
449
|
const runOne = (displayName, filePath) => {
|
|
431
450
|
try {
|
|
432
|
-
execFileSync(process.execPath, [filePath], {
|
|
451
|
+
const out = execFileSync(process.execPath, [filePath], {
|
|
433
452
|
// task 1002644: stdout used to be IGNORED, so the failing assertion (test
|
|
434
453
|
// files print their FAIL lines to stdout) was discarded and the only
|
|
435
454
|
// context shown was the last 3 STDERR lines — often stale diagnostic
|
|
@@ -441,7 +460,9 @@ function main() {
|
|
|
441
460
|
timeout: perTestTimeout,
|
|
442
461
|
env: { ...process.env, NODE_ENV: 'test' },
|
|
443
462
|
});
|
|
444
|
-
|
|
463
|
+
const n = countSkips(out ? out.toString() : '');
|
|
464
|
+
if (n) skipped.push({ name: displayName, n });
|
|
465
|
+
console.log(` ok ${displayName}${n ? ` (${n} SKIPPED — not run on this platform)` : ''}`);
|
|
445
466
|
} catch (err) {
|
|
446
467
|
const why = err.killed ? 'TIMEOUT' : `exit ${err.status}`;
|
|
447
468
|
const stdoutText = (err.stdout ? err.stdout.toString() : '').trim();
|
|
@@ -492,6 +513,11 @@ function main() {
|
|
|
492
513
|
if (teardownAborts.length) {
|
|
493
514
|
console.log(`${label}: ${teardownAborts.length} passed but aborted in libuv teardown (not failures; task 1003845) — ${teardownAborts.join(', ')}`);
|
|
494
515
|
}
|
|
516
|
+
if (skipped.length) {
|
|
517
|
+
const n = skipped.reduce((sum, s) => sum + s.n, 0);
|
|
518
|
+
console.log(`${label}: ${n} case(s) NOT RUN on this platform — ${skipped.map((s) => `${s.name} (${s.n})`).join(', ')}`);
|
|
519
|
+
console.log(`${label}: a skipped case is not a passing one. Green here does not mean that coverage was exercised.`);
|
|
520
|
+
}
|
|
495
521
|
reportToWorkflow(failures);
|
|
496
522
|
if (failures.length === 0) {
|
|
497
523
|
console.log(`${label}: all ${total} passed.`);
|
|
@@ -503,4 +529,4 @@ function main() {
|
|
|
503
529
|
|
|
504
530
|
if (require.main === module) main();
|
|
505
531
|
|
|
506
|
-
module.exports = { discoverModuleTests, isCleanTeardownAbort, describeFailures, STATUS_DESCRIPTION_MAX, INTEGRATION, INTEGRATION_PARKED, KNOWN_FAILING };
|
|
532
|
+
module.exports = { discoverModuleTests, isCleanTeardownAbort, countSkips, describeFailures, STATUS_DESCRIPTION_MAX, INTEGRATION, INTEGRATION_PARKED, KNOWN_FAILING };
|
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.876'; // 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');
|