@bongos/core 1.19.645 → 1.19.647
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 +36 -31
- package/.claude/skills/grade-recover/SKILL.md +4 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +137 -3
- package/docs/api-reference.md +4 -2
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_237_box_widen_paths.sql +40 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/dev-box/box-access.js +128 -4
- package/modules/dev-box/boxes.js +21 -0
- package/modules/dev-box/routes/box.js +108 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-sync.js +84 -1
- package/scripts/gds/claude-materialize.js +14 -1
- package/scripts/gds/ship-flow.js +27 -1
- package/scripts/gds/ship-honesty.js +53 -0
- package/src/module-api.js +1 -1
- package/tests/box_access.mjs +155 -0
- package/tests/box_sync_scope_report.mjs +45 -0
- package/tests/claude_materialize.mjs +30 -0
- package/tests/ship_cannot_lie.mjs +103 -1
|
@@ -254,6 +254,117 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
|
|
|
254
254
|
return [...base, ...extra];
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// PURE: builder-requested WIDENING (task 1003087 / idea 1000793)
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
// A builder sometimes needs a directory their task scope did not pull — to run
|
|
262
|
+
// the full suite, to read a caller, to regenerate a TREE-DEPENDENT artifact. The
|
|
263
|
+
// obvious move, `git sparse-checkout add <path>`, does not survive: the */10
|
|
264
|
+
// source-fetch cron re-applies the SERVER-computed set and deletes the addition,
|
|
265
|
+
// including WHILE A COMMAND IS RUNNING (a live suite died mid-run with
|
|
266
|
+
// MODULE_NOT_FOUND that read exactly like a code bug; a box-committed
|
|
267
|
+
// gen-repo-map regen off a narrow tree reds CI's freshness gate — learning
|
|
268
|
+
// 1000192, which stranded PR #254).
|
|
269
|
+
//
|
|
270
|
+
// So the widening is recorded SERVER-side and unioned in HERE, which is what
|
|
271
|
+
// makes it tick-surviving: the cron asks this endpoint for the set on every tick,
|
|
272
|
+
// so it gets the union too. Nothing in the instance-owned fetch script changes —
|
|
273
|
+
// which matters, because the neutral core does not ship it (ADR 0150).
|
|
274
|
+
|
|
275
|
+
// Bounds. Small on purpose: this is an escape hatch for a handful of dirs, not a
|
|
276
|
+
// second scope system. Over the cap the extras are dropped, not an error — a
|
|
277
|
+
// truncated widen still fetches, and failing the source pull over it would be a
|
|
278
|
+
// far worse trade than materializing less.
|
|
279
|
+
const MAX_WIDEN_PATHS = 24;
|
|
280
|
+
const MAX_WIDEN_PATH_LEN = 200;
|
|
281
|
+
|
|
282
|
+
// withinAny(p, roots) — is p one of roots, or underneath one? Cone mode
|
|
283
|
+
// materializes a directory and everything below it, so "already covered" and
|
|
284
|
+
// "inside the allowed surface" are the same ancestor walk.
|
|
285
|
+
function withinAny(p, roots) {
|
|
286
|
+
let q = p;
|
|
287
|
+
for (;;) {
|
|
288
|
+
if (roots.includes(q)) return true;
|
|
289
|
+
const cut = q.lastIndexOf('/');
|
|
290
|
+
if (cut < 0) return false;
|
|
291
|
+
q = q.slice(0, cut);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// normalizeWidenPath — repo-relative POSIX directory, or null when it is not one.
|
|
296
|
+
//
|
|
297
|
+
// This is a path that reaches `git sparse-checkout set` on a box, so it is
|
|
298
|
+
// validated as INPUT rather than trusted: absolute paths, Windows drive letters,
|
|
299
|
+
// '..' traversal and empty segments are all refused rather than normalized away,
|
|
300
|
+
// because "cleaned up into something that still works" is how a traversal becomes
|
|
301
|
+
// a shrug. Backslashes are folded to '/' first so a Windows-typed path is judged
|
|
302
|
+
// on what it means, not on its separator.
|
|
303
|
+
function normalizeWidenPath(raw) {
|
|
304
|
+
if (typeof raw !== 'string') return null;
|
|
305
|
+
// BACKSLASH by char code, not as a literal: this file is edited through
|
|
306
|
+
// tooling that has silently collapsed a doubled backslash before, and a
|
|
307
|
+
// separator fold that quietly stops folding is invisible until a Windows
|
|
308
|
+
// path slips through as one long segment.
|
|
309
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
310
|
+
let p = raw.trim().split(BACKSLASH).join('/');
|
|
311
|
+
while (p.startsWith('./')) p = p.slice(2);
|
|
312
|
+
p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
|
|
313
|
+
if (!p || p === '.' || p.length > MAX_WIDEN_PATH_LEN) return null;
|
|
314
|
+
if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) return null;
|
|
315
|
+
const segs = p.split('/');
|
|
316
|
+
if (!segs.every((s) => s && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s))) return null;
|
|
317
|
+
return p;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// admissibleWidenPaths — the widen requests this builder's RANK actually permits,
|
|
321
|
+
// normalized, deduped, capped and sorted.
|
|
322
|
+
//
|
|
323
|
+
// THIS IS THE SECURITY BOUNDARY OF THE FEATURE, so it is stated plainly: widening
|
|
324
|
+
// changes what is MATERIALIZED on a box, and rank decides how much of the repo a
|
|
325
|
+
// box may hold at all (ADR 0031 section 9.2 — 'full' for Metic+, the 'starter'
|
|
326
|
+
// surface below that). Honoring an arbitrary widen would hand a Xenos the whole
|
|
327
|
+
// tree through a convenience flag, which is a rank escalation dressed as
|
|
328
|
+
// ergonomics. So:
|
|
329
|
+
// - a builder whose RANK scope is 'full' may widen anywhere (they are already
|
|
330
|
+
// entitled to the entire repo; the widen only decides what gets pulled);
|
|
331
|
+
// - anyone else may widen only INSIDE the starter surface. That is not a null
|
|
332
|
+
// grant: a task-scoped box is BASE union its modules, which is NARROWER than
|
|
333
|
+
// starter, so this is exactly the room to climb back to what their rank
|
|
334
|
+
// already allowed — and no further.
|
|
335
|
+
// The authoring wall is untouched either way. What a builder may CHANGE is still
|
|
336
|
+
// goal-scope-check against the module globs, never what happens to be on disk
|
|
337
|
+
// (ADR 0148).
|
|
338
|
+
function admissibleWidenPaths(widenPaths, rank) {
|
|
339
|
+
const unrestricted = boxScopeForRank(rank) === 'full';
|
|
340
|
+
const out = [];
|
|
341
|
+
const seen = new Set();
|
|
342
|
+
for (const raw of Array.isArray(widenPaths) ? widenPaths : []) {
|
|
343
|
+
const p = normalizeWidenPath(raw);
|
|
344
|
+
if (!p || seen.has(p)) continue;
|
|
345
|
+
if (!unrestricted && !withinAny(p, STARTER_SPARSE_PATHS)) continue;
|
|
346
|
+
seen.add(p);
|
|
347
|
+
out.push(p);
|
|
348
|
+
if (out.length >= MAX_WIDEN_PATHS) break;
|
|
349
|
+
}
|
|
350
|
+
return out.sort();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// withWidenPaths — union admitted widen dirs into a computed sparse set, skipping
|
|
354
|
+
// any the set already covers (cone mode pulls a dir and everything under it, so
|
|
355
|
+
// adding 'scripts/gds' when 'scripts' is present is noise in the git command and
|
|
356
|
+
// noise in the box's scope readout). A null set is the 'full' clone — there is
|
|
357
|
+
// nothing to widen into, so it passes through untouched.
|
|
358
|
+
function withWidenPaths(sparsePaths, admitted) {
|
|
359
|
+
if (!Array.isArray(sparsePaths) || !admitted || admitted.length === 0) return sparsePaths;
|
|
360
|
+
const out = [...sparsePaths];
|
|
361
|
+
for (const dir of admitted) {
|
|
362
|
+
if (withinAny(dir, out)) continue;
|
|
363
|
+
out.push(dir);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
257
368
|
// ---------------------------------------------------------------------------
|
|
258
369
|
// PURE: the per-fetch access decision (consumed by GET /box/source-access)
|
|
259
370
|
// ---------------------------------------------------------------------------
|
|
@@ -295,7 +406,7 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
|
|
|
295
406
|
// { allowed:false, reason:'BUILDER_INACTIVE' }
|
|
296
407
|
// { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
|
|
297
408
|
// { allowed:true, scope, spec }
|
|
298
|
-
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
|
|
409
|
+
function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap, widenPaths } = {}) {
|
|
299
410
|
if (status && status !== 'active') {
|
|
300
411
|
return { allowed: false, reason: 'BUILDER_INACTIVE' };
|
|
301
412
|
}
|
|
@@ -307,23 +418,32 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
|
|
|
307
418
|
actual: rank ?? null,
|
|
308
419
|
};
|
|
309
420
|
}
|
|
421
|
+
// The builder's own widening, narrowed to what their rank already reaches
|
|
422
|
+
// (task 1003087). Computed once here so every sparse branch below unions the
|
|
423
|
+
// SAME set — a widen that applied to one scope and not another would be a
|
|
424
|
+
// surprise that only surfaces when a claim changes.
|
|
425
|
+
const widen = admissibleWidenPaths(widenPaths, rank);
|
|
426
|
+
|
|
310
427
|
// Claim-driven task scope (present → the box asks for exactly its work's code).
|
|
311
428
|
if (Array.isArray(scopeKeys)) {
|
|
312
429
|
if (scopeKeys.length > 0) {
|
|
313
|
-
const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
|
|
430
|
+
const sparsePaths = withWidenPaths(sparsePathsForScopeKeys(scopeKeys, moduleScopeMap), widen);
|
|
314
431
|
return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
|
|
315
432
|
}
|
|
316
433
|
// Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
|
|
317
434
|
// NOT a stripped box — the planning gap is not theirs to pay for.
|
|
318
435
|
if (!hasActiveClaims) {
|
|
319
|
-
const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
|
|
436
|
+
const sparsePaths = withWidenPaths(sparsePathsForScopeKeys([], moduleScopeMap), widen);
|
|
320
437
|
return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
|
|
321
438
|
}
|
|
322
439
|
}
|
|
323
440
|
// No claim signal (no lifecycle port on this instance), or claims with an
|
|
324
441
|
// undeclared goal wall: the pre-ADR-0148 rank fallback.
|
|
325
442
|
const scope = boxScopeForRank(rank);
|
|
326
|
-
|
|
443
|
+
const spec = cloneSpecForScope(scope);
|
|
444
|
+
// 'full' carries sparsePaths null (the whole repo) — withWidenPaths passes it
|
|
445
|
+
// through, because there is no sparse set to widen INTO.
|
|
446
|
+
return { allowed: true, scope, spec: { ...spec, sparsePaths: withWidenPaths(spec.sparsePaths, widen) } };
|
|
327
447
|
}
|
|
328
448
|
|
|
329
449
|
// scopeMetadata — PURE. The credential-FREE projection of a source-access
|
|
@@ -470,6 +590,10 @@ module.exports = {
|
|
|
470
590
|
boxCanHoldSource,
|
|
471
591
|
cloneSpecForScope,
|
|
472
592
|
sparsePathsForScopeKeys, // ADR 0148 — BASE ∪ the claims' module dirs (cone-mode)
|
|
593
|
+
MAX_WIDEN_PATHS, // task 1003087 — the builder-widening cap
|
|
594
|
+
normalizeWidenPath, // task 1003087 — repo-relative dir, or null
|
|
595
|
+
admissibleWidenPaths, // task 1003087 — the widen requests this RANK permits
|
|
596
|
+
withWidenPaths, // task 1003087 — union a widen set into a sparse set
|
|
473
597
|
scopeMetadata, // BV1.R100 — credential-free projection for GET /box/scope
|
|
474
598
|
// pure — decisions
|
|
475
599
|
decideSourceAccess,
|
package/modules/dev-box/boxes.js
CHANGED
|
@@ -397,6 +397,26 @@ function decideEnsureAction({ boxState, rank, status, blocked = false, floor = D
|
|
|
397
397
|
// DB helpers (async). Each takes the pg pool (or a client mid-transaction).
|
|
398
398
|
// ---------------------------------------------------------------------------
|
|
399
399
|
|
|
400
|
+
// setBoxWidenPaths — replace a box row's builder-requested widen set (task
|
|
401
|
+
// 1003087). Whole-set replace rather than add/remove SQL: the set is tiny and
|
|
402
|
+
// capped, the caller has already normalized and rank-filtered it, and a
|
|
403
|
+
// read-modify-write in one statement keeps two concurrent widens from
|
|
404
|
+
// interleaving into a half-applied list.
|
|
405
|
+
//
|
|
406
|
+
// Returns the stored array, or null when the builder has no box row — the
|
|
407
|
+
// caller turns that into a 404 rather than silently creating one, because a
|
|
408
|
+
// widen with no box to apply it to is a request that has not happened yet.
|
|
409
|
+
async function setBoxWidenPaths(db, builderId, paths) {
|
|
410
|
+
const { rows } = await db.query(
|
|
411
|
+
`UPDATE builder_boxes
|
|
412
|
+
SET widen_paths = $2::text[], updated_at = now()
|
|
413
|
+
WHERE builder_id = $1
|
|
414
|
+
RETURNING widen_paths`,
|
|
415
|
+
[builderId, Array.isArray(paths) ? paths : []]
|
|
416
|
+
);
|
|
417
|
+
return rows[0] ? rows[0].widen_paths : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
400
420
|
async function getBoxByBuilderId(db, builderId) {
|
|
401
421
|
const { rows } = await db.query(
|
|
402
422
|
`SELECT * FROM builder_boxes WHERE builder_id = $1`,
|
|
@@ -891,6 +911,7 @@ module.exports = {
|
|
|
891
911
|
decideEnsureAction,
|
|
892
912
|
// db
|
|
893
913
|
getBoxByBuilderId,
|
|
914
|
+
setBoxWidenPaths,
|
|
894
915
|
ensureBoxRow,
|
|
895
916
|
listBoxes,
|
|
896
917
|
recordEvent,
|
|
@@ -480,8 +480,16 @@ module.exports = function buildBoxRouter() {
|
|
|
480
480
|
hasActiveClaims = undefined;
|
|
481
481
|
}
|
|
482
482
|
}
|
|
483
|
-
|
|
483
|
+
// Read the box BEFORE deciding: its widen_paths are an INPUT to the sparse
|
|
484
|
+
// set now (task 1003087). A builder-requested directory is unioned in here,
|
|
485
|
+
// which is what makes it survive the */10 cron — the cron re-asks THIS
|
|
486
|
+
// endpoint every tick, so the union comes back with it instead of being
|
|
487
|
+
// deleted by the next `git sparse-checkout set`.
|
|
484
488
|
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
489
|
+
const decision = boxAccess.decideSourceAccess({
|
|
490
|
+
rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
|
|
491
|
+
widenPaths: box ? box.widen_paths : null,
|
|
492
|
+
});
|
|
485
493
|
const auditDeny = (reason, extra) =>
|
|
486
494
|
box && boxAccess.recordSourceEvent(pool, {
|
|
487
495
|
box, builderId: req.builder.id, event: 'source_deny',
|
|
@@ -598,7 +606,13 @@ module.exports = function buildBoxRouter() {
|
|
|
598
606
|
hasActiveClaims = undefined;
|
|
599
607
|
}
|
|
600
608
|
}
|
|
601
|
-
|
|
609
|
+
// Same widen input as the real fetch — this readout exists precisely so it
|
|
610
|
+
// cannot drift from what the box will actually hold.
|
|
611
|
+
const scopeBox = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
612
|
+
const decision = boxAccess.decideSourceAccess({
|
|
613
|
+
rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
|
|
614
|
+
widenPaths: scopeBox ? scopeBox.widen_paths : null,
|
|
615
|
+
});
|
|
602
616
|
if (!decision.allowed) {
|
|
603
617
|
return res.fail('SOURCE_ACCESS_REVOKED', 403, {
|
|
604
618
|
reason: decision.reason,
|
|
@@ -753,6 +767,98 @@ module.exports = function buildBoxRouter() {
|
|
|
753
767
|
}
|
|
754
768
|
});
|
|
755
769
|
|
|
770
|
+
// GET /box/widen — the caller's own recorded widen set (task 1003087).
|
|
771
|
+
// rank: any authenticated builder, own resource. Reports what is STORED plus
|
|
772
|
+
// what is currently ADMITTED, because those differ whenever a request sits
|
|
773
|
+
// outside the builder's rank scope — showing only the stored list would let a
|
|
774
|
+
// Xenos believe a widen took effect that the fetch quietly drops.
|
|
775
|
+
router.get('/box/widen', auth.requireBuilder, async (req, res) => {
|
|
776
|
+
try {
|
|
777
|
+
const builder = await gdsDb.getBuilderById(req.builder.id);
|
|
778
|
+
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
779
|
+
if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
|
|
780
|
+
const stored = Array.isArray(box.widen_paths) ? box.widen_paths : [];
|
|
781
|
+
const admitted = boxAccess.admissibleWidenPaths(stored, builder ? builder.rank : null);
|
|
782
|
+
res.json({
|
|
783
|
+
widen_paths: stored,
|
|
784
|
+
admitted,
|
|
785
|
+
ignored: stored.filter((p) => !admitted.includes(boxAccess.normalizeWidenPath(p))),
|
|
786
|
+
max: boxAccess.MAX_WIDEN_PATHS,
|
|
787
|
+
});
|
|
788
|
+
} catch (err) {
|
|
789
|
+
log.error('[gds] GET /box/widen', err);
|
|
790
|
+
res.fail('widen_read_failed', { status: 500, message: 'internal error' });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// POST /box/widen — record extra sparse-checkout directories that must SURVIVE
|
|
795
|
+
// the */10 source-fetch (task 1003087, idea 1000793). rank: any authenticated
|
|
796
|
+
// builder, OWN box (builder_id-scoped in SQL, so no id from the body can point
|
|
797
|
+
// this at someone else's box).
|
|
798
|
+
//
|
|
799
|
+
// Deliberately NOT allowBoxScope. The on-box crons read source-access with a
|
|
800
|
+
// box-scoped session (ADR 0053); this is a WRITE that changes what the box
|
|
801
|
+
// pulls forever after, so it takes the builder's own session — the same posture
|
|
802
|
+
// that keeps a stolen box token from re-scoping the checkout it was stolen from.
|
|
803
|
+
//
|
|
804
|
+
// add/remove are applied to the stored set and the result is re-normalized, so
|
|
805
|
+
// the endpoint is idempotent: widening twice is not an error, and neither is
|
|
806
|
+
// removing something that was never there.
|
|
807
|
+
//
|
|
808
|
+
// A path outside the caller's rank scope is ACCEPTED into storage but reported
|
|
809
|
+
// in `ignored` rather than refused. That is the honest shape: rank can change,
|
|
810
|
+
// and a builder promoted to Metic should find the widen they asked for as a
|
|
811
|
+
// Xenos simply start working, instead of having been silently discarded months
|
|
812
|
+
// earlier. What it can never do is take effect early — admissibleWidenPaths is
|
|
813
|
+
// re-evaluated against the LIVE rank on every single fetch.
|
|
814
|
+
router.post('/box/widen', auth.requireBuilder, async (req, res) => {
|
|
815
|
+
if (validateOrRespond(req, res, {
|
|
816
|
+
add: { type: 'array' },
|
|
817
|
+
remove: { type: 'array' },
|
|
818
|
+
clear: { type: 'boolean' },
|
|
819
|
+
})) return;
|
|
820
|
+
try {
|
|
821
|
+
const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
|
|
822
|
+
if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
|
|
823
|
+
const builder = await gdsDb.getBuilderById(req.builder.id);
|
|
824
|
+
|
|
825
|
+
const current = Array.isArray(box.widen_paths) ? box.widen_paths : [];
|
|
826
|
+
const next = new Set(req.body && req.body.clear === true ? [] : current);
|
|
827
|
+
|
|
828
|
+
const rejected = [];
|
|
829
|
+
for (const raw of Array.isArray(req.body && req.body.add) ? req.body.add : []) {
|
|
830
|
+
const p = boxAccess.normalizeWidenPath(raw);
|
|
831
|
+
if (!p) { rejected.push(String(raw).slice(0, 120)); continue; }
|
|
832
|
+
next.add(p);
|
|
833
|
+
}
|
|
834
|
+
for (const raw of Array.isArray(req.body && req.body.remove) ? req.body.remove : []) {
|
|
835
|
+
const p = boxAccess.normalizeWidenPath(raw);
|
|
836
|
+
if (p) next.delete(p);
|
|
837
|
+
}
|
|
838
|
+
if (rejected.length) {
|
|
839
|
+
return res.fail('bad_widen_path', 400, {
|
|
840
|
+
rejected,
|
|
841
|
+
message: 'a widen path must be a repo-relative directory: no absolute paths, no "..", no drive letters',
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const stored = [...next].sort().slice(0, boxAccess.MAX_WIDEN_PATHS);
|
|
846
|
+
const saved = await boxes.setBoxWidenPaths(pool, req.builder.id, stored);
|
|
847
|
+
if (saved === null) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
|
|
848
|
+
const admitted = boxAccess.admissibleWidenPaths(saved, builder ? builder.rank : null);
|
|
849
|
+
res.json({
|
|
850
|
+
ok: true,
|
|
851
|
+
widen_paths: saved,
|
|
852
|
+
admitted,
|
|
853
|
+
ignored: saved.filter((p) => !admitted.includes(p)),
|
|
854
|
+
max: boxAccess.MAX_WIDEN_PATHS,
|
|
855
|
+
});
|
|
856
|
+
} catch (err) {
|
|
857
|
+
log.error('[gds] POST /box/widen', err);
|
|
858
|
+
res.fail('widen_failed', { status: 500, message: 'internal error' });
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
756
862
|
// GET /box/authorized-keys — the box pulls its builder's authorized_keys.
|
|
757
863
|
// rank: any authenticated builder (own resource) — but the ALLOW/DENY is decided
|
|
758
864
|
// INSIDE from the caller's LIVE rank+status (boxAccess.decideSourceAccess), NOT a
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.647",
|
|
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.647",
|
|
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.647",
|
|
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-sync.js
CHANGED
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Usage: node scripts/gds/box-sync.js # re-fetch now
|
|
17
17
|
// node scripts/gds/box-sync.js --dry-run # print what it WOULD run
|
|
18
|
+
// node scripts/gds/box-sync.js --widen <dir> # keep <dir> across ticks
|
|
19
|
+
// node scripts/gds/box-sync.js --unwiden <dir> # stop keeping it
|
|
20
|
+
// node scripts/gds/box-sync.js --widen-list # what is kept, and what is ignored
|
|
21
|
+
//
|
|
22
|
+
// WHY --widen EXISTS (task 1003087 / idea 1000793). `git sparse-checkout add`
|
|
23
|
+
// does not survive: the */10 cron re-applies the SERVER-computed set and deletes
|
|
24
|
+
// the addition, sometimes MID-COMMAND — a live test suite died with
|
|
25
|
+
// MODULE_NOT_FOUND that read exactly like a code bug. --widen records the
|
|
26
|
+
// directory server-side instead, so every fetch (the cron included) returns it
|
|
27
|
+
// in the set and it stops being deleted. It is capped, and it can never widen
|
|
28
|
+
// past what your RANK already reaches — a request outside it is stored but
|
|
29
|
+
// reported as ignored rather than silently applied.
|
|
18
30
|
|
|
19
31
|
const fs = require('node:fs');
|
|
20
32
|
const path = require('node:path');
|
|
@@ -36,7 +48,78 @@ function resolveFetchScript(opts = {}) {
|
|
|
36
48
|
try { return fs.existsSync(p) ? p : null; } catch { return null; }
|
|
37
49
|
}
|
|
38
50
|
|
|
51
|
+
// valueAfter(argv, flag) — the argument following a flag, or null. Kept tiny and
|
|
52
|
+
// pure so the arg handling is testable without spawning anything.
|
|
53
|
+
function valueAfter(argv, flag) {
|
|
54
|
+
const i = argv.indexOf(flag);
|
|
55
|
+
if (i < 0) return null;
|
|
56
|
+
const v = argv[i + 1];
|
|
57
|
+
return typeof v === 'string' && v && !v.startsWith('--') ? v : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// formatWidenReport — PURE. The lines to print for a /box/widen body. Reports
|
|
61
|
+
// IGNORED entries loudly: a widen the server accepted into storage but will not
|
|
62
|
+
// apply (because it is outside your rank scope) looks identical to a working one
|
|
63
|
+
// unless it is said out loud, and a silently-inert widen is exactly the class of
|
|
64
|
+
// bug this whole task is about.
|
|
65
|
+
function formatWidenReport(body) {
|
|
66
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
67
|
+
return ['widen: could not read your widen set.'];
|
|
68
|
+
}
|
|
69
|
+
const kept = Array.isArray(body.widen_paths) ? body.widen_paths : [];
|
|
70
|
+
const ignored = Array.isArray(body.ignored) ? body.ignored : [];
|
|
71
|
+
const out = [];
|
|
72
|
+
out.push(kept.length
|
|
73
|
+
? `widen: keeping ${kept.length} path(s) across ticks: ${kept.join(', ')}`
|
|
74
|
+
: 'widen: nothing kept — the fetch set is whatever your claim scope says.');
|
|
75
|
+
if (ignored.length) {
|
|
76
|
+
out.push(`widen: IGNORED (outside your rank scope, stored but not applied): ${ignored.join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The --widen family. Talks to the control plane, so it works off a box too —
|
|
82
|
+
// unlike the fetch itself, the widen set is server state you can inspect and
|
|
83
|
+
// edit from anywhere.
|
|
84
|
+
async function runWiden(argv) {
|
|
85
|
+
const { apiCall } = require('./cli-lib');
|
|
86
|
+
const add = valueAfter(argv, '--widen');
|
|
87
|
+
const remove = valueAfter(argv, '--unwiden');
|
|
88
|
+
const clear = argv.includes('--widen-clear');
|
|
89
|
+
const listOnly = !add && !remove && !clear;
|
|
90
|
+
|
|
91
|
+
const r = listOnly
|
|
92
|
+
? await apiCall('GET', '/api/gds/box/widen')
|
|
93
|
+
: await apiCall('POST', '/api/gds/box/widen', {
|
|
94
|
+
...(add ? { add: [add] } : {}),
|
|
95
|
+
...(remove ? { remove: [remove] } : {}),
|
|
96
|
+
...(clear ? { clear: true } : {}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!r || (r.status !== 200 && r.status !== 201)) {
|
|
100
|
+
const code = r && r.data && r.data.error ? r.data.error : `HTTP ${r ? r.status : '?'}`;
|
|
101
|
+
if (code === 'no_box') {
|
|
102
|
+
console.error('widen: you have no dev box — there is nothing to widen. Provision one first.');
|
|
103
|
+
} else if (code === 'bad_widen_path') {
|
|
104
|
+
const bad = r.data && r.data.rejected ? r.data.rejected.join(', ') : '';
|
|
105
|
+
console.error(`widen: refused ${bad} — give a repo-relative DIRECTORY (no leading /, no "..").`);
|
|
106
|
+
} else {
|
|
107
|
+
console.error(`widen: failed (${code}).`);
|
|
108
|
+
}
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
for (const line of formatWidenReport(r.data)) console.log(line);
|
|
112
|
+
if (!listOnly) console.log('widen: run `box-sync` (no flags) to apply it to the checkout now.');
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
39
116
|
function main(argv = process.argv.slice(2)) {
|
|
117
|
+
// The widen family is server state, not a fetch — handled before the on-a-box
|
|
118
|
+
// check, so it works from a laptop too.
|
|
119
|
+
if (argv.some((a) => a === '--widen' || a === '--unwiden' || a === '--widen-list' || a === '--widen-clear')) {
|
|
120
|
+
runWiden(argv).then((code) => process.exit(code), () => process.exit(1));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
40
123
|
const dryRun = argv.includes('--dry-run') || argv.includes('-n');
|
|
41
124
|
const script = resolveFetchScript();
|
|
42
125
|
if (!script) {
|
|
@@ -130,4 +213,4 @@ async function reportScope() {
|
|
|
130
213
|
|
|
131
214
|
if (require.main === module) main();
|
|
132
215
|
|
|
133
|
-
module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport };
|
|
216
|
+
module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport, formatWidenReport, valueAfter };
|
|
@@ -431,7 +431,20 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
431
431
|
landedNow[name] = { module: mod.key, source: `.claude/skills/${name}`, coreShipped: true };
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
|
-
|
|
434
|
+
// A name the CORE ships now is the core's, whatever an older manifest said — a core
|
|
435
|
+
// skill can take over a name a module once owned, and step 1 copies it in on this very
|
|
436
|
+
// run. Without this guard 1c deleted it again immediately (task 1003818, a regression
|
|
437
|
+
// in 1003632's own first cut). materializeModuleSkillsIntoCore states the same rule for
|
|
438
|
+
// the checkout side via its `tracked(name)` check; this is the instance-side twin.
|
|
439
|
+
//
|
|
440
|
+
// The `!skipSkills.has(name)` half is load-bearing, not belt-and-braces: when a DISABLED
|
|
441
|
+
// module declares a core-shipped name, step 1 SKIPS the copy (that is the task 2103b
|
|
442
|
+
// exclusion), so the instance's dir really is stale and must still be withdrawn. Keeping
|
|
443
|
+
// it on the strength of "the core ships this name" would resurrect exactly the bug 1c
|
|
444
|
+
// exists to fix. Deliberately asked of the CORE source tree rather than the roster, so a
|
|
445
|
+
// name no enabled module declares any more is still recognised as legitimately sourced.
|
|
446
|
+
const coreShipsNow = (name) => fs.existsSync(path.join(skillsSrc, name)) && !skipSkills.has(name);
|
|
447
|
+
const withdrawable = Object.keys(landedPreviously).filter((n) => SKILL_NAME_RE.test(n) && !landedNow[n] && !coreShipsNow(n));
|
|
435
448
|
if (!overwrite) {
|
|
436
449
|
// adopt mode never clobbers what the owner already has; withdrawing is a clobber.
|
|
437
450
|
if (withdrawable.length) summary.notes.push(`${withdrawable.length} skill(s) of a now-disabled module were left in place (adopt mode does not withdraw): ${withdrawable.sort().join(', ')}.`);
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
11
|
const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
|
|
12
|
-
const { resolveProse, markShipProgress, smokeGateMessages } = require('./ship-honesty.js');
|
|
12
|
+
const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal } = require('./ship-honesty.js');
|
|
13
13
|
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
14
14
|
const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
|
|
15
15
|
const { matchOne } = require('../../src/bongos/path-match');
|
|
@@ -684,6 +684,16 @@ async function regradeMain() {
|
|
|
684
684
|
console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
|
|
685
685
|
process.exit(2);
|
|
686
686
|
}
|
|
687
|
+
// task 1003702: refuse --notes rather than swallowing it. The reasoning — why
|
|
688
|
+
// a refusal and not a persist — is in ship-honesty.js above regradeNotesRefusal
|
|
689
|
+
// and is not repeated here. What is load-bearing at THIS call site is the
|
|
690
|
+
// POSITION: before the preflight and before any grade spend, because a refusal
|
|
691
|
+
// that first costs a 4-worker panel run is barely better than the silence it
|
|
692
|
+
// replaces. tests/ship_cannot_lie.mjs pins that order.
|
|
693
|
+
if (proseFlagPresent(args, 'notes')) {
|
|
694
|
+
for (const line of regradeNotesRefusal(taskId)) console.error(line);
|
|
695
|
+
process.exit(2);
|
|
696
|
+
}
|
|
687
697
|
const regradeProse = resolveProse(args, ['summary', 'response']);
|
|
688
698
|
const summaryArg = regradeProse.summary;
|
|
689
699
|
// task 1002662: the builder's response to the prior round's findings — how
|
|
@@ -753,6 +763,17 @@ async function regradeMain() {
|
|
|
753
763
|
// re-grade is the FIRST time this task actually ships. Fall back to the
|
|
754
764
|
// task's stored value_summary so the merge commit + Discord #ship-news post
|
|
755
765
|
// carry meaningful text even when --summary is omitted.
|
|
766
|
+
//
|
|
767
|
+
// AND IT NEVER REACHES THE TASK ROW — task 1003702's acceptance 3 asked this
|
|
768
|
+
// question; the answer is a real, separate defect, filed as task 1003817.
|
|
769
|
+
// `summary` from here feeds the grader prompt, the merge commit message and
|
|
770
|
+
// the PR title/body, and NOTHING writes it back to tasks.value_summary. All
|
|
771
|
+
// three candidate write paths were read and ruled out: publish-branch accepts
|
|
772
|
+
// value_summary but uses it only for PR text; PATCH /tasks/:id does not accept
|
|
773
|
+
// the field at all; the claim-resolve route is the sole writer and a re-grade
|
|
774
|
+
// has no claim (which is why claimLike is synthesized above). Fixing it needs
|
|
775
|
+
// a server route change, so it is deliberately NOT folded into this CLI fix.
|
|
776
|
+
// tests/ship_cannot_lie.mjs pins both halves of that so the answer cannot rot.
|
|
756
777
|
const summary = summaryArg || task.value_summary || '';
|
|
757
778
|
|
|
758
779
|
// The grader helpers want a claim-like object for touches[] (preflight's
|
|
@@ -848,6 +869,11 @@ async function regradeMain() {
|
|
|
848
869
|
card: {
|
|
849
870
|
title: task.title,
|
|
850
871
|
valueSummary: summary,
|
|
872
|
+
// Empty by design, not by omission (task 1003702). A re-grade takes no
|
|
873
|
+
// --notes at all now — it is refused above — so there are none from THIS
|
|
874
|
+
// round to show. The first ship's notes are the durable record and live on
|
|
875
|
+
// the resolved claim; re-printing them here would suggest this round
|
|
876
|
+
// produced them.
|
|
851
877
|
notes: '',
|
|
852
878
|
files: committedFiles,
|
|
853
879
|
baseline: graderBaseline,
|
|
@@ -143,8 +143,61 @@ function smokeGateMessages(taskId) {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// ---- --regrade must not swallow --notes (task 1003702) ----------------------
|
|
147
|
+
//
|
|
148
|
+
// THE THIRD WAY TO LOSE THE BUILDER'S PROSE, and the reason this lives in the
|
|
149
|
+
// prose-loss file rather than beside regradeMain. `--regrade` resolves only
|
|
150
|
+
// `summary` and `response`, so `--notes` parsed as an unknown flag, produced no
|
|
151
|
+
// error, and vanished. A builder answering a "no evidence in the notes" blocker
|
|
152
|
+
// passed it again in --notes, saw the ship run, and got the identical finding
|
|
153
|
+
// back marked unanswered — with nothing anywhere saying why.
|
|
154
|
+
//
|
|
155
|
+
// AND THE FIX IS A REFUSAL, NOT A PERSIST, because the premise the bug was filed
|
|
156
|
+
// under is wrong in the builder's favour: the panel NEVER reads ship notes, on a
|
|
157
|
+
// first ship or a re-grade. modules/grading/grader-prompt.js buildPrompt() puts
|
|
158
|
+
// the task row, the VALUE SUMMARY, the changed files, the diffstat and the diff
|
|
159
|
+
// in the prompt and nothing else, and the workers get the same bundle. So
|
|
160
|
+
// storing the notes would have fixed the silence and none of the substance: the
|
|
161
|
+
// builder would pass --notes, watch it save, and STILL get "unanswered" — a
|
|
162
|
+
// worse failure, because now it looks like it worked.
|
|
163
|
+
//
|
|
164
|
+
// The two channels that do reach the panel are named in the refusal: --response
|
|
165
|
+
// (fenced into the prior-round block, task 1002662) and the DIFF itself, which
|
|
166
|
+
// is why the workaround that actually worked on task 1003698 was committing the
|
|
167
|
+
// evidence as a file. Nothing is written and nothing is lost: the first ship's
|
|
168
|
+
// notes stay the durable ledger record, which is the one thing --notes on a
|
|
169
|
+
// re-grade could legitimately have meant.
|
|
170
|
+
function proseFlagPresent(args, name) {
|
|
171
|
+
const list = Array.isArray(args) ? args : [];
|
|
172
|
+
return list.some((a) => a === `--${name}` || String(a).startsWith(`--${name}=`)
|
|
173
|
+
|| a === `--${name}-file` || String(a).startsWith(`--${name}-file=`));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Pure so the refusal's WORDING is testable — a message that fails to name the
|
|
177
|
+
// alternative is the same dead end with an error code on it.
|
|
178
|
+
function regradeNotesRefusal(taskId) {
|
|
179
|
+
return [
|
|
180
|
+
'--regrade does not take --notes, and your notes have NOT been saved or graded.',
|
|
181
|
+
'',
|
|
182
|
+
'The grader never reads ship notes — not on a re-grade, and not on the first ship.',
|
|
183
|
+
'The panel is given the task, your VALUE SUMMARY, the changed files and the diff.',
|
|
184
|
+
'So notes cannot answer a finding, and silently keeping them would only look like it had.',
|
|
185
|
+
'',
|
|
186
|
+
'Put the evidence where the panel can actually see it:',
|
|
187
|
+
` --response "..." answers the prior round's findings; it rides to the panel fenced.`,
|
|
188
|
+
` --response-file <path> the safe form for prose (a shell eats backticks in an inline value).`,
|
|
189
|
+
' the DIFF commit the proof — a test, a dry-run log, a session-log file.',
|
|
190
|
+
' The panel reads the committed diff and can Read every changed file.',
|
|
191
|
+
'',
|
|
192
|
+
` node scripts/gds/ship.js ${taskId} --regrade --response-file notes.md`,
|
|
193
|
+
'',
|
|
194
|
+
"The first ship's notes stay the durable record; nothing you wrote earlier was lost.",
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
146
198
|
module.exports = {
|
|
147
199
|
resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages,
|
|
200
|
+
proseFlagPresent, regradeNotesRefusal,
|
|
148
201
|
_resetForTest: () => { shipProgress = { stage: 'preflight', taskId: null }; },
|
|
149
202
|
_peekForTest: () => shipProgress,
|
|
150
203
|
};
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.647'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|