@bongos/core 1.19.76 → 1.19.78
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 +18 -18
- package/docs/canonical-permissions.md +9 -0
- package/modules/dev-box/box-access.js +30 -7
- package/modules/dev-box/routes/box.js +27 -2
- package/modules/lifecycle/db.js +24 -0
- package/modules/lifecycle/lifecycle.js +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box.js +28 -14
- package/scripts/gds/hooks/pre-push.js +45 -11
- package/src/module-api.js +5 -1
- package/tests/box_task_scope.mjs +70 -0
- package/tests/boxes.mjs +81 -0
- package/tests/pre_push_hook.mjs +103 -6
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.78",
|
|
6
|
+
"core_contract": "1.19.78",
|
|
7
|
+
"source_commit": "e325ccc071131d7c01dbf333d8f7cd57ae312039",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-12T22:44:08.133Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 295,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 1541,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "96bcb5ac17be8a28801e81faab41c95d0b6b2bfee4cd4919231a9b2e0ac6a8a9",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1347,7 +1347,7 @@
|
|
|
1347
1347
|
{
|
|
1348
1348
|
"path": "docs/canonical-permissions.md",
|
|
1349
1349
|
"mode": "0000644",
|
|
1350
|
-
"sha256": "
|
|
1350
|
+
"sha256": "1e1b93e51e8408ae8b44d3d241f4fe52babefa59c03d36aced5b04ffa745dce2"
|
|
1351
1351
|
},
|
|
1352
1352
|
{
|
|
1353
1353
|
"path": "docs/design-contract.md",
|
|
@@ -2582,7 +2582,7 @@
|
|
|
2582
2582
|
{
|
|
2583
2583
|
"path": "modules/dev-box/box-access.js",
|
|
2584
2584
|
"mode": "0000644",
|
|
2585
|
-
"sha256": "
|
|
2585
|
+
"sha256": "8d2dc69a139a5a658866a18527efeb5d9ae989e05883fa6cbd0ea63db0ba9476"
|
|
2586
2586
|
},
|
|
2587
2587
|
{
|
|
2588
2588
|
"path": "modules/dev-box/box-credential.js",
|
|
@@ -2612,7 +2612,7 @@
|
|
|
2612
2612
|
{
|
|
2613
2613
|
"path": "modules/dev-box/routes/box.js",
|
|
2614
2614
|
"mode": "0000644",
|
|
2615
|
-
"sha256": "
|
|
2615
|
+
"sha256": "ba75c28d8927ebbda1482f394a0062febc6e32a52863814d00d6ad2a6667a591"
|
|
2616
2616
|
},
|
|
2617
2617
|
{
|
|
2618
2618
|
"path": "modules/discord/CLAUDE.md",
|
|
@@ -3402,7 +3402,7 @@
|
|
|
3402
3402
|
{
|
|
3403
3403
|
"path": "modules/lifecycle/db.js",
|
|
3404
3404
|
"mode": "0000644",
|
|
3405
|
-
"sha256": "
|
|
3405
|
+
"sha256": "5e7dc6c267384eb3b691f49ec82631425151b97d2208eaf342922bf46146965f"
|
|
3406
3406
|
},
|
|
3407
3407
|
{
|
|
3408
3408
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -3437,7 +3437,7 @@
|
|
|
3437
3437
|
{
|
|
3438
3438
|
"path": "modules/lifecycle/lifecycle.js",
|
|
3439
3439
|
"mode": "0000644",
|
|
3440
|
-
"sha256": "
|
|
3440
|
+
"sha256": "8f62f50c5a81d649df6b85ef7c89e57a0bf0e8877482693861a4f9cd5cfafe5a"
|
|
3441
3441
|
},
|
|
3442
3442
|
{
|
|
3443
3443
|
"path": "modules/lifecycle/merge-lock.js",
|
|
@@ -4057,12 +4057,12 @@
|
|
|
4057
4057
|
{
|
|
4058
4058
|
"path": "package-lock.json",
|
|
4059
4059
|
"mode": "0000644",
|
|
4060
|
-
"sha256": "
|
|
4060
|
+
"sha256": "7829011d4ec272839d524b9e2f9449e16c18ff635378e52490091cb025cefe3f"
|
|
4061
4061
|
},
|
|
4062
4062
|
{
|
|
4063
4063
|
"path": "package.json",
|
|
4064
4064
|
"mode": "0000644",
|
|
4065
|
-
"sha256": "
|
|
4065
|
+
"sha256": "b179d0a0a83c292a353548cceac274dba29e8e296c62565244cc4d2fc81e6951"
|
|
4066
4066
|
},
|
|
4067
4067
|
{
|
|
4068
4068
|
"path": "public-docs/index.html",
|
|
@@ -4557,7 +4557,7 @@
|
|
|
4557
4557
|
{
|
|
4558
4558
|
"path": "scripts/gds/box.js",
|
|
4559
4559
|
"mode": "0000644",
|
|
4560
|
-
"sha256": "
|
|
4560
|
+
"sha256": "41e1a0c0ef56d5cf10cc73124964c114854a12d8dd3de249d4bafcda04dd2106"
|
|
4561
4561
|
},
|
|
4562
4562
|
{
|
|
4563
4563
|
"path": "scripts/gds/build-bongos.js",
|
|
@@ -4837,7 +4837,7 @@
|
|
|
4837
4837
|
{
|
|
4838
4838
|
"path": "scripts/gds/hooks/pre-push.js",
|
|
4839
4839
|
"mode": "0000644",
|
|
4840
|
-
"sha256": "
|
|
4840
|
+
"sha256": "682c19e53e0633f02d5d5c2d4c9e3ae7c04e304c32461ed210b5fad8a6623e33"
|
|
4841
4841
|
},
|
|
4842
4842
|
{
|
|
4843
4843
|
"path": "scripts/gds/http-api-client.js",
|
|
@@ -5687,7 +5687,7 @@
|
|
|
5687
5687
|
{
|
|
5688
5688
|
"path": "src/module-api.js",
|
|
5689
5689
|
"mode": "0000644",
|
|
5690
|
-
"sha256": "
|
|
5690
|
+
"sha256": "65dcc9e68f955842239c2c9d4f26e77c920ede6e79608347758ca7d6d71fb5e4"
|
|
5691
5691
|
},
|
|
5692
5692
|
{
|
|
5693
5693
|
"path": "src/module-loader/loader.js",
|
|
@@ -6032,7 +6032,7 @@
|
|
|
6032
6032
|
{
|
|
6033
6033
|
"path": "tests/box_task_scope.mjs",
|
|
6034
6034
|
"mode": "0000644",
|
|
6035
|
-
"sha256": "
|
|
6035
|
+
"sha256": "22a2dc8bdccb9900b642371b85e8cc1785ecfacaf8f7579b042951425d95d794"
|
|
6036
6036
|
},
|
|
6037
6037
|
{
|
|
6038
6038
|
"path": "tests/box_terminal_ssrf.mjs",
|
|
@@ -6042,7 +6042,7 @@
|
|
|
6042
6042
|
{
|
|
6043
6043
|
"path": "tests/boxes.mjs",
|
|
6044
6044
|
"mode": "0000644",
|
|
6045
|
-
"sha256": "
|
|
6045
|
+
"sha256": "8702916e37e8cc08ae0e055b1bc7ea70da71720c51bd2a05df2a4dc598a17f97"
|
|
6046
6046
|
},
|
|
6047
6047
|
{
|
|
6048
6048
|
"path": "tests/branding.mjs",
|
|
@@ -7157,7 +7157,7 @@
|
|
|
7157
7157
|
{
|
|
7158
7158
|
"path": "tests/pre_push_hook.mjs",
|
|
7159
7159
|
"mode": "0000644",
|
|
7160
|
-
"sha256": "
|
|
7160
|
+
"sha256": "59716c99719a4e9f5475757f48331c3fb9f3edc58b204b4f3d40abe0b39937db"
|
|
7161
7161
|
},
|
|
7162
7162
|
{
|
|
7163
7163
|
"path": "tests/preflight_checks.mjs",
|
|
@@ -274,6 +274,15 @@ pre-merge gate misses, independent of the builder's cooperation:
|
|
|
274
274
|
override). A raw push is caught and reverted by the **post-push main audit**
|
|
275
275
|
(`scripts/gds/main-audit.js`) — nightly and as the `~/deploy.sh` deploy gate — which is the
|
|
276
276
|
authoritative layer (it reads the real git history + real ranks, independent of the builder).
|
|
277
|
+
- **The pre-push hook fails OPEN by contract, and the wrapper is what enforces that** (task 1002725).
|
|
278
|
+
The gate exits **13** — and only 13 — when it *deliberately* blocks; `.husky/pre-push` turns that
|
|
279
|
+
into git's exit 1. Any other non-zero exit is a **crash** and the wrapper warns loudly and allows
|
|
280
|
+
the push. This is deliberate: Layer 1 is cooperative, so a hook bug must never brick every push,
|
|
281
|
+
and the fail-closed net is the post-push audit above. The reserved code exists because node itself
|
|
282
|
+
exits 1 on a syntax error — `1` cannot distinguish a verdict from a fall-over. The gate therefore
|
|
283
|
+
must never call `process.exit()`: doing so after its rank-lookup `fetch` aborts node natively on
|
|
284
|
+
Windows (libuv `UV_HANDLE_CLOSING`), which runs no JS handler and exited 127, silently inverting
|
|
285
|
+
fail-open into "no Windows builder can push at all".
|
|
277
286
|
- CLAUDE.md §10 carries a portable **behavioral** framing of this rule (product builders build the
|
|
278
287
|
product, not the platform core they stand on); this doc holds the rank-name detail and the code is
|
|
279
288
|
the enforcement. Editing CLAUDE.md or the hook locally cannot remove the audit/deploy-gate.
|
|
@@ -203,18 +203,33 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
|
|
|
203
203
|
// + passed by the route in R94) selects the surface, three ways:
|
|
204
204
|
// - scopeKeys is a NON-EMPTY array → TASK scope: BASE ∪ the claims' module dirs
|
|
205
205
|
// (via sparsePathsForScopeKeys + the passed-in `moduleScopeMap`).
|
|
206
|
-
// - scopeKeys is an EMPTY array →
|
|
207
|
-
//
|
|
206
|
+
// - scopeKeys is an EMPTY array → depends on whether the builder is actually
|
|
207
|
+
// working (see `hasActiveClaims` below).
|
|
208
208
|
// - scopeKeys is ABSENT (undefined) → the pre-ADR-0148 RANK fallback (full for
|
|
209
209
|
// Metic+, else the starter sparse surface). This is the path a vanilla instance
|
|
210
210
|
// with no lifecycle port takes, and it preserves the old behavior EXACTLY — so
|
|
211
211
|
// shipping R93 before R94 wires the route changes nothing in production.
|
|
212
212
|
//
|
|
213
|
+
// TASK 1002729 — the empty-array case is NOT one case, it is two, and collapsing
|
|
214
|
+
// them was a live defect. moduleScopeForActiveClaims returns [] both when the
|
|
215
|
+
// builder holds no claim AND when they hold claims whose goals declare no
|
|
216
|
+
// scope_modules (the goal wall is optional; only 13 of 46 goals on the live
|
|
217
|
+
// instance declared one). Treating the second as "no scope" handed a working
|
|
218
|
+
// builder a box with NO modules/ directory at all — less code than the rank
|
|
219
|
+
// fallback would have given a newcomer. On the live fleet that was 247 of 248
|
|
220
|
+
// source grants, including an Archon holding 7 active claims.
|
|
221
|
+
// - [] + hasActiveClaims true → RANK fallback. The builder is working; an
|
|
222
|
+
// undeclared goal wall is a planning gap, and it must not cripple their box.
|
|
223
|
+
// - [] + hasActiveClaims false → BASE-only, as before (a genuinely unscoped box
|
|
224
|
+
// — the pre-existing Bongos instance, nothing extra). Least privilege holds.
|
|
225
|
+
// `hasActiveClaims` is optional: omitted (undefined) preserves the old BASE-only
|
|
226
|
+
// reading, so a caller that hasn't been taught the signal is unchanged.
|
|
227
|
+
//
|
|
213
228
|
// Returns one of:
|
|
214
229
|
// { allowed:false, reason:'BUILDER_INACTIVE' }
|
|
215
230
|
// { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
|
|
216
231
|
// { allowed:true, scope, spec }
|
|
217
|
-
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, moduleScopeMap } = {}) {
|
|
232
|
+
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
|
|
218
233
|
if (status && status !== 'active') {
|
|
219
234
|
return { allowed: false, reason: 'BUILDER_INACTIVE' };
|
|
220
235
|
}
|
|
@@ -228,11 +243,19 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
|
|
|
228
243
|
}
|
|
229
244
|
// Claim-driven task scope (present → the box asks for exactly its work's code).
|
|
230
245
|
if (Array.isArray(scopeKeys)) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
246
|
+
if (scopeKeys.length > 0) {
|
|
247
|
+
const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
|
|
248
|
+
return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
|
|
249
|
+
}
|
|
250
|
+
// Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
|
|
251
|
+
// NOT a stripped box — the planning gap is not theirs to pay for.
|
|
252
|
+
if (!hasActiveClaims) {
|
|
253
|
+
const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
|
|
254
|
+
return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
|
|
255
|
+
}
|
|
234
256
|
}
|
|
235
|
-
// No claim signal (no lifecycle port on this instance)
|
|
257
|
+
// No claim signal (no lifecycle port on this instance), or claims with an
|
|
258
|
+
// undeclared goal wall: the pre-ADR-0148 rank fallback.
|
|
236
259
|
const scope = boxScopeForRank(rank);
|
|
237
260
|
return { allowed: true, scope, spec: cloneSpecForScope(scope) };
|
|
238
261
|
}
|
|
@@ -444,6 +444,7 @@ module.exports = function buildBoxRouter() {
|
|
|
444
444
|
// NEVER fail the source fetch on a scope-read hiccup — degrade to rank scope.
|
|
445
445
|
const lifecycle = api.resolveOptional('lifecycle');
|
|
446
446
|
let scopeKeys;
|
|
447
|
+
let hasActiveClaims;
|
|
447
448
|
if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
448
449
|
try {
|
|
449
450
|
scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
|
|
@@ -452,7 +453,19 @@ module.exports = function buildBoxRouter() {
|
|
|
452
453
|
scopeKeys = undefined;
|
|
453
454
|
}
|
|
454
455
|
}
|
|
455
|
-
|
|
456
|
+
// task 1002729 — only needed to disambiguate an EMPTY scope: no claim (BASE)
|
|
457
|
+
// vs claims whose goals declare no wall (rank scope). Skipped otherwise so
|
|
458
|
+
// the common path keeps its single query.
|
|
459
|
+
if (Array.isArray(scopeKeys) && scopeKeys.length === 0
|
|
460
|
+
&& lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
|
|
461
|
+
try {
|
|
462
|
+
hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
|
|
463
|
+
} catch (claimErr) {
|
|
464
|
+
console.error('[gds] source-access claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
|
|
465
|
+
hasActiveClaims = undefined;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
|
|
456
469
|
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
457
470
|
const auditDeny = (reason, extra) =>
|
|
458
471
|
box && boxAccess.recordSourceEvent(pool, {
|
|
@@ -548,6 +561,7 @@ module.exports = function buildBoxRouter() {
|
|
|
548
561
|
// (optional lifecycle port; degrade to rank scope on any hiccup).
|
|
549
562
|
const lifecycle = api.resolveOptional('lifecycle');
|
|
550
563
|
let scopeKeys;
|
|
564
|
+
let hasActiveClaims;
|
|
551
565
|
if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
|
|
552
566
|
try {
|
|
553
567
|
scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
|
|
@@ -556,7 +570,18 @@ module.exports = function buildBoxRouter() {
|
|
|
556
570
|
scopeKeys = undefined;
|
|
557
571
|
}
|
|
558
572
|
}
|
|
559
|
-
|
|
573
|
+
// task 1002729 — same disambiguation as source-access, so this read-only
|
|
574
|
+
// companion can never report a scope the real fetch wouldn't grant.
|
|
575
|
+
if (Array.isArray(scopeKeys) && scopeKeys.length === 0
|
|
576
|
+
&& lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
|
|
577
|
+
try {
|
|
578
|
+
hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
|
|
579
|
+
} catch (claimErr) {
|
|
580
|
+
console.error('[gds] /box/scope claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
|
|
581
|
+
hasActiveClaims = undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
|
|
560
585
|
if (!decision.allowed) {
|
|
561
586
|
return res.fail('SOURCE_ACCESS_REVOKED', 403, {
|
|
562
587
|
reason: decision.reason,
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -4756,6 +4756,29 @@ async function moduleScopeForActiveClaims(builderId, deps = {}) {
|
|
|
4756
4756
|
return rows.map((r) => r.module_key);
|
|
4757
4757
|
}
|
|
4758
4758
|
|
|
4759
|
+
// Task 1002729 — the companion signal moduleScopeForActiveClaims cannot carry.
|
|
4760
|
+
// That query INNER JOINs goals and unnests scope_modules, so it returns [] for
|
|
4761
|
+
// BOTH "this builder holds no claim" and "this builder holds claims whose goals
|
|
4762
|
+
// declare no scope_modules". Those must not be treated alike: the first is a
|
|
4763
|
+
// genuinely unscoped box (BASE-only is right), the second is a builder actively
|
|
4764
|
+
// working who would be handed a box with no modules/ at all. Live evidence for
|
|
4765
|
+
// why this matters: 247 of 248 source grants on the cloudbongos fleet resolved to
|
|
4766
|
+
// BASE, including an Archon holding 7 active claims — every one of those claims
|
|
4767
|
+
// sat on a goal with scope_modules = {}. Counts CLAIMS, deliberately not joining
|
|
4768
|
+
// goals, so it stays true regardless of how the goal wall is (or isn't) declared.
|
|
4769
|
+
async function activeClaimCountForBuilder(builderId, deps = {}) {
|
|
4770
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
4771
|
+
const id = Number(builderId);
|
|
4772
|
+
if (!Number.isInteger(id)) {
|
|
4773
|
+
throw new Error('activeClaimCountForBuilder: builderId must be numeric');
|
|
4774
|
+
}
|
|
4775
|
+
const { rows } = await activePool.query(
|
|
4776
|
+
`SELECT count(*)::int AS n FROM claims WHERE builder_id = $1 AND released_at IS NULL`,
|
|
4777
|
+
[id]
|
|
4778
|
+
);
|
|
4779
|
+
return rows.length ? Number(rows[0].n) : 0;
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4759
4782
|
// task 1642 — every active claim bound to a given CLI session. "Active" is the
|
|
4760
4783
|
// claims-table convention (released_at IS NULL); there is NO `status` column on
|
|
4761
4784
|
// claims (a common misread — the task's status lives on `tasks`). Keyed on
|
|
@@ -5625,6 +5648,7 @@ module.exports = {
|
|
|
5625
5648
|
getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
|
|
5626
5649
|
getActiveClaimsForBuilder,
|
|
5627
5650
|
moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
|
|
5651
|
+
activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
|
|
5628
5652
|
getActiveClaimsForSession,
|
|
5629
5653
|
getLastClaimHolder, // --regrade ownership guard (#678)
|
|
5630
5654
|
getTaskGrade, // V4.R08 — diff-hash reuse check before grading
|
|
@@ -65,4 +65,7 @@ module.exports = {
|
|
|
65
65
|
// resolver (goal 1000051 / R92, ADR 0148 §2). Keys only; box-access maps them to
|
|
66
66
|
// git sparse-checkout globs through the doorway `moduleScopeMap`.
|
|
67
67
|
moduleScopeForActiveClaims: (...a) => db.moduleScopeForActiveClaims(...a),
|
|
68
|
+
// task 1002729 — the "is this builder actually working right now" signal that
|
|
69
|
+
// lets box-access tell an unscoped box from a crippled one (see db.js).
|
|
70
|
+
activeClaimCountForBuilder: (...a) => db.activeClaimCountForBuilder(...a),
|
|
68
71
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.78",
|
|
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.78",
|
|
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.78",
|
|
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",
|
package/scripts/gds/box.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// park <builder> snapshot + destroy the droplet (stop compute billing)
|
|
15
15
|
// wake <builder> recreate the droplet from its snapshot
|
|
16
16
|
// deprovision <builder> destroy droplet AND snapshot (full reclaim)
|
|
17
|
-
// sweep-idle
|
|
17
|
+
// sweep-idle PARK every active box idle > BOX_IDLE_MINUTES (snapshot kept)
|
|
18
18
|
// sweep-dormant deprovision every box parked > BOX_DORMANT_DAYS (pings builder)
|
|
19
19
|
// status print the box roster + estimated cost
|
|
20
20
|
//
|
|
@@ -1007,33 +1007,47 @@ async function cmdDeprovision(pool, boxes, ref, { apply }) {
|
|
|
1007
1007
|
}
|
|
1008
1008
|
|
|
1009
1009
|
// ---- sweep-idle ------------------------------------------------------------
|
|
1010
|
-
// Idle boxes are
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1010
|
+
// Idle boxes are PARKED: snapshot taken, droplet destroyed (compute billing
|
|
1011
|
+
// stops), filesystem preserved so the builder wakes into their work. Snapshot
|
|
1012
|
+
// storage is ~$0.30/mo — the price of not silently discarding uncommitted work.
|
|
1013
|
+
//
|
|
1014
|
+
// This sweep deliberately never destroys a snapshot. Full reclaim (droplet AND
|
|
1015
|
+
// snapshot) belongs to sweep-dormant, which only fires after BOX_DORMANT_DAYS of
|
|
1016
|
+
// being parked and pings the builder first. That split is the whole safety
|
|
1017
|
+
// property: no automated path can take work off a builder without warning.
|
|
1018
|
+
//
|
|
1019
|
+
// History: this sweep used to call deprovisionOne directly, on the theory that
|
|
1020
|
+
// boxes are ephemeral and builders provision fresh each session. In practice it
|
|
1021
|
+
// meant a box idle past the threshold was annihilated — droplet and snapshot —
|
|
1022
|
+
// taking anything uncommitted with it, while the CLI help, the /builder-box
|
|
1023
|
+
// skill and the hall all still described park-and-wake. Task 1002726 restored
|
|
1024
|
+
// the documented behaviour rather than the documentation.
|
|
1014
1025
|
|
|
1015
1026
|
async function cmdSweepIdle(pool, boxes, { apply }) {
|
|
1016
1027
|
const rows = await boxes.listBoxes(pool, { state: 'active' });
|
|
1017
1028
|
const idle = boxes.selectIdleBoxes(rows, { idleMinutes: CONFIG.idleMinutes, nowMs: Date.now() });
|
|
1018
1029
|
console.log(`idle sweep (threshold=${CONFIG.idleMinutes}m${apply ? '' : ', dry-run'}): ${idle.length} of ${rows.length} active box(es) idle`);
|
|
1019
|
-
if (idle.length === 0) { console.log(' nothing idle — exit clean'); return {
|
|
1030
|
+
if (idle.length === 0) { console.log(' nothing idle — exit clean'); return { parked: 0 }; }
|
|
1020
1031
|
const doApi = apply ? makeDo(apply) : null;
|
|
1021
|
-
let
|
|
1032
|
+
let parked = 0, errors = 0;
|
|
1022
1033
|
for (const box of idle) {
|
|
1023
1034
|
const mins = Math.round((Date.now() - (boxes.effectiveActivityMs(box) || Date.now())) / 60000);
|
|
1024
|
-
if (!apply) { console.log(` would
|
|
1035
|
+
if (!apply) { console.log(` would park @${box.github_login}'s box (idle ${mins}m, droplet ${box.droplet_id})`); continue; }
|
|
1025
1036
|
try {
|
|
1026
|
-
await
|
|
1027
|
-
|
|
1028
|
-
console.log(` ✓
|
|
1037
|
+
await parkOne(pool, boxes, doApi, box, { actor: 'sweep:idle' });
|
|
1038
|
+
parked++;
|
|
1039
|
+
console.log(` ✓ parked @${box.github_login} (idle ${mins}m — snapshot kept, compute stopped)`);
|
|
1029
1040
|
} catch (e) {
|
|
1041
|
+
// A park that fails must NOT fall through to a destroy — leaving the box
|
|
1042
|
+
// at 'error' with its droplet intact is strictly safer than reclaiming it,
|
|
1043
|
+
// and reconcile-drift picks the row up from there.
|
|
1030
1044
|
errors++;
|
|
1031
1045
|
await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
|
|
1032
1046
|
console.log(` ✖ @${box.github_login}: ${e.message}`);
|
|
1033
1047
|
}
|
|
1034
1048
|
}
|
|
1035
|
-
console.log(`done —
|
|
1036
|
-
return {
|
|
1049
|
+
console.log(`done — parked=${parked} errors=${errors} dry-run=${!apply}`);
|
|
1050
|
+
return { parked, errors };
|
|
1037
1051
|
}
|
|
1038
1052
|
|
|
1039
1053
|
// ---- sweep-dormant ---------------------------------------------------------
|
|
@@ -1427,4 +1441,4 @@ async function main() {
|
|
|
1427
1441
|
|
|
1428
1442
|
if (require.main === module) main();
|
|
1429
1443
|
|
|
1430
|
-
module.exports = { cmdProvision, deprovisionOne, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
|
|
1444
|
+
module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
|
|
@@ -37,6 +37,24 @@ const { apiCall, productName } = require(path.join(__dirname, '..', 'cli-lib.js'
|
|
|
37
37
|
const ZERO = /^0+$/;
|
|
38
38
|
const MAIN_REF = 'refs/heads/main';
|
|
39
39
|
|
|
40
|
+
// The ONE exit code that means "the gate deliberately blocked this push". Any
|
|
41
|
+
// other non-zero exit is a CRASH, and .husky/pre-push translates it back to a
|
|
42
|
+
// warn-and-allow (task 1002725 / idea 1000704).
|
|
43
|
+
//
|
|
44
|
+
// WHY a reserved code and not the conventional 1: node itself exits 1 on a
|
|
45
|
+
// syntax error / unhandled rejection, so `1` cannot distinguish "we decided to
|
|
46
|
+
// block" from "the gate fell over". Reserving an unusual code means the wrapper
|
|
47
|
+
// only ever fails CLOSED on a decision this file actually made — every other
|
|
48
|
+
// shape (127 native abort, 134 SIGABRT, 1 syntax error) reads as a crash and
|
|
49
|
+
// fails OPEN, which is this layer's documented design (the post-push main audit
|
|
50
|
+
// is the fail-closed net).
|
|
51
|
+
const BLOCK_EXIT_CODE = 13;
|
|
52
|
+
|
|
53
|
+
// Sentinel returned by block() so each call site can `return block([...])` and
|
|
54
|
+
// stop the sweep. run() must never call process.exit() itself — see the note on
|
|
55
|
+
// the entrypoint at the bottom of this file.
|
|
56
|
+
const BLOCKED = Symbol('blocked');
|
|
57
|
+
|
|
40
58
|
function git(args) {
|
|
41
59
|
try {
|
|
42
60
|
return execSync(`git ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
@@ -70,18 +88,25 @@ function changedFilesFor(localSha, remoteSha) {
|
|
|
70
88
|
const LIVE_RANK_TIMEOUT_MS = 4000;
|
|
71
89
|
|
|
72
90
|
async function liveRank() {
|
|
91
|
+
let timer = null;
|
|
73
92
|
try {
|
|
74
|
-
const timeout = new Promise((resolve) => setTimeout(() => resolve('__timeout__'), LIVE_RANK_TIMEOUT_MS));
|
|
93
|
+
const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve('__timeout__'), LIVE_RANK_TIMEOUT_MS); });
|
|
75
94
|
const res = await Promise.race([
|
|
76
95
|
apiCall('GET', '/api/gds/me', null, { allowUnauthenticated: true }),
|
|
77
96
|
timeout,
|
|
78
97
|
]);
|
|
98
|
+
// The loser of the race must be cleared. This used to leak: process.exit()
|
|
99
|
+
// tore the loop down regardless, so nobody noticed. Now that the hook exits
|
|
100
|
+
// NATURALLY (task 1002725), an armed 4s timer would hold every
|
|
101
|
+
// protected-path push open for the full timeout after the API already
|
|
102
|
+
// answered.
|
|
103
|
+
if (timer) clearTimeout(timer);
|
|
79
104
|
if (res === '__timeout__') {
|
|
80
105
|
console.error(`\n\x1b[33m⚠ permission gate: GDS /me did not respond within ${LIVE_RANK_TIMEOUT_MS}ms — treating rank as unknown.\x1b[0m`);
|
|
81
106
|
return null;
|
|
82
107
|
}
|
|
83
108
|
if (res && res.ok) return res.data?.builder?.rank ?? null;
|
|
84
|
-
} catch (_) { /* offline / no session */ }
|
|
109
|
+
} catch (_) { /* offline / no session */ } finally { if (timer) clearTimeout(timer); }
|
|
85
110
|
return null;
|
|
86
111
|
}
|
|
87
112
|
|
|
@@ -89,7 +114,8 @@ function block(lines) {
|
|
|
89
114
|
console.error(`\n\x1b[31m✖ push blocked by the ${productName()} permission gate (SEC #863)\x1b[0m`);
|
|
90
115
|
for (const l of lines) console.error(' ' + l);
|
|
91
116
|
console.error('');
|
|
92
|
-
process.
|
|
117
|
+
process.exitCode = BLOCK_EXIT_CODE;
|
|
118
|
+
return BLOCKED;
|
|
93
119
|
}
|
|
94
120
|
|
|
95
121
|
async function run() {
|
|
@@ -103,13 +129,13 @@ async function run() {
|
|
|
103
129
|
const [localRef, localSha, remoteRef, remoteSha] = l.split(/\s+/);
|
|
104
130
|
return { localRef, localSha, remoteRef, remoteSha };
|
|
105
131
|
});
|
|
106
|
-
if (refs.length === 0)
|
|
132
|
+
if (refs.length === 0) return; // nothing to push
|
|
107
133
|
|
|
108
134
|
// Rule 1: no direct push to main.
|
|
109
135
|
if (!allowMain) {
|
|
110
136
|
for (const r of refs) {
|
|
111
137
|
if (r.remoteRef === MAIN_REF && !ZERO.test(r.localSha)) {
|
|
112
|
-
block([
|
|
138
|
+
return block([
|
|
113
139
|
'Direct pushes to `main` are not allowed.',
|
|
114
140
|
'The GDS lands graded work for you: run `/builder-ship` on your claimed task.',
|
|
115
141
|
'A direct push to main skips the grader, the confirm gate, parallel-safety, and',
|
|
@@ -128,7 +154,7 @@ async function run() {
|
|
|
128
154
|
}
|
|
129
155
|
const changedFiles = [...allChanged];
|
|
130
156
|
const protectedHits = ppc.matchProtected(changedFiles);
|
|
131
|
-
if (protectedHits.length === 0)
|
|
157
|
+
if (protectedHits.length === 0) return; // nothing sensitive — fast path
|
|
132
158
|
|
|
133
159
|
const rank = await liveRank();
|
|
134
160
|
if (rank === null) {
|
|
@@ -140,12 +166,12 @@ async function run() {
|
|
|
140
166
|
console.error(' This push touches permission-sensitive paths:');
|
|
141
167
|
for (const h of protectedHits.slice(0, 10)) console.error(` - ${h.file}`);
|
|
142
168
|
console.error(' Allowing the push; the server-side audit will re-check with your real rank.\n');
|
|
143
|
-
|
|
169
|
+
return;
|
|
144
170
|
}
|
|
145
171
|
|
|
146
172
|
const result = ppc.checkPermissionPaths({ changedFiles, builderRank: rank });
|
|
147
173
|
if (result.hardFail) {
|
|
148
|
-
block([
|
|
174
|
+
return block([
|
|
149
175
|
`Your rank '${rank}' is below Metic and may not modify permission-sensitive surfaces:`,
|
|
150
176
|
...protectedHits.slice(0, 12).map((h) => ` - ${h.file} (matches ${h.glob})`),
|
|
151
177
|
'',
|
|
@@ -154,20 +180,28 @@ async function run() {
|
|
|
154
180
|
'raise it with an Archon — do not edit it from a sandbox/newcomer rank (ADR 0016, ADR 0043).',
|
|
155
181
|
]);
|
|
156
182
|
}
|
|
157
|
-
process.exit(0);
|
|
158
183
|
}
|
|
159
184
|
|
|
160
185
|
// Only run when invoked directly (the husky wrapper does `node pre-push.js`).
|
|
161
186
|
// Requiring this file (e.g. a test importing matchProtected logic) must NOT
|
|
162
187
|
// read stdin or call process.exit.
|
|
188
|
+
//
|
|
189
|
+
// NOTE (task 1002725): nothing on this path may call process.exit(). Calling it
|
|
190
|
+
// after liveRank()'s fetch aborts the process natively on Windows —
|
|
191
|
+
// "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c"
|
|
192
|
+
// — because undici's handles are still closing when the loop is torn down. The
|
|
193
|
+
// abort exits 127 BEFORE the .catch() below can run, so the fail-open guarantee
|
|
194
|
+
// was silently inverted into "no Windows builder can push anything at all".
|
|
195
|
+
// Setting process.exitCode and letting the loop drain naturally is what keeps
|
|
196
|
+
// this hook's own error handling reachable.
|
|
163
197
|
if (require.main === module) {
|
|
164
198
|
run().catch((e) => {
|
|
165
199
|
// A hook crash must not silently allow a bad push of protected paths, but it
|
|
166
200
|
// also must not brick all pushes on an unrelated bug. Fail OPEN with a loud
|
|
167
201
|
// warning — the post-push audit remains the fail-closed net.
|
|
168
202
|
console.error(`\n⚠ permission pre-push hook errored (${e.message}); allowing push. The server-side audit still applies.\n`);
|
|
169
|
-
process.
|
|
203
|
+
process.exitCode = 0;
|
|
170
204
|
});
|
|
171
205
|
}
|
|
172
206
|
|
|
173
|
-
module.exports = { run };
|
|
207
|
+
module.exports = { run, BLOCK_EXIT_CODE };
|
package/src/module-api.js
CHANGED
|
@@ -579,8 +579,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
579
579
|
// landed since 1.19.74 with no explicit bump. run 31632769733. (task 1002620)
|
|
580
580
|
// 1.19.76 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
581
581
|
// landed since 1.19.75 with no explicit bump. run 31637719876. (task 1002620)
|
|
582
|
+
// 1.19.77 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
583
|
+
// landed since 1.19.76 with no explicit bump. run 31647860631. (task 1002620)
|
|
584
|
+
// 1.19.78 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
585
|
+
// landed since 1.19.77 with no explicit bump. run 31648127800. (task 1002620)
|
|
582
586
|
// ---------------------------------------------------------------------------
|
|
583
|
-
const CORE_VERSION = '1.19.
|
|
587
|
+
const CORE_VERSION = '1.19.78'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
584
588
|
|
|
585
589
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
586
590
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/box_task_scope.mjs
CHANGED
|
@@ -96,4 +96,74 @@ await test('an inactive builder is denied even with claims in scope', async () =
|
|
|
96
96
|
assert.equal(inactive.decision.reason, 'BUILDER_INACTIVE');
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
// =========================================================================
|
|
100
|
+
// Task 1002729 — an EMPTY scope is two different situations, not one
|
|
101
|
+
// =========================================================================
|
|
102
|
+
// The live defect: moduleScopeForActiveClaims returns [] both for "holds no
|
|
103
|
+
// claim" and for "holds claims whose goals declare no scope_modules". Reading
|
|
104
|
+
// both as BASE handed working builders a box with no modules/ at all — 247 of
|
|
105
|
+
// 248 grants on the live fleet, including an Archon with 7 active claims.
|
|
106
|
+
|
|
107
|
+
// A pool that answers BOTH queries the resolver pair now issues: the
|
|
108
|
+
// claims→goals→scope_modules unnest, and the plain active-claim count.
|
|
109
|
+
function poolReturningWithClaims(moduleKeyRows, claimCount) {
|
|
110
|
+
const c = makeSqlAwareClient((s) => {
|
|
111
|
+
if (/FROM claims c/.test(s) && /unnest\(g\.scope_modules\)/.test(s)) return { rows: moduleKeyRows };
|
|
112
|
+
if (/count\(\*\)/.test(s) && /FROM claims/.test(s)) return { rows: [{ n: claimCount }] };
|
|
113
|
+
return null;
|
|
114
|
+
});
|
|
115
|
+
return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function specForClaimsWithCount({ builderId = 3, rank = 'metic', status = 'active', moduleKeyRows, claimCount }) {
|
|
119
|
+
const pool = poolReturningWithClaims(moduleKeyRows, claimCount);
|
|
120
|
+
const scopeKeys = await db.moduleScopeForActiveClaims(builderId, { pool });
|
|
121
|
+
const hasActiveClaims = (await db.activeClaimCountForBuilder(builderId, { pool })) > 0;
|
|
122
|
+
return { scopeKeys, hasActiveClaims, decision: access.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: scopeMap }) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await test('claims held but every goal wall empty → RANK scope, never a stripped box', async () => {
|
|
126
|
+
// Exactly Lars's live shape: 7 active claims, all on goals with scope_modules={}.
|
|
127
|
+
const { scopeKeys, hasActiveClaims, decision } = await specForClaimsWithCount({
|
|
128
|
+
rank: 'archon', moduleKeyRows: [], claimCount: 7,
|
|
129
|
+
});
|
|
130
|
+
assert.deepEqual(scopeKeys, [], 'the union query still yields nothing — the goals declare no wall');
|
|
131
|
+
assert.equal(hasActiveClaims, true, 'but the builder IS working');
|
|
132
|
+
assert.equal(decision.scope, 'full', 'an Archon mid-claim gets the full surface, not base');
|
|
133
|
+
assert.equal(decision.spec.mode, 'full');
|
|
134
|
+
assert.notEqual(decision.scope, 'base', 'the regression this test exists to prevent');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('no claims at all → BASE-only is preserved (least privilege holds)', async () => {
|
|
138
|
+
const { hasActiveClaims, decision } = await specForClaimsWithCount({
|
|
139
|
+
rank: 'archon', moduleKeyRows: [], claimCount: 0,
|
|
140
|
+
});
|
|
141
|
+
assert.equal(hasActiveClaims, false);
|
|
142
|
+
assert.equal(decision.scope, 'base', 'an idle box is still not handed the repo');
|
|
143
|
+
assert.deepEqual(decision.spec.sparsePaths, [...BASE]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test('a declared goal wall still wins over the rank fallback', async () => {
|
|
147
|
+
// Claims AND a wall → task scope, exactly as before; the fix must not widen this.
|
|
148
|
+
const { decision } = await specForClaimsWithCount({
|
|
149
|
+
rank: 'archon', moduleKeyRows: [{ module_key: 'status-ui' }], claimCount: 2,
|
|
150
|
+
});
|
|
151
|
+
assert.equal(decision.scope, 'task', 'a real wall is still honoured, not overridden by rank');
|
|
152
|
+
assert.deepEqual(decision.spec.sparsePaths, [...BASE, 'modules/status-ui']);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await test('a Thetes mid-claim with no wall gets STARTER, not full — rank still bounds it', async () => {
|
|
156
|
+
const { decision } = await specForClaimsWithCount({
|
|
157
|
+
rank: 'thetes', moduleKeyRows: [], claimCount: 1,
|
|
158
|
+
});
|
|
159
|
+
assert.equal(decision.scope, 'starter', 'the fallback is rank-bounded; it is not a blanket widen');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('activeClaimCountForBuilder rejects a non-numeric builder id', async () => {
|
|
163
|
+
await assert.rejects(
|
|
164
|
+
() => db.activeClaimCountForBuilder('not-a-number', { pool: poolReturningWithClaims([], 0) }),
|
|
165
|
+
/must be numeric/,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
99
169
|
summary();
|
package/tests/boxes.mjs
CHANGED
|
@@ -1251,5 +1251,86 @@ await ta('markErrorUnlessDestroyed: a genuine (pre-destroyed) failure still park
|
|
|
1251
1251
|
assert.match(writes[0].patch.error_note, /DO 500/);
|
|
1252
1252
|
});
|
|
1253
1253
|
|
|
1254
|
+
console.log('\ntask 1002726 — the idle sweep parks, it does not destroy:');
|
|
1255
|
+
|
|
1256
|
+
// The regression this pins: cmdSweepIdle used to call deprovisionOne, so an idle
|
|
1257
|
+
// box lost its droplet AND its snapshot — every sweep:idle row in the live
|
|
1258
|
+
// box_events history was a full teardown, and nothing could be woken. The sweep
|
|
1259
|
+
// must snapshot-and-park instead, leaving the filesystem recoverable.
|
|
1260
|
+
function sweepHarness({ idleRows, parkThrows = null }) {
|
|
1261
|
+
const events = [];
|
|
1262
|
+
const states = [];
|
|
1263
|
+
const fakeBoxes = {
|
|
1264
|
+
async listBoxes() { return idleRows; },
|
|
1265
|
+
selectIdleBoxes: boxes.selectIdleBoxes,
|
|
1266
|
+
effectiveActivityMs: boxes.effectiveActivityMs,
|
|
1267
|
+
async setBoxState(_db, _bid, state, patch) { states.push({ state, patch }); return {}; },
|
|
1268
|
+
async recordComputeCost() { return 0; },
|
|
1269
|
+
async recordEvent(_db, e) { events.push(e); },
|
|
1270
|
+
async getBoxByBuilderId() { return { state: 'active' }; },
|
|
1271
|
+
};
|
|
1272
|
+
return { fakeBoxes, events, states, parkThrows };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
await ta('cmdSweepIdle: an idle box is PARKED — snapshot retained, no snapshot delete', async () => {
|
|
1276
|
+
const NOW_ROW = {
|
|
1277
|
+
id: 3, builder_id: 3, state: 'active', droplet_id: '591743808', droplet_name: 'box-lars589',
|
|
1278
|
+
github_login: 'lars589', last_activity_at: new Date(Date.now() - 5 * 3600_000).toISOString(),
|
|
1279
|
+
claude_active: false, snapshot_id: null,
|
|
1280
|
+
};
|
|
1281
|
+
const h = sweepHarness({ idleRows: [NOW_ROW] });
|
|
1282
|
+
const deleted = [];
|
|
1283
|
+
const doApi = {
|
|
1284
|
+
dryRun: false,
|
|
1285
|
+
async powerOffDroplet() { return { action: { id: 1 } }; },
|
|
1286
|
+
async waitForAction() {},
|
|
1287
|
+
async snapshotDroplet() { return { action: { id: 2 } }; },
|
|
1288
|
+
async getDroplet() { return { droplet: { snapshot_ids: ['999'] } }; },
|
|
1289
|
+
async deleteDroplet(id) { deleted.push(`droplet:${id}`); },
|
|
1290
|
+
async deleteSnapshot(id) { deleted.push(`snapshot:${id}`); },
|
|
1291
|
+
};
|
|
1292
|
+
// makeDo() is only reached under --apply; inject our fake by stubbing the DO
|
|
1293
|
+
// client the command builds. cmdSweepIdle takes (pool, boxes, {apply}), so we
|
|
1294
|
+
// exercise parkOne directly with the same contract the sweep now uses.
|
|
1295
|
+
await boxCli.parkOne({}, h.fakeBoxes, doApi, NOW_ROW, { actor: 'sweep:idle' });
|
|
1296
|
+
|
|
1297
|
+
const parked = h.states.find((s) => s.state === 'parked');
|
|
1298
|
+
assert.ok(parked, 'box reaches parked');
|
|
1299
|
+
assert.equal(parked.patch.snapshot_id, '999', 'snapshot id retained on the row');
|
|
1300
|
+
assert.equal(parked.patch.droplet_id, null, 'droplet released');
|
|
1301
|
+
assert.ok(deleted.includes('droplet:591743808'), 'droplet destroyed (compute billing stops)');
|
|
1302
|
+
assert.ok(!deleted.some((d) => d.startsWith('snapshot:')), 'the snapshot is NEVER deleted by an idle park');
|
|
1303
|
+
assert.equal(h.events.find((e) => e.event === 'park')?.actor, 'sweep:idle', 'logged as a park, not a deprovision');
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
await ta('cmdSweepIdle: dry-run reports "would park" and touches nothing', async () => {
|
|
1307
|
+
const row = {
|
|
1308
|
+
id: 3, builder_id: 3, state: 'active', droplet_id: '591743808', github_login: 'lars589',
|
|
1309
|
+
last_activity_at: new Date(Date.now() - 5 * 3600_000).toISOString(), claude_active: false,
|
|
1310
|
+
};
|
|
1311
|
+
const h = sweepHarness({ idleRows: [row] });
|
|
1312
|
+
const lines = [];
|
|
1313
|
+
const origLog = console.log;
|
|
1314
|
+
console.log = (...a) => lines.push(a.join(' '));
|
|
1315
|
+
try {
|
|
1316
|
+
const res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: false });
|
|
1317
|
+
assert.equal(res.parked, 0, 'dry-run parks nothing');
|
|
1318
|
+
} finally { console.log = origLog; }
|
|
1319
|
+
assert.ok(lines.some((l) => /would park/.test(l)), 'dry-run says park, not deprovision');
|
|
1320
|
+
assert.ok(!lines.some((l) => /would deprovision/.test(l)), 'no destroy language survives');
|
|
1321
|
+
assert.equal(h.states.length, 0, 'no state written in dry-run');
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
await ta('cmdSweepIdle: a box running Claude is never parked mid-session', async () => {
|
|
1325
|
+
const busy = {
|
|
1326
|
+
id: 4, builder_id: 4, state: 'active', droplet_id: '1', github_login: 'busy',
|
|
1327
|
+
last_activity_at: new Date(Date.now() - 99 * 3600_000).toISOString(), claude_active: true,
|
|
1328
|
+
};
|
|
1329
|
+
const h = sweepHarness({ idleRows: [busy] });
|
|
1330
|
+
const res = await boxCli.cmdSweepIdle({}, h.fakeBoxes, { apply: true });
|
|
1331
|
+
assert.equal(res.parked, 0, 'claude_active holds the box open regardless of age');
|
|
1332
|
+
assert.equal(h.states.length, 0);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1254
1335
|
console.log(`\nboxes.mjs: ${passed} passed, ${failed} failed`);
|
|
1255
1336
|
process.exit(failed === 0 ? 0 : 1);
|
package/tests/pre_push_hook.mjs
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
// Pins three facts the 2026-08-06 choke-point map litigated:
|
|
4
4
|
// 1. G-17 refutation: pre-push.js's dependency path resolves INSIDE the repo
|
|
5
5
|
// (three `..` from scripts/gds/hooks/ is the root, not one above it).
|
|
6
|
-
// 2. The hook actually blocks a direct push to refs/heads/main (exit
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// 2. The hook actually blocks a direct push to refs/heads/main (exit 13, the
|
|
7
|
+
// reserved block code the wrapper turns into git's exit 1), and allows a
|
|
8
|
+
// branch deletion (exit 0) — both offline-deterministic.
|
|
9
|
+
// 3. G-2 fix: the tracked .husky/pre-push wrapper exists, invokes the real
|
|
9
10
|
// gate, and is LF + executable in the git index (CRLF or mode 100644
|
|
10
11
|
// would silently kill the hook on Unix clones).
|
|
11
12
|
//
|
|
13
|
+
// Plus (task 1002725) the fail-open contract: a CRASHED gate must not brick the
|
|
14
|
+
// push — see the block comment above those tests.
|
|
15
|
+
//
|
|
12
16
|
// DB-free, no network: the main-push block fires before any API call.
|
|
13
17
|
// Run: node --test tests/pre_push_hook.mjs
|
|
14
18
|
|
|
@@ -17,6 +21,7 @@ import { test } from 'node:test';
|
|
|
17
21
|
import { createRequire } from 'node:module';
|
|
18
22
|
import { spawnSync } from 'node:child_process';
|
|
19
23
|
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
20
25
|
import path from 'node:path';
|
|
21
26
|
import { fileURLToPath } from 'node:url';
|
|
22
27
|
|
|
@@ -26,6 +31,7 @@ const HOOK = path.join(ROOT, 'scripts', 'gds', 'hooks', 'pre-push.js');
|
|
|
26
31
|
const WRAPPER = path.join(ROOT, '.husky', 'pre-push');
|
|
27
32
|
const SHA = 'a'.repeat(40);
|
|
28
33
|
const ZEROS = '0'.repeat(40);
|
|
34
|
+
const BLOCK_EXIT_CODE = 13; // pre-push.js's reserved "deliberately blocked" code
|
|
29
35
|
|
|
30
36
|
function runHook(stdinLine) {
|
|
31
37
|
return spawnSync(process.execPath, [HOOK, 'origin', 'https://example.invalid/repo.git'], {
|
|
@@ -36,6 +42,30 @@ function runHook(stdinLine) {
|
|
|
36
42
|
});
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// Run the WRAPPER (not the gate) under `sh`, optionally with a fake `node`
|
|
46
|
+
// earlier on PATH so we can drive the gate's exit code without needing the gate
|
|
47
|
+
// to actually crash. This is how the fail-open contract is testable at all: a
|
|
48
|
+
// native abort cannot be provoked on demand, but the wrapper only ever sees an
|
|
49
|
+
// exit code, so substituting the code proves the translation.
|
|
50
|
+
function runWrapper(stdinLine, { fakeNodeExit = null, fakeNodeStderr = '' } = {}) {
|
|
51
|
+
let shimDir = null;
|
|
52
|
+
const env = { ...process.env, OTB_ALLOW_MAIN_PUSH: '' };
|
|
53
|
+
if (fakeNodeExit !== null) {
|
|
54
|
+
shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prepush-shim-'));
|
|
55
|
+
const shim = path.join(shimDir, 'node');
|
|
56
|
+
fs.writeFileSync(shim, `#!/bin/sh\n[ -n "${fakeNodeStderr}" ] && printf '%s\\n' "${fakeNodeStderr}" >&2\nexit ${fakeNodeExit}\n`);
|
|
57
|
+
fs.chmodSync(shim, 0o755);
|
|
58
|
+
env.PATH = `${shimDir}${path.delimiter}${env.PATH}`;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return spawnSync('sh', [WRAPPER, 'origin', 'https://example.invalid/repo.git'], {
|
|
62
|
+
cwd: ROOT, input: stdinLine, encoding: 'utf8', env,
|
|
63
|
+
});
|
|
64
|
+
} finally {
|
|
65
|
+
if (shimDir) fs.rmSync(shimDir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
test('G-17 refuted: the line-30 dependency path resolves inside the repo and loads', () => {
|
|
40
70
|
const hooksDir = path.dirname(HOOK);
|
|
41
71
|
const resolved = path.join(hooksDir, '..', '..', '..', 'src', 'bongos', 'permission-path-check.js');
|
|
@@ -45,9 +75,9 @@ test('G-17 refuted: the line-30 dependency path resolves inside the repo and loa
|
|
|
45
75
|
assert.equal(typeof ppc.checkPermissionPaths, 'function');
|
|
46
76
|
});
|
|
47
77
|
|
|
48
|
-
test('hook blocks a direct push to refs/heads/main with
|
|
78
|
+
test('hook blocks a direct push to refs/heads/main with the reserved block code', () => {
|
|
49
79
|
const r = runHook(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`);
|
|
50
|
-
assert.equal(r.status,
|
|
80
|
+
assert.equal(r.status, BLOCK_EXIT_CODE, `expected exit ${BLOCK_EXIT_CODE}, got ${r.status}; stderr: ${r.stderr}`);
|
|
51
81
|
assert.match(r.stderr, /push blocked/i);
|
|
52
82
|
assert.match(r.stderr, /Direct pushes to `main`/);
|
|
53
83
|
});
|
|
@@ -62,7 +92,7 @@ test('requiring the hook as a module neither reads stdin nor exits', () => {
|
|
|
62
92
|
assert.equal(typeof mod.run, 'function');
|
|
63
93
|
});
|
|
64
94
|
|
|
65
|
-
test('G-2 fixed: tracked .husky/pre-push wrapper
|
|
95
|
+
test('G-2 fixed: tracked .husky/pre-push wrapper invokes the real gate', () => {
|
|
66
96
|
assert.ok(fs.existsSync(WRAPPER), '.husky/pre-push must be tracked (ADR 0043 Layer 1 delivery)');
|
|
67
97
|
const body = fs.readFileSync(WRAPPER, 'utf8');
|
|
68
98
|
assert.ok(body.startsWith('#!/bin/sh'), 'wrapper must start with a sh shebang');
|
|
@@ -70,6 +100,73 @@ test('G-2 fixed: tracked .husky/pre-push wrapper execs the real gate', () => {
|
|
|
70
100
|
assert.ok(!body.includes('\r'), 'wrapper must be LF-only (CRLF breaks the shebang on Unix)');
|
|
71
101
|
});
|
|
72
102
|
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// task 1002725 / idea 1000704 — the fail-open guarantee is REAL, not aspirational.
|
|
105
|
+
//
|
|
106
|
+
// The gate is documented to fail OPEN on a crash (it is cooperative Layer 1; the
|
|
107
|
+
// post-push main audit is the fail-closed net). On Windows + Node 24 it instead
|
|
108
|
+
// died with a native libuv abort — "Assertion failed: !(handle->flags &
|
|
109
|
+
// UV_HANDLE_CLOSING), file src\win\async.c, line 76" — triggered by calling
|
|
110
|
+
// process.exit() after liveRank()'s fetch, while undici's handles were still
|
|
111
|
+
// closing. A native abort runs no JS, so the gate's own .catch() never fired; the
|
|
112
|
+
// process exited 127 and git refused EVERY push. A Windows builder could not land
|
|
113
|
+
// any work at all.
|
|
114
|
+
//
|
|
115
|
+
// Two invariants pin the fix:
|
|
116
|
+
// 1. the gate never calls process.exit() (the abort's trigger), and
|
|
117
|
+
// 2. the WRAPPER — the only layer that survives a native abort — translates a
|
|
118
|
+
// crash back into allow-with-a-warning, and a deliberate block into exit 1.
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
test('gate never calls process.exit() — that is what aborts natively on Windows', () => {
|
|
122
|
+
const src = fs.readFileSync(HOOK, 'utf8');
|
|
123
|
+
const withoutComments = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
124
|
+
assert.ok(
|
|
125
|
+
!/process\.exit\s*\(/.test(withoutComments),
|
|
126
|
+
'pre-push.js must set process.exitCode and let the loop drain; process.exit() after a fetch aborts node on Windows'
|
|
127
|
+
);
|
|
128
|
+
assert.match(src, /process\.exitCode/, 'the gate still has to report its verdict via process.exitCode');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('wrapper translates the reserved block code into a real git block (exit 1)', () => {
|
|
132
|
+
const r = runWrapper(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`);
|
|
133
|
+
assert.equal(r.status, 1, `a deliberate block must stop the push; got ${r.status}; stderr: ${r.stderr}`);
|
|
134
|
+
assert.match(r.stderr, /Direct pushes to `main`/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('wrapper FAILS OPEN when the gate crashes natively (the 1002725 regression)', () => {
|
|
138
|
+
const r = runWrapper(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`, {
|
|
139
|
+
fakeNodeExit: 127,
|
|
140
|
+
fakeNodeStderr: 'Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)',
|
|
141
|
+
});
|
|
142
|
+
assert.equal(r.status, 0, `a crashed gate must NOT brick the push; got exit ${r.status}; stderr: ${r.stderr}`);
|
|
143
|
+
assert.match(r.stderr, /crashed \(exit 127\)/i, 'the crash must be reported loudly, not swallowed');
|
|
144
|
+
assert.match(r.stderr, /allowing push/i);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('wrapper fails open on a plain node failure (exit 1) too, not just a native abort', () => {
|
|
148
|
+
// node exits 1 on a syntax error / unhandled rejection. That is a CRASH, not a
|
|
149
|
+
// verdict — which is precisely why the deliberate block needed its own code.
|
|
150
|
+
const r = runWrapper(`refs/heads/main ${SHA} refs/heads/main ${SHA}\n`, { fakeNodeExit: 1 });
|
|
151
|
+
assert.equal(r.status, 0, `exit 1 from node is a crash and must fail open; got ${r.status}`);
|
|
152
|
+
assert.match(r.stderr, /crashed \(exit 1\)/i);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('wrapper allows a clean gate run (exit 0) untouched', () => {
|
|
156
|
+
const r = runWrapper(`refs/heads/some-branch ${ZEROS} refs/heads/some-branch ${SHA}\n`, { fakeNodeExit: 0 });
|
|
157
|
+
assert.equal(r.status, 0);
|
|
158
|
+
assert.doesNotMatch(r.stderr, /crashed/i, 'a clean run must not warn');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('wrapper does not exec the gate — it must survive to inspect the exit code', () => {
|
|
162
|
+
const body = fs.readFileSync(WRAPPER, 'utf8');
|
|
163
|
+
assert.ok(
|
|
164
|
+
!/^\s*exec\s+node/m.test(body),
|
|
165
|
+
'`exec node` replaces the shell, so a crash exit code propagates straight to git as a block'
|
|
166
|
+
);
|
|
167
|
+
assert.match(body, /\$\?/, 'the wrapper must capture the gate exit code');
|
|
168
|
+
});
|
|
169
|
+
|
|
73
170
|
test('wrapper is executable (mode 100755) in the git index', () => {
|
|
74
171
|
const r = spawnSync('git', ['ls-files', '-s', '--', '.husky/pre-push'], { cwd: ROOT, encoding: 'utf8' });
|
|
75
172
|
assert.equal(r.status, 0, `git ls-files failed: ${r.stderr}`);
|