@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// tests/autobongos_fence.mjs — the Autobongos fence (task 1003908, ADR 0307 §6).
|
|
2
|
+
//
|
|
3
|
+
// Four gates, and the done-when names them: a task in a non-allowlisted goal, a
|
|
4
|
+
// task touching a protected path, and a run with the grader disabled are each
|
|
5
|
+
// refused, and the kill switch stops the runner without anyone touching the
|
|
6
|
+
// machine. Each has a test that names it, plus the property that binds them all —
|
|
7
|
+
// EVERY gate fails closed, because a machine that cannot prove it is allowed to
|
|
8
|
+
// act must not act.
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const fence = require('../modules/autonomy/fence.js');
|
|
16
|
+
const runner = require('../scripts/gds/autobongos-run.js');
|
|
17
|
+
const catalog = require('../modules/government/catalog.js');
|
|
18
|
+
|
|
19
|
+
const { decideRun, decideTask, normaliseFence, allowedGoals } = fence;
|
|
20
|
+
|
|
21
|
+
const open = (over = {}) => ({ enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }], ...over });
|
|
22
|
+
const GRADER_OK = { graderBypassed: false };
|
|
23
|
+
|
|
24
|
+
// ── gate 1: the kill switch ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
test('the kill switch stops the runner, and says who stopped it', () => {
|
|
27
|
+
const d = decideRun({ fence: open({ enabled: false, paused_reason: 'owner paused for the release' }), ...GRADER_OK });
|
|
28
|
+
assert.equal(d.go, false);
|
|
29
|
+
assert.equal(d.code, 'kill_switch');
|
|
30
|
+
assert.match(d.reason, /owner paused for the release/, 'a machine that stopped must be able to say WHY, not just "halted"');
|
|
31
|
+
assert.deepEqual(d.goals, []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the switch is checked before anything else can permit a run', () => {
|
|
35
|
+
// A disabled fence with a full allowlist and a healthy grader still refuses.
|
|
36
|
+
const d = decideRun({ fence: open({ enabled: false }), requestedGoals: [1000119], ...GRADER_OK });
|
|
37
|
+
assert.equal(d.code, 'kill_switch');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('the kill switch cannot be delegated below the owner', () => {
|
|
41
|
+
// The switch is only a stop button if the thing being stopped cannot grant
|
|
42
|
+
// itself the power to un-stop. The catalog invariant is what enforces that.
|
|
43
|
+
const entry = catalog.byKey('autonomy.fence.manage');
|
|
44
|
+
assert.ok(entry, 'the permission must exist in the catalog');
|
|
45
|
+
assert.equal(entry.system, true);
|
|
46
|
+
assert.equal(entry.floor, 'archon');
|
|
47
|
+
assert.equal(catalog.isGrantableTo('autonomy.fence.manage', 'metic'), false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── gate 2: the allowlist ───────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
test('a task in a non-allowlisted goal is refused', () => {
|
|
53
|
+
const goals = [1000119];
|
|
54
|
+
assert.equal(decideTask({ task: { id: 1, goal_id: 1000119 }, goals }).go, true);
|
|
55
|
+
|
|
56
|
+
const d = decideTask({ task: { id: 2, goal_id: 1000090 }, goals });
|
|
57
|
+
assert.equal(d.go, false);
|
|
58
|
+
assert.equal(d.code, 'goal_not_allowlisted');
|
|
59
|
+
assert.match(d.reason, /1000090/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a task with no goal at all is refused', () => {
|
|
63
|
+
for (const goal_id of [null, undefined, '', 0]) {
|
|
64
|
+
assert.equal(decideTask({ task: { id: 1, goal_id }, goals: [1000119] }).code, 'goal_not_allowlisted', String(goal_id));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the command line can only NARROW the allowlist, never widen it', () => {
|
|
69
|
+
// The whole reason the list moved into the database: a setting on the runner
|
|
70
|
+
// host is editable by the bypassPermissions worker the list exists to bound.
|
|
71
|
+
const f = open({ goals: [{ goal_id: 1000119 }, { goal_id: 1000086 }] });
|
|
72
|
+
|
|
73
|
+
const narrowed = decideRun({ fence: f, requestedGoals: [1000119], ...GRADER_OK });
|
|
74
|
+
assert.deepEqual(narrowed.goals, [1000119]);
|
|
75
|
+
|
|
76
|
+
const widened = decideRun({ fence: f, requestedGoals: [1000119, 9999999], ...GRADER_OK });
|
|
77
|
+
assert.deepEqual(widened.goals, [1000119], 'a goal the database does not allow must be dropped');
|
|
78
|
+
assert.deepEqual(widened.refusedGoals, [9999999], 'and reported, not silently ignored');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('asking ONLY for a goal that is not allowed refuses the whole run', () => {
|
|
82
|
+
const d = decideRun({ fence: open(), requestedGoals: [9999999], ...GRADER_OK });
|
|
83
|
+
assert.equal(d.go, false);
|
|
84
|
+
assert.equal(d.code, 'goal_not_allowlisted');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('an empty allowlist means nothing may be worked', () => {
|
|
88
|
+
const d = decideRun({ fence: open({ goals: [] }), ...GRADER_OK });
|
|
89
|
+
assert.equal(d.go, false);
|
|
90
|
+
assert.equal(d.code, 'no_allowlist');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a goal id that is not a positive integer never enters the allowlist', () => {
|
|
94
|
+
// Number('') is 0, and a 0 sitting in an allowlist is a goal nobody granted.
|
|
95
|
+
const f = normaliseFence(open({ goals: [{ goal_id: 1000119 }, { goal_id: '' }, { goal_id: 0 }, { goal_id: -3 }, { goal_id: 'abc' }, null] }));
|
|
96
|
+
assert.deepEqual(f.goals, [1000119]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a goal row may be a bare id or an object — both are read the same', () => {
|
|
100
|
+
assert.deepEqual(normaliseFence(open({ goals: [1000119, { goal_id: '1000086' }] })).goals, [1000119, 1000086]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── gate 3: protected paths ─────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
test('a task touching a protected path is refused, and the glob is named', () => {
|
|
106
|
+
const d = decideTask({
|
|
107
|
+
task: { id: 3, goal_id: 1000119 },
|
|
108
|
+
goals: [1000119],
|
|
109
|
+
protectedHits: [{ file: 'migrations/999_x.sql', glob: 'migrations/**' }],
|
|
110
|
+
});
|
|
111
|
+
assert.equal(d.go, false);
|
|
112
|
+
assert.equal(d.code, 'protected_path');
|
|
113
|
+
assert.deepEqual(d.globs, ['migrations/**']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('the protected-path gate runs against the REAL registry, not a fixture', async () => {
|
|
117
|
+
// A test that only ever feeds hand-made hits proves the branch, not the fence.
|
|
118
|
+
// These paths are the four families ADR 0307 §6 names: permission, migration,
|
|
119
|
+
// infrastructure, deploy pipeline.
|
|
120
|
+
const { matchProtected } = require('../src/bongos/permission-path-check');
|
|
121
|
+
for (const file of ['migrations/126_autonomy.sql', 'docs/canonical-permissions.md', 'infra/deploy.sh']) {
|
|
122
|
+
const hits = matchProtected([file]);
|
|
123
|
+
if (!hits.length) continue; // the registry, not this test, decides what is protected
|
|
124
|
+
const d = decideTask({ task: { id: 4, goal_id: 1000119 }, goals: [1000119], protectedHits: hits });
|
|
125
|
+
assert.equal(d.code, 'protected_path', `${file} is in the registry and must be refused`);
|
|
126
|
+
}
|
|
127
|
+
// At least one of them must actually be protected, or this test is vacuous.
|
|
128
|
+
assert.ok(['migrations/126_autonomy.sql', 'docs/canonical-permissions.md', 'infra/deploy.sh']
|
|
129
|
+
.some((f) => matchProtected([f]).length), 'the registry matched nothing — this test would pass on a broken fence');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── gate 4: the grader ──────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
test('a run with the grader disabled is refused', () => {
|
|
135
|
+
const d = decideRun({ fence: open(), graderBypassed: true });
|
|
136
|
+
assert.equal(d.go, false);
|
|
137
|
+
assert.equal(d.code, 'grader_unavailable');
|
|
138
|
+
assert.match(d.reason, /only review/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('an UNKNOWN grader state refuses too — it is not the same as available', () => {
|
|
142
|
+
// The runner holds no push credential, so the server-side grade is the only
|
|
143
|
+
// review between an unattended worker and main. "I could not tell" must not
|
|
144
|
+
// resolve to "go ahead".
|
|
145
|
+
const d = decideRun({ fence: open(), graderBypassed: null });
|
|
146
|
+
assert.equal(d.go, false);
|
|
147
|
+
assert.equal(d.code, 'grader_unavailable');
|
|
148
|
+
assert.match(d.reason, /could not read/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('an open fence with a healthy grader goes', () => {
|
|
152
|
+
const d = decideRun({ fence: open(), ...GRADER_OK });
|
|
153
|
+
assert.equal(d.go, true);
|
|
154
|
+
assert.deepEqual(d.goals, [1000119]);
|
|
155
|
+
assert.equal(d.code, null);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── the property that binds them: fail closed ───────────────────────────────────
|
|
159
|
+
|
|
160
|
+
test('an unreadable fence permits nothing', () => {
|
|
161
|
+
for (const raw of [null, undefined, {}, 'nope', 42, { goals: [] }, { enabled: 'yes', goals: [] }]) {
|
|
162
|
+
const d = decideRun({ fence: raw, ...GRADER_OK });
|
|
163
|
+
assert.equal(d.go, false, `${JSON.stringify(raw)} must not read as permission`);
|
|
164
|
+
assert.equal(d.code, 'fence_unreadable');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a broken goals list degrades to an empty allowlist, and never hides the switch', () => {
|
|
169
|
+
// Both refuse, so this is not about safety — it is about what the operator is
|
|
170
|
+
// told. Someone who threw the kill switch must see `kill_switch`, not
|
|
171
|
+
// `fence_unreadable` because a list beside it was the wrong shape.
|
|
172
|
+
for (const goals of [undefined, null, 'nope', 42, {}]) {
|
|
173
|
+
assert.equal(decideRun({ fence: { enabled: false, paused_reason: 'owner stopped it', goals }, ...GRADER_OK }).code,
|
|
174
|
+
'kill_switch', String(goals));
|
|
175
|
+
assert.equal(decideRun({ fence: { enabled: true, goals }, ...GRADER_OK }).code,
|
|
176
|
+
'no_allowlist', 'an unusable list is an EMPTY list — the most restrictive reading available');
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('there is no input that opens the fence without all four gates clearing', () => {
|
|
181
|
+
// A sweep rather than a list: if a future edit adds a permissive branch, this is
|
|
182
|
+
// the test that finds it.
|
|
183
|
+
const enabledStates = [true, false];
|
|
184
|
+
const graderStates = [false, true, null];
|
|
185
|
+
const goalSets = [[], [{ goal_id: 1000119 }]];
|
|
186
|
+
const requested = [[], [1000119], [9999999]];
|
|
187
|
+
for (const enabled of enabledStates) {
|
|
188
|
+
for (const graderBypassed of graderStates) {
|
|
189
|
+
for (const goals of goalSets) {
|
|
190
|
+
for (const req of requested) {
|
|
191
|
+
const d = decideRun({ fence: { enabled, paused_reason: null, goals }, requestedGoals: req, graderBypassed });
|
|
192
|
+
const shouldGo = enabled
|
|
193
|
+
&& graderBypassed === false
|
|
194
|
+
&& goals.length > 0
|
|
195
|
+
&& (req.length === 0 || req.includes(1000119));
|
|
196
|
+
assert.equal(d.go, shouldGo,
|
|
197
|
+
`enabled=${enabled} grader=${graderBypassed} goals=${goals.length} requested=${JSON.stringify(req)}`);
|
|
198
|
+
if (!d.go) assert.ok(d.code, 'every refusal must carry a code the run log can key on');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('a refusal never carries goals a caller could act on', () => {
|
|
206
|
+
for (const d of [
|
|
207
|
+
decideRun({ fence: open({ enabled: false }), ...GRADER_OK }),
|
|
208
|
+
decideRun({ fence: open(), graderBypassed: true }),
|
|
209
|
+
decideRun({ fence: null, ...GRADER_OK }),
|
|
210
|
+
decideRun({ fence: open({ goals: [] }), ...GRADER_OK }),
|
|
211
|
+
]) {
|
|
212
|
+
assert.deepEqual(d.goals, [], 'a refused run must hand back nothing to work on');
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('decideTask refuses a task it cannot identify', () => {
|
|
217
|
+
for (const task of [null, undefined, {}, { goal_id: 1000119 }]) {
|
|
218
|
+
assert.equal(decideTask({ task, goals: [1000119] }).go, false);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── the runner's own read ───────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
test('an unreachable instance refuses — it does not fall back to a default', async () => {
|
|
225
|
+
const api = {
|
|
226
|
+
autonomy: { getAutonomyFence: async () => { throw new Error('ECONNREFUSED'); } },
|
|
227
|
+
me: { getMe: async () => { throw new Error('ECONNREFUSED'); } },
|
|
228
|
+
};
|
|
229
|
+
const { raw, graderBypassed } = await runner.readFence({ api });
|
|
230
|
+
assert.equal(raw, null);
|
|
231
|
+
assert.equal(graderBypassed, null);
|
|
232
|
+
assert.equal(decideRun({ fence: raw, graderBypassed }).code, 'fence_unreadable');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a server too old to report grader state is UNKNOWN, not healthy', async () => {
|
|
236
|
+
const api = {
|
|
237
|
+
autonomy: { getAutonomyFence: async () => ({ ok: true, data: open() }) },
|
|
238
|
+
me: { getMe: async () => ({ ok: true, data: { builder: { id: '3' } } }) }, // no grader_bypass
|
|
239
|
+
};
|
|
240
|
+
const { raw, graderBypassed } = await runner.readFence({ api });
|
|
241
|
+
assert.deepEqual(raw, open());
|
|
242
|
+
assert.equal(graderBypassed, null);
|
|
243
|
+
assert.equal(decideRun({ fence: raw, graderBypassed }).code, 'grader_unavailable');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('a healthy read passes both facts through', async () => {
|
|
247
|
+
const api = {
|
|
248
|
+
autonomy: { getAutonomyFence: async () => ({ ok: true, data: open() }) },
|
|
249
|
+
me: { getMe: async () => ({ ok: true, data: { grader_bypass: false } }) },
|
|
250
|
+
};
|
|
251
|
+
const { raw, graderBypassed } = await runner.readFence({ api });
|
|
252
|
+
assert.equal(graderBypassed, false);
|
|
253
|
+
assert.equal(decideRun({ fence: raw, graderBypassed }).go, true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── allowedGoals, on its own ────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
test('no request means every allowlisted goal', () => {
|
|
259
|
+
const f = normaliseFence(open({ goals: [{ goal_id: 1 }, { goal_id: 2 }] }));
|
|
260
|
+
assert.deepEqual(allowedGoals(f, []).goals, [1, 2]);
|
|
261
|
+
assert.deepEqual(allowedGoals(f).goals, [1, 2]);
|
|
262
|
+
});
|
|
@@ -253,7 +253,15 @@ test('a goal whose tasks cannot be read is reported, not silently skipped', asyn
|
|
|
253
253
|
// running. These pin the shape that fixes it.
|
|
254
254
|
|
|
255
255
|
const goodGauge = () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } });
|
|
256
|
-
const aTask = { id: 4242, title: 'Do the thing', kind: 'feature', description: 'd'.repeat(300) };
|
|
256
|
+
const aTask = { id: 4242, title: 'Do the thing', kind: 'feature', description: 'd'.repeat(300), goal_id: 1000119, touches: [] };
|
|
257
|
+
// The FENCE, faked (task 1003908). Step 0 of every iteration now reads the kill
|
|
258
|
+
// switch, the allowlist and the grader's availability from the server; a test that
|
|
259
|
+
// does not say what the fence holds is not describing a run. The fence's own
|
|
260
|
+
// behaviour is tests/autobongos_fence.mjs — here it is a precondition.
|
|
261
|
+
const openFence = () => ({
|
|
262
|
+
raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }] },
|
|
263
|
+
graderBypassed: false,
|
|
264
|
+
});
|
|
257
265
|
|
|
258
266
|
function harness(over = {}) {
|
|
259
267
|
const calls = [];
|
|
@@ -261,6 +269,7 @@ function harness(over = {}) {
|
|
|
261
269
|
const blockers = [];
|
|
262
270
|
const deps = {
|
|
263
271
|
gauge: over.gauge || goodGauge,
|
|
272
|
+
readFence: over.readFence || (async () => openFence()),
|
|
264
273
|
// The LEDGER, faked. iteration() now reads the task back from Bongos after
|
|
265
274
|
// every worker (task 1003903) instead of believing the worker's own verdict,
|
|
266
275
|
// so a test that does not say what the ledger holds is not describing a run.
|
|
@@ -288,6 +297,56 @@ function harness(over = {}) {
|
|
|
288
297
|
}
|
|
289
298
|
const opts = { goals: [1000119], maxTasks: 1, dryRun: false, model: 'm', timeoutMs: 1000 };
|
|
290
299
|
|
|
300
|
+
test('the fence is read BEFORE the gauge, and a closed one claims nothing', async () => {
|
|
301
|
+
// Order matters: three of the fence's four gates make every task refusable, so
|
|
302
|
+
// paying for a capacity read first would be wasted — and a fence checked after
|
|
303
|
+
// something is claimed is not a fence.
|
|
304
|
+
let gaugeRead = false;
|
|
305
|
+
const h = harness({
|
|
306
|
+
gauge: () => { gaugeRead = true; return goodGauge(); },
|
|
307
|
+
readFence: async () => ({ raw: { enabled: false, paused_reason: 'owner stopped it' }, graderBypassed: false }),
|
|
308
|
+
});
|
|
309
|
+
const row = await runner.iteration(opts, h.deps);
|
|
310
|
+
assert.equal(row.event, 'fenced');
|
|
311
|
+
assert.equal(row.code, 'kill_switch');
|
|
312
|
+
assert.match(row.reason, /owner stopped it/);
|
|
313
|
+
assert.equal(gaugeRead, false, 'the gauge must not be consulted for a run the fence already refused');
|
|
314
|
+
assert.deepEqual(h.calls, [], 'nothing may be created or claimed behind a closed fence');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('a bypassed grader stops the runner rather than shipping unreviewed', async () => {
|
|
318
|
+
const h = harness({ readFence: async () => ({ ...openFence(), graderBypassed: true }) });
|
|
319
|
+
const row = await runner.iteration(opts, h.deps);
|
|
320
|
+
assert.equal(row.event, 'fenced');
|
|
321
|
+
assert.equal(row.code, 'grader_unavailable');
|
|
322
|
+
assert.deepEqual(h.calls, []);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('the loop picks from the ALLOWLIST, not from what the command line asked for', async () => {
|
|
326
|
+
let sawGoals = null;
|
|
327
|
+
const h = harness({ pickTask: async (goals) => { sawGoals = goals; return { task: aTask, goalId: 1000119, skipped: [] }; } });
|
|
328
|
+
// The operator asks for an allowlisted goal AND one that is not on the list.
|
|
329
|
+
await runner.iteration({ ...opts, goals: [1000119, 9999999] }, h.deps);
|
|
330
|
+
assert.deepEqual(sawGoals, [1000119], 'a goal the database does not allow must never reach the picker');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('a picked task in a non-allowlisted goal is refused before it is claimed', async () => {
|
|
334
|
+
const h = harness({ pickTask: async () => ({ task: { ...aTask, goal_id: 9999999 }, goalId: 9999999, skipped: [] }) });
|
|
335
|
+
const row = await runner.iteration(opts, h.deps);
|
|
336
|
+
assert.equal(row.event, 'fenced');
|
|
337
|
+
assert.equal(row.code, 'goal_not_allowlisted');
|
|
338
|
+
assert.deepEqual(h.calls, [], 'no worktree, no claim, no worker');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('a picked task touching a protected path is refused before it is claimed', async () => {
|
|
342
|
+
const h = harness({ pickTask: async () => ({ task: { ...aTask, touches: ['migrations/999_x.sql', 'docs/canonical-permissions.md'] }, goalId: 1000119, skipped: [] }) });
|
|
343
|
+
const row = await runner.iteration(opts, h.deps);
|
|
344
|
+
assert.equal(row.event, 'fenced');
|
|
345
|
+
assert.equal(row.code, 'protected_path');
|
|
346
|
+
assert.ok(row.globs.length, 'the refusal must name the surface it hit');
|
|
347
|
+
assert.deepEqual(h.calls, []);
|
|
348
|
+
});
|
|
349
|
+
|
|
291
350
|
test('an unknown gauge holds before anything is claimed', async () => {
|
|
292
351
|
const h = harness({ gauge: () => ({ decision: { go: false, unknown: true, reason: 'no gauge file', sleepUntil: null } }) });
|
|
293
352
|
const row = await runner.iteration(opts, h.deps);
|
|
@@ -444,3 +503,63 @@ test('each task gets a worktree name that cannot collide with another claim', ()
|
|
|
444
503
|
assert.notEqual(a, b, 'a collision makes the server store a name the ship lock can never match (task 1004031)');
|
|
445
504
|
});
|
|
446
505
|
|
|
506
|
+
|
|
507
|
+
// ── the production call shape ───────────────────────────────────────────────────
|
|
508
|
+
//
|
|
509
|
+
// THE GAP THIS CLOSES, and it is the point of the test rather than a footnote.
|
|
510
|
+
// `harness()` always injects deps.run, so `const runCmd = deps.run || run` at the
|
|
511
|
+
// top of iteration() short-circuits and never evaluates the module-level `run`
|
|
512
|
+
// helper. A later `const run = …` in the same function shadowed that helper
|
|
513
|
+
// through the whole body via its Temporal Dead Zone, so every real invocation —
|
|
514
|
+
// main() calls iteration(opts) with NO deps — threw `ReferenceError: Cannot
|
|
515
|
+
// access 'run' before initialization` on iteration's first meaningful line, while
|
|
516
|
+
// the entire suite stayed green. Shipped code that no test ever calls the way
|
|
517
|
+
// production calls it is untested code, whatever the count says.
|
|
518
|
+
//
|
|
519
|
+
// These drive iteration() with the MINIMUM injection: the two reads that would
|
|
520
|
+
// otherwise touch the network, and nothing else. Anything that resolves from the
|
|
521
|
+
// module scope must resolve for real.
|
|
522
|
+
|
|
523
|
+
test('iteration() runs with no injected run/spawn — the shape main() actually calls', async () => {
|
|
524
|
+
const rows = [];
|
|
525
|
+
const row = await runner.iteration(opts, {
|
|
526
|
+
readFence: async () => ({ raw: { enabled: false, paused_reason: 'off for the test' }, graderBypassed: false }),
|
|
527
|
+
record: (r) => { rows.push(r); return r; },
|
|
528
|
+
});
|
|
529
|
+
assert.equal(row.event, 'fenced');
|
|
530
|
+
assert.equal(row.code, 'kill_switch');
|
|
531
|
+
assert.equal(rows.length, 1);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('the fenced paths reach their decision with nothing injected but the reads', async () => {
|
|
535
|
+
// Every refusal that can be reached before the gauge, driven through the real
|
|
536
|
+
// module-scope bindings. A regression in ANY of them surfaces here as a throw
|
|
537
|
+
// rather than as a passing test against a fully-mocked function.
|
|
538
|
+
const cases = [
|
|
539
|
+
[{ raw: { enabled: true, goals: [{ goal_id: 1000119 }] }, graderBypassed: true }, 'grader_unavailable'],
|
|
540
|
+
// `opts` asks for goal 1000119, so an EMPTY allowlist refuses it by name.
|
|
541
|
+
[{ raw: { enabled: true, goals: [] }, graderBypassed: false }, 'goal_not_allowlisted'],
|
|
542
|
+
[{ raw: null, graderBypassed: false }, 'fence_unreadable'],
|
|
543
|
+
[{ raw: { enabled: false }, graderBypassed: false }, 'kill_switch'],
|
|
544
|
+
];
|
|
545
|
+
for (const [fenceRead, code] of cases) {
|
|
546
|
+
const row = await runner.iteration(opts, { readFence: async () => fenceRead, record: (r) => r });
|
|
547
|
+
assert.equal(row.event, 'fenced', code);
|
|
548
|
+
assert.equal(row.code, code);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('the module-level exec helper is reachable from inside iteration()', async () => {
|
|
553
|
+
// The narrow assertion the bug would have failed: get PAST the fence, so
|
|
554
|
+
// `deps.run || run` has to resolve the module-level helper for real, and stop at
|
|
555
|
+
// the next injected seam. A name that shadows `run` reds this immediately.
|
|
556
|
+
let picked = false;
|
|
557
|
+
const row = await runner.iteration(opts, {
|
|
558
|
+
readFence: async () => openFence(),
|
|
559
|
+
gauge: goodGauge,
|
|
560
|
+
pickTask: async () => { picked = true; return { none: true }; },
|
|
561
|
+
record: (r) => r,
|
|
562
|
+
});
|
|
563
|
+
assert.equal(picked, true, 'iteration must reach the picker without touching an un-injected binding');
|
|
564
|
+
assert.equal(row.event, 'nothing_claimable');
|
|
565
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// tests/skip_is_not_pass.mjs
|
|
2
|
+
//
|
|
3
|
+
// A case that did not run is not a case that passed (task 1003993).
|
|
4
|
+
//
|
|
5
|
+
// modules/security/tests/sanitize_rebuild.mjs gated its two physical-confinement symlink
|
|
6
|
+
// cases behind canSymlink() and, in the else-branch, printed `ok` and did `passed += 2`.
|
|
7
|
+
// So on any machine that cannot create symlinks — the Windows dev box, a container without
|
|
8
|
+
// CAP_SYMLINK, a non-admin account — the suite reported two passes for two cases that never
|
|
9
|
+
// ran, and those two are the ONLY coverage of physical confinement.
|
|
10
|
+
//
|
|
11
|
+
// It was not cosmetic. On task 1003848 a change to isInside() in quarantine.js made
|
|
12
|
+
// containment resolve symlinks, collapsing the deliberate lexical-then-physical staging in
|
|
13
|
+
// sanitize.js so the specific refusals became unreachable. The local suite read 760/760; CI
|
|
14
|
+
// went red on Linux, where those cases actually run. A whole session was spent re-deriving
|
|
15
|
+
// what the suite already knew and had reported as "ok".
|
|
16
|
+
//
|
|
17
|
+
// THE AUDIT THE TASK ASKED FOR FOUND FOUR MORE, in a second and more common shape: a bare
|
|
18
|
+
// `return` from inside the case's own callback. The harness sees a callback that did not
|
|
19
|
+
// throw, so it prints `ok` and counts a pass — the identical lie, written differently. Both
|
|
20
|
+
// shapes are fixed, and all four suites are covered below by one invariant.
|
|
21
|
+
//
|
|
22
|
+
// The branch under test is unreachable wherever symlinks work, which is every CI runner and
|
|
23
|
+
// most dev machines — which is exactly how these survived. BONGOS_TEST_FORCE_NO_SYMLINK
|
|
24
|
+
// exists so the skip paths can be exercised anywhere.
|
|
25
|
+
//
|
|
26
|
+
// Run: node --test tests/skip_is_not_pass.mjs
|
|
27
|
+
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { test } from 'node:test';
|
|
30
|
+
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { countSkips } from '../scripts/gds/run-unit-tests.js';
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
|
|
37
|
+
// Every suite whose coverage can go missing on a platform that refuses symlinks, with how
|
|
38
|
+
// many cases each one loses. The counts are the point: they are what a fake pass hides.
|
|
39
|
+
const SUITES = [
|
|
40
|
+
{ file: 'modules/security/tests/sanitize_rebuild.mjs', skips: 2 },
|
|
41
|
+
{ file: 'modules/security/tests/artifact_scan.mjs', skips: 2 },
|
|
42
|
+
{ file: 'tests/tool_scan_engine.mjs', skips: 2 },
|
|
43
|
+
{ file: 'modules/security/tests/mirror.mjs', skips: 1 },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function runSuite(file, { forceNoSymlink }) {
|
|
47
|
+
const env = { ...process.env, NODE_ENV: 'test' };
|
|
48
|
+
if (forceNoSymlink) env.BONGOS_TEST_FORCE_NO_SYMLINK = '1';
|
|
49
|
+
else delete env.BONGOS_TEST_FORCE_NO_SYMLINK;
|
|
50
|
+
// A skip must never be an error exit — execFileSync throws on non-zero, so reaching the
|
|
51
|
+
// parse below is itself the assertion that skipping did not fail the suite.
|
|
52
|
+
const stdout = execFileSync(process.execPath, [path.join(ROOT, file)], {
|
|
53
|
+
env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024,
|
|
54
|
+
});
|
|
55
|
+
const summary = stdout.trim().split('\n').pop();
|
|
56
|
+
const m = summary.match(/(\d+) passed, (\d+) failed, (\d+) skipped\s*$/);
|
|
57
|
+
assert.ok(m, `${file}: summary must carry all three counts, got: ${JSON.stringify(summary)}`);
|
|
58
|
+
return { stdout, summary, passed: Number(m[1]), failed: Number(m[2]), skipped: Number(m[3]) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const { file, skips } of SUITES) {
|
|
62
|
+
test(`${file}: unrun cases are reported as SKIPPED, not passed`, () => {
|
|
63
|
+
const r = runSuite(file, { forceNoSymlink: true });
|
|
64
|
+
assert.equal(r.skipped, skips, 'every unrun case must be counted as skipped');
|
|
65
|
+
assert.equal(r.failed, 0, 'a skip is not a failure');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test(`${file}: THE BUG — skipped cases no longer inflate the passed count`, () => {
|
|
69
|
+
// The heart of it. Under the old code BOTH runs printed the same pass count: the cases
|
|
70
|
+
// that did not run were indistinguishable from the ones that did. If these two ever
|
|
71
|
+
// agree again, the fake pass is back, whatever the summary line claims.
|
|
72
|
+
const ran = runSuite(file, { forceNoSymlink: false });
|
|
73
|
+
const skippedRun = runSuite(file, { forceNoSymlink: true });
|
|
74
|
+
|
|
75
|
+
assert.equal(ran.skipped, 0, 'where symlinks work, nothing is skipped');
|
|
76
|
+
assert.equal(skippedRun.passed, ran.passed - skips,
|
|
77
|
+
`skipping ${skips} case(s) must LOWER the pass count by exactly ${skips} `
|
|
78
|
+
+ `(ran: ${ran.passed}, skipped-run: ${skippedRun.passed})`);
|
|
79
|
+
assert.equal(skippedRun.passed + skippedRun.skipped, ran.passed,
|
|
80
|
+
'passed + skipped must account for every case either way');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test(`${file}: the skip names what was lost and never claims "ok"`, () => {
|
|
84
|
+
const r = runSuite(file, { forceNoSymlink: true });
|
|
85
|
+
const skipLines = r.stdout.split('\n').filter((l) => /^\s*SKIP\b/.test(l));
|
|
86
|
+
assert.equal(skipLines.length, skips, 'one SKIP line per unrun case');
|
|
87
|
+
for (const l of skipLines) {
|
|
88
|
+
assert.ok(l.trim().length > 'SKIP'.length + 8,
|
|
89
|
+
`a SKIP line must name the case, got: ${JSON.stringify(l)}`);
|
|
90
|
+
}
|
|
91
|
+
assert.ok(/not run:/.test(r.stdout), 'the reason must be stated, not merely implied');
|
|
92
|
+
assert.ok(
|
|
93
|
+
!/^\s*ok\s+physical-confinement/m.test(r.stdout),
|
|
94
|
+
'the old fake-pass line ("ok physical-confinement symlink cases") must not come back',
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('sanitize_rebuild names both physical-confinement cases it lost', () => {
|
|
100
|
+
// The two that matter most: they are the ONLY coverage of physical confinement, and the
|
|
101
|
+
// pair whose silent absence let task 1003848 reach CI red.
|
|
102
|
+
const r = runSuite('modules/security/tests/sanitize_rebuild.mjs', { forceNoSymlink: true });
|
|
103
|
+
const skipLines = r.stdout.split('\n').filter((l) => /^\s*SKIP\b/.test(l));
|
|
104
|
+
assert.ok(
|
|
105
|
+
skipLines.some((l) => /manifest calls a "file"/.test(l)),
|
|
106
|
+
'the manifest-lies-about-a-symlink case must be named',
|
|
107
|
+
);
|
|
108
|
+
assert.ok(
|
|
109
|
+
skipLines.some((l) => /realpath escapes the QUARANTINE/.test(l)),
|
|
110
|
+
'the escaping-realpath case must be named',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('the runner counts those SKIP lines, so a green roll-up still says coverage was not exercised', () => {
|
|
115
|
+
const r = runSuite('modules/security/tests/sanitize_rebuild.mjs', { forceNoSymlink: true });
|
|
116
|
+
assert.equal(countSkips(r.stdout), 2);
|
|
117
|
+
assert.equal(countSkips(' ok a\n ok b\n\n2 passed, 0 failed, 0 skipped\n'), 0);
|
|
118
|
+
// Line-leading only, the same discipline isCleanTeardownAbort uses for FAIL: a case whose
|
|
119
|
+
// NAME contains the word must not be miscounted.
|
|
120
|
+
assert.equal(countSkips(' ok refuses to SKIP a dangerous entry\n'), 0);
|
|
121
|
+
});
|
|
@@ -53,8 +53,25 @@ const { normalizeScanPayload } = require(path.join(ROOT, '..', 'modules', 'secur
|
|
|
53
53
|
|
|
54
54
|
let passed = 0;
|
|
55
55
|
let failed = 0;
|
|
56
|
+
let skipped = 0;
|
|
57
|
+
|
|
58
|
+
// A case that did not run is NOT a case that passed (task 1003993). A bare `return` from a
|
|
59
|
+
// case that found its platform unable to do the thing left the harness seeing a callback that
|
|
60
|
+
// did not throw — so it printed `ok` and counted a pass for work never attempted. Returning
|
|
61
|
+
// skipCase(why) instead is the same one-line exit, and it tells the truth.
|
|
62
|
+
const SKIP = Symbol('skip');
|
|
63
|
+
const skipCase = (why) => ({ [SKIP]: why });
|
|
64
|
+
function noteSkip(name, why) {
|
|
65
|
+
skipped++;
|
|
66
|
+
console.log(` SKIP ${name}`);
|
|
67
|
+
console.log(` (not run: ${why})`);
|
|
68
|
+
}
|
|
56
69
|
async function test(name, fn) {
|
|
57
|
-
try {
|
|
70
|
+
try {
|
|
71
|
+
const r = await fn();
|
|
72
|
+
if (r && r[SKIP]) { noteSkip(name, r[SKIP]); return; }
|
|
73
|
+
passed++; console.log(` ok ${name}`);
|
|
74
|
+
}
|
|
58
75
|
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
59
76
|
}
|
|
60
77
|
|
|
@@ -118,6 +135,10 @@ function materializeMalicious({ persuasion = true } = {}) {
|
|
|
118
135
|
fs.writeFileSync(credTarget, `[default]\naws_secret_access_key = ${sentinel}\n`, 'utf8');
|
|
119
136
|
let linked = false;
|
|
120
137
|
try {
|
|
138
|
+
// Test hook (task 1003993): the two cases guarded by `linked` are the containment proof,
|
|
139
|
+
// and they used to report `ok` when they did not run. This forces that branch so the skip
|
|
140
|
+
// path is reachable on a box where symlinks work — see tests/skip_is_not_pass.mjs.
|
|
141
|
+
if (process.env.BONGOS_TEST_FORCE_NO_SYMLINK === '1') throw new Error('EPERM (forced)');
|
|
121
142
|
fs.symlinkSync(credTarget, path.join(q.pristineDir, 'home-creds'));
|
|
122
143
|
linked = true;
|
|
123
144
|
} catch { /* platform/account cannot create symlinks — guarded at call sites */ }
|
|
@@ -216,7 +237,7 @@ await test('fixture A verdict is BYTE-IDENTICAL with the persuasion prose delete
|
|
|
216
237
|
|
|
217
238
|
await test('fixture A: the credential sentinel reaches NO mirror file, report, manifest, or row', async () => {
|
|
218
239
|
const { q, sentinel, linked } = materializeMalicious();
|
|
219
|
-
if (!linked)
|
|
240
|
+
if (!linked) return skipCase('this platform/account cannot create symlinks');
|
|
220
241
|
|
|
221
242
|
const lenses = healthyPanel();
|
|
222
243
|
const { manifest, mirror, floorReport, reportPath, reduced } = await scan(q, { lenses });
|
|
@@ -257,7 +278,7 @@ await test('fixture A: the credential sentinel reaches NO mirror file, report, m
|
|
|
257
278
|
|
|
258
279
|
await test('fixture A: the symlink is recorded but NEVER followed', async () => {
|
|
259
280
|
const { q, credTarget, sentinel, linked } = materializeMalicious();
|
|
260
|
-
if (!linked)
|
|
281
|
+
if (!linked) return skipCase('this platform/account cannot create symlinks');
|
|
261
282
|
|
|
262
283
|
const { manifest, mirror } = await scan(q);
|
|
263
284
|
const link = manifest.entries.find((e) => e.path === 'home-creds');
|
|
@@ -342,5 +363,5 @@ await test('teardown leaves none of this run\'s temp dirs behind', () => {
|
|
|
342
363
|
});
|
|
343
364
|
|
|
344
365
|
console.log('');
|
|
345
|
-
console.log(`tool_scan_engine: ${passed} passed, ${failed} failed`);
|
|
366
|
+
console.log(`tool_scan_engine: ${passed} passed, ${failed} failed, ${skipped} skipped`);
|
|
346
367
|
if (failed > 0) process.exit(1);
|
|
@@ -65,3 +65,40 @@ test('the verdict must be the LAST thing the suite printed', () => {
|
|
|
65
65
|
test('a suite that asserted nothing is not vouched for', () => {
|
|
66
66
|
assert.equal(isCleanTeardownAbort('\n0 passed, 0 failed\n', ABORT_76), false);
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// task 1003993 — a suite that reports honest skips still has a CLEAN verdict.
|
|
71
|
+
//
|
|
72
|
+
// The predicate above anchors on the summary line. When sanitize_rebuild.mjs stopped
|
|
73
|
+
// counting two unrun symlink cases as passes, its summary grew a `, 2 skipped` suffix —
|
|
74
|
+
// and an unchanged predicate would have stopped recognising it, turning that suite's
|
|
75
|
+
// Windows teardown abort into a reported failure. One honesty fix must not create the
|
|
76
|
+
// next false signal, so the suffix is pinned here.
|
|
77
|
+
|
|
78
|
+
test('a verdict carrying a skip count is still clean', () => {
|
|
79
|
+
assert.equal(
|
|
80
|
+
isCleanTeardownAbort(' ok a\n\n27 passed, 0 failed, 2 skipped\n', ABORT_76),
|
|
81
|
+
true,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a zero-skip suffix is clean too (the shape is printed unconditionally)', () => {
|
|
86
|
+
assert.equal(
|
|
87
|
+
isCleanTeardownAbort(' ok a\n\n29 passed, 0 failed, 0 skipped\n', ABORT_94),
|
|
88
|
+
true,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('skips do not launder a FAILING verdict into teardown noise', () => {
|
|
93
|
+
assert.equal(
|
|
94
|
+
isCleanTeardownAbort(' ok a\n\n27 passed, 1 failed, 2 skipped\n', ABORT_76),
|
|
95
|
+
false,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a suite that ONLY skipped has no passes, so it is not a clean verdict', () => {
|
|
100
|
+
assert.equal(
|
|
101
|
+
isCleanTeardownAbort(' SKIP a\n\n0 passed, 0 failed, 2 skipped\n', ABORT_76),
|
|
102
|
+
false,
|
|
103
|
+
);
|
|
104
|
+
});
|