@bongos/core 1.19.883 → 1.19.885

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.
Files changed (73) hide show
  1. package/.bongos-core.json +31 -291
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/api/openapi.json +4 -2
  4. package/docs/architecture.md +0 -1
  5. package/docs/file-map.md +0 -1
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/lifecycle/routes/tasks.js +18 -4
  8. package/package-lock.json +2 -2
  9. package/package.json +1 -1
  10. package/scripts/gds/ship-land.js +103 -14
  11. package/scripts/gds/ship-merge.js +28 -0
  12. package/scripts/gds/ship.js +8 -0
  13. package/src/bongos/serve-internal.js +10 -56
  14. package/src/module-api.js +1 -1
  15. package/src/platform-server.js +0 -1
  16. package/tests/host_topology_skips.mjs +0 -1
  17. package/tests/instance_release_config.mjs +27 -26
  18. package/tests/publish_branch_route.mjs +70 -2
  19. package/tests/session_rename_fallback.mjs +8 -90
  20. package/tests/ship_land_proof.mjs +218 -0
  21. package/modules/dev-box/app/README.md +0 -74
  22. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  23. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  24. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  25. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  26. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  27. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  28. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  29. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  30. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  31. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  32. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  33. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  34. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  35. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  36. package/modules/dev-box/app/build-resources/icon.png +0 -0
  37. package/modules/dev-box/app/electron-builder.tmpl.yml +0 -95
  38. package/modules/dev-box/app/electron-builder.yml +0 -95
  39. package/modules/dev-box/app/package-lock.json +0 -5291
  40. package/modules/dev-box/app/package.json +0 -30
  41. package/modules/dev-box/app/renderer/app.js +0 -319
  42. package/modules/dev-box/app/renderer/index.html +0 -45
  43. package/modules/dev-box/app/renderer/styles.css +0 -248
  44. package/modules/dev-box/app/renderer/theme.generated.css +0 -16
  45. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +0 -82
  46. package/modules/dev-box/app/scripts/dmgbuild-settings.py +0 -39
  47. package/modules/dev-box/app/scripts/gen-brand.js +0 -138
  48. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +0 -323
  49. package/modules/dev-box/app/scripts/gen-icons.js +0 -286
  50. package/modules/dev-box/app/scripts/vendor-client.js +0 -23
  51. package/modules/dev-box/app/src/box-controller.js +0 -287
  52. package/modules/dev-box/app/src/brand.generated.json +0 -13
  53. package/modules/dev-box/app/src/brand.js +0 -16
  54. package/modules/dev-box/app/src/config.js +0 -31
  55. package/modules/dev-box/app/src/gds-client.js +0 -96
  56. package/modules/dev-box/app/src/main.js +0 -610
  57. package/modules/dev-box/app/src/pairing.js +0 -57
  58. package/modules/dev-box/app/src/preload.js +0 -29
  59. package/modules/dev-box/app/src/session-store.js +0 -82
  60. package/modules/dev-box/app/src/ssh-setup.js +0 -569
  61. package/modules/dev-box/app/src/task-picker.js +0 -70
  62. package/modules/dev-box/app/src/tray.js +0 -99
  63. package/modules/dev-box/app/src/updater.js +0 -55
  64. package/modules/dev-box/app/src/vendor/bongos-client.cjs +0 -1014
  65. package/modules/dev-box/app/test/unit.mjs +0 -526
  66. package/src/devbox-downloads.js +0 -67
  67. package/tests/devbox_artifact_gate.mjs +0 -75
  68. package/tests/devbox_dmg_backdrop.mjs +0 -137
  69. package/tests/devbox_downloads.mjs +0 -140
  70. package/tests/devbox_icons.mjs +0 -304
  71. package/tests/devbox_installer_brand.mjs +0 -232
  72. package/tests/devbox_key_register.mjs +0 -234
  73. package/tests/devbox_update_path.mjs +0 -84
@@ -438,24 +438,100 @@ async function pollServerPublish(taskId, branch) {
438
438
  // Best-effort + never throws: a non-Metic author, an unconfigured server, or a
439
439
  // transient failure just leaves the PR un-attested → it escalates (the safe
440
440
  // direction). A non-gate PR is unaffected by the attestation either way.
441
+ //
442
+ // task 1004055 — the endpoint does TWO things, and only one of them was ever
443
+ // reported. Besides the attestation (best-effort, and declining is a legitimate
444
+ // answer) it records claims.published_head_sha, the tip that makes the ship
445
+ // PROVABLE — and that half is load-bearing, not best-effort: without it POST /ship
446
+ // refuses the confirmed→shipped flip. So this returns { recorded, attested, reason }
447
+ // rather than undefined, and callers act on `recorded`. It still never throws.
441
448
  async function attestGateTrust(taskId, headSha) {
442
- if (!headSha) return;
449
+ if (!headSha) return { recorded: false, attested: false, reason: 'no head SHA to attest' };
450
+ let r;
443
451
  try {
444
- const r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
445
- if (r.ok && r.data) {
446
- if (r.data.attested) {
447
- console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
448
- } else {
449
- console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
450
- }
451
- } else if (r.status === 503) {
452
- console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
452
+ r = await (await cliClient()).tasks.postTasksIdAttestGate({ id: taskId, body: { head_sha: headSha } });
453
+ } catch (e) {
454
+ // task 1004055: a bare `catch (_) {}` here meant a thrown request error left NO
455
+ // trace at all — the quietest of the failure shapes, and the one that reached
456
+ // the builder as an unexplained ship_land_not_proven.
457
+ console.log(` ci-land: gate attestation request failed (${(e && e.message) || 'unknown error'}).`);
458
+ return { recorded: false, attested: false, reason: `request failed (${(e && e.message) || 'unknown error'})` };
459
+ }
460
+ const recorded = landProofRecorded(r);
461
+ if (r.ok && r.data) {
462
+ if (r.data.attested) {
463
+ console.log(' ci-land: gate-author-trust attested on the head SHA (Metic+) — a gate-surface PR auto-passes the ADR-0058 gatekeeper.');
453
464
  } else {
454
- console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
465
+ console.log(` ci-land: gate-author-trust NOT attested (${r.data.reason || 'rank below Metic'}) — a gate-surface PR will need owner approval; a non-gate PR is unaffected.`);
455
466
  }
456
- } catch (_) {
457
- /* best-effort — never block the ship on the attestation */
467
+ return { recorded, attested: !!r.data.attested, reason: r.data.reason || null };
468
+ }
469
+ if (r.status === 503) {
470
+ console.log(' ci-land: the server has no push credential to post the gate attestation (operator: GITHUB_APP_* / GITHUB_PUSH_TOKEN). A gate-surface PR will need owner approval.');
471
+ return { recorded, attested: false, reason: 'the server has no push credential' };
458
472
  }
473
+ console.log(` ci-land: gate attestation request failed (HTTP ${r.status}); a gate-surface PR may need a manual re-run / owner approval.`);
474
+ return { recorded, attested: false, reason: `HTTP ${r.status}` };
475
+ }
476
+
477
+ // landProofRecorded — PURE. Did an attest-gate response actually record
478
+ // claims.published_head_sha (task 1004055)? That row is the durable per-task land
479
+ // proof; without it POST /tasks/:id/ship refuses with ship_land_not_proven and the
480
+ // task strands at 'confirmed' with its work already on main.
481
+ //
482
+ // The server SAYS so in `tip_recorded` — on the 200 body and in the 503's details,
483
+ // because the write deliberately sits BEFORE the credential check. Prefer that.
484
+ // A server that predates the field sends neither, which is the NORMAL state here:
485
+ // an instance runs a PINNED core, so it lags main by however long the pin sits
486
+ // still. For those, fall back to the route's contract — the write is above the
487
+ // credential 503, so 200 and 503 both mean the tip landed and nothing else does.
488
+ // Exported so the truth table is pinned with no server.
489
+ function landProofRecorded(r) {
490
+ if (!r || typeof r.status !== 'number') return false;
491
+ const body = r.data || {};
492
+ const explicit = body.tip_recorded !== undefined
493
+ ? body.tip_recorded
494
+ : (body.error && body.error.details ? body.error.details.tip_recorded : undefined);
495
+ if (explicit !== undefined) return explicit === true;
496
+ return r.status === 200 || r.status === 503;
497
+ }
498
+
499
+ // landProofWarningLines — PURE. What to print when the proof did NOT get recorded
500
+ // (task 1004055). Without it POST /ship refuses, the task parks at 'confirmed' —
501
+ // which gates the builder's WHOLE queue — and the publish-reconciler eventually
502
+ // writes strand:land_not_proven, after which nothing heals it. Say it HERE, while
503
+ // the sha is still on screen, and hand over the calls that fix it, rather than
504
+ // letting it surface as an opaque 409 later.
505
+ //
506
+ // `merged` is the tense, and it is the whole difference between the two callers:
507
+ // the local-merge path has ALREADY put the work on main and needs the full
508
+ // two-call recovery now; the PR path has only pushed, so the land may still be
509
+ // minutes away and the same recovery would be premature. Exported so both
510
+ // wordings are pinned.
511
+ function landProofWarningLines(taskId, sha, reason, { merged = true } = {}) {
512
+ const head = sha || '<the merge commit on main>';
513
+ const why = ` WARNING: the land proof for task ${taskId} was NOT recorded (${reason || 'unknown'}).`;
514
+ const fix = ` node scripts/gds/api.js POST /tasks/${taskId}/attest-gate --body '{"head_sha":"${head}"}'`;
515
+ if (!merged) {
516
+ return [
517
+ '',
518
+ why,
519
+ ' Nothing is lost yet — the branch is pushed and the PR still lands normally.',
520
+ ' But if the ship then refuses with ship_land_not_proven, re-record the tip',
521
+ ' against the merge commit and re-run the ship:',
522
+ ` node scripts/gds/api.js POST /tasks/${taskId}/attest-gate --body '{"head_sha":"<the merge commit on main>"}'`,
523
+ ` node scripts/gds/ship.js ${taskId}`,
524
+ ];
525
+ }
526
+ return [
527
+ '',
528
+ why,
529
+ ' The merge IS on main, but the server cannot prove the land is this task\'s, so',
530
+ ' the confirmed -> shipped flip will be refused (ship_land_not_proven) and the',
531
+ ' task will park at confirmed — which blocks every new claim you make. Fix it:',
532
+ fix,
533
+ ` node scripts/gds/api.js POST /tasks/${taskId}/ship --body '{}'`,
534
+ ];
459
535
  }
460
536
 
461
537
  // pickStaleGateReviewRun — PURE. Among `gh run list --json
@@ -596,7 +672,14 @@ async function ciLandLocal(taskId, branch, valueSummary) {
596
672
  // and a Metic+ gate-surface PR auto-passes — race-free for a fresh PR, since the
597
673
  // `gh pr create` below is what fires gate-review and it runs only after this
598
674
  // returns. (A reused open PR is handled by the re-run nudge after auto-merge.)
599
- await attestGateTrust(taskId, headSha);
675
+ // task 1004055: the same call also records the land proof, so a miss here is not
676
+ // cosmetic — check it rather than firing and forgetting. Not fatal on this path:
677
+ // the branch is pushed and the PR lands regardless, so say what to do IF the ship
678
+ // is later refused, and carry on.
679
+ const gateProof = await attestGateTrust(taskId, headSha);
680
+ if (!gateProof.recorded) {
681
+ for (const line of landProofWarningLines(taskId, headSha, gateProof.reason, { merged: false })) console.log(line);
682
+ }
600
683
 
601
684
  // Ensure exactly one PR for this branch. gh pr create is idempotent-ish: it
602
685
  // errors if a PR already exists, so check first and reuse it.
@@ -723,6 +806,12 @@ module.exports = {
723
806
  ciLand,
724
807
  classifyLandOutcome,
725
808
  formatPublishFailure,
809
+ // task 1004055: the land-proof half of attest-gate. attestGateTrust is exported
810
+ // so the LOCAL-merge path can record a proof it previously never recorded at all;
811
+ // the two pure halves so the truth table + the wording are pinned without a server.
812
+ attestGateTrust,
813
+ landProofRecorded,
814
+ landProofWarningLines,
726
815
  // task 1002572 (PART 2): the bail ids + the pure "can a PR still land this?"
727
816
  // decision, so the single-checkout fallback is opt-in per bail and unit-tested.
728
817
  LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
@@ -20,9 +20,11 @@ const { mainIsProtected, pushVia, readDeployConfigRaw, resolveDeployMode, resolv
20
20
  const {
21
21
  LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
22
22
  LAND_BAIL_NO_TASK_BRANCH,
23
+ attestGateTrust,
23
24
  ciLand,
24
25
  landBailRecoverableByPr,
25
26
  landBailed,
27
+ landProofWarningLines,
26
28
  landed,
27
29
  } = require('./ship-land.js');
28
30
  const { runSmoke } = require('./ship-grade.js');
@@ -301,6 +303,32 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
301
303
  return landBailed('could not push the merged main to origin', '/merge-mode');
302
304
  }
303
305
 
306
+ // task 1004055: RECORD THE LAND PROOF — the step this path never had.
307
+ //
308
+ // The two OTHER land paths each tell the server which commit is this task's:
309
+ // ciLandLocal posts attest-gate after its push, ciLandServer gets it from
310
+ // publish-branch. This path merges and pushes main ITSELF, so nothing ever did,
311
+ // and claims.published_head_sha stayed NULL. It also opens no PR and deletes the
312
+ // branch below, so getPublishStatus can find nothing by head ref either — which
313
+ // left deriveShipAssurance with neither of its two proofs and made the POST /ship
314
+ // three lines later refuse with ship_land_not_proven, EVERY time, with the work
315
+ // already merged. The task then parked at 'confirmed', gating its builder's whole
316
+ // queue, until someone ran the recovery by hand (five consecutive ships,
317
+ // 2026-09-19; twelve refusals in one day's audit log).
318
+ //
319
+ // The merge commit is the sha to record: the push above just put it on main, so
320
+ // the ancestry check cannot fail, and it is what the hand-recovery always used.
321
+ // It goes HERE, immediately after the push and BEFORE both the deploy step (which
322
+ // can bail) and the branch deletion (which destroys the only other evidence) —
323
+ // the proof must exist from the first moment the work is on main.
324
+ const mergeSha = gitOk('git rev-parse HEAD', { cwd: REPO_ROOT });
325
+ const proof = await attestGateTrust(taskId, mergeSha);
326
+ if (proof.recorded) {
327
+ console.log(` auto-merge: land proof recorded (merge ${String(mergeSha).slice(0, 8)} on main).`);
328
+ } else {
329
+ for (const line of landProofWarningLines(taskId, mergeSha, proof.reason)) console.log(line);
330
+ }
331
+
304
332
  // Deploy step. The core carries NO deploy target of its own — under the
305
333
  // standalone model a core change reaches a live instance via package-core +
306
334
  // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
@@ -127,6 +127,8 @@ const {
127
127
  landPending,
128
128
  landPendingLines,
129
129
  landPollAfterAutoMerge,
130
+ landProofRecorded,
131
+ landProofWarningLines,
130
132
  landed,
131
133
  pickStaleGateReviewRun,
132
134
  reconcileBlockedLines,
@@ -266,6 +268,12 @@ module.exports = {
266
268
  // object, so a consumer MUST test `.ok`; the old `if (!merged)` would sail past
267
269
  // a bail. Also pins pending (a poll timeout) as distinct from bailed.
268
270
  landed, landPending, landBailed, strandCommand,
271
+ // task 1004055: the land proof the LOCAL-merge path never recorded. landProofRecorded
272
+ // is the "did the tip actually land?" truth table (explicit tip_recorded wins; a
273
+ // pinned older core falls back to the route's before-the-credential-check contract);
274
+ // landProofWarningLines is the recovery the builder gets when it did not, in the
275
+ // tense of whichever path is speaking. Both pure — exported so they pin without a server.
276
+ landProofRecorded, landProofWarningLines,
269
277
  // task 1001916: the publish-branch failure formatter — exported pure so both
270
278
  // wire shapes (envelope + legacy flat), the step/reason surfacing, and the
271
279
  // non-fast-forward remedy hint are pinned without a server.
@@ -3,7 +3,6 @@
3
3
  // Mounts every non-game surface the platform serves onto an Express app:
4
4
  // * /healthz + /version — liveness + deployed-build info
5
5
  // * /api/bongos/* — the GDS API (buildGdsRouter)
6
- // * /downloads/devbox/* — Dev Box installer redirect surface (#904)
7
6
  // * generic module web-surface — each enabled module's contributes.webSurfaces
8
7
  // hosts served on its Host prefix by ONE generic static
9
8
  // loop (the status dashboard at status.<apex>,
@@ -28,9 +27,7 @@
28
27
  // import boundary (scripts/gds/fitness.js §1) — it imports nothing from the host
29
28
  // game runtime (no src/db.js, no src/rooms/, no src/world/, no Colyseus). It
30
29
  // serves only static files + the GDS router, so a non-game Cloud Bongos instance
31
- // runs it unchanged. (The /downloads/devbox/* surface is an instance feature that
32
- // R54's module system, task 1193, will gate per-instance; it is benign on a
33
- // vanilla boot — it only redirects to whatever config/devbox-app.json names.)
30
+ // runs it unchanged.
34
31
 
35
32
  const path = require('path');
36
33
  const fs = require('fs');
@@ -44,7 +41,6 @@ const gdsAuth = require('./auth');
44
41
  const { loadIdpConfig } = require('./auth-config');
45
42
  const { buildStampedHtml } = require('../asset-version');
46
43
  const { buildInfo } = require('../build-info');
47
- const { buildDevboxDownloads } = require('../devbox-downloads');
48
44
  const { buildBongosDownloads } = require('../bongos-downloads');
49
45
  const { clientBranding, branding, applyBrandConditionals } = require('../branding');
50
46
  const { prelaunchGate } = require('./prelaunch-gate');
@@ -59,7 +55,7 @@ const { API_PREFIX, LEGACY_API_PREFIXES, API_VERSION, VERSIONED_API_PREFIX, ALL_
59
55
  // is byte-identical for the live instance.
60
56
  const ROOT = resolveCoreRoot();
61
57
 
62
- // Instance-only RELEASE config (config/devbox-app.json, config/bongos-cli.json) is
58
+ // Instance-only RELEASE config (config/bongos-cli.json) is
63
59
  // boundary-excluded from the portable core (publish-manifest INSTANCE_EXCLUDE), so it is
64
60
  // HOST content: it lives at the INSTANCE root, which on a vendored instance is NOT the
65
61
  // package dir this file sits under. A core-relative require therefore looked in
@@ -532,38 +528,6 @@ function isApexOnlyPath(p) {
532
528
  // Call this ONCE, right after app setup (trust proxy etc.) and BEFORE any
533
529
  // entrypoint-specific tail (the game static + themed 404 in server.js; the plain
534
530
  // 404 in platform-server.js).
535
- // The Dev Box download/manifest mini-router (#904, ADR 0045), extracted so the
536
- // ACTUAL route handlers are hermetically testable (tests/devbox_update_path.mjs
537
- // mounts them on a throwaway app — the docs_openapi_route pattern) instead of
538
- // only their pure builder. task 1002649: the manifest is served under BOTH
539
- // /latest.json and an extensionless /latest twin — a front proxy with a
540
- // file-matcher can eat *.json paths before Node (the live pre-ADR-0152 vhost
541
- // 404'd latest.json empty while extensionless siblings worked), so every
542
- // manifest consumer has a path that survives that class. Registration order
543
- // matters: the literal routes must precede the `:target` catch-all.
544
- function mountDevboxDownloads(app, devboxDownloads) {
545
- const manifest = (_req, res) => {
546
- // Short TTL: a release bump (config change + deploy) is visible within a
547
- // minute, same posture as the hall's HTML.
548
- res.set('Cache-Control', 'public, max-age=60');
549
- res.json(devboxDownloads.latestJson());
550
- };
551
- app.get('/downloads/devbox/latest.json', manifest);
552
- app.get('/downloads/devbox/latest', manifest);
553
- // #924: the lightweight update-check endpoint the Dev Box app polls + the hall
554
- // /settings courtesy line reads.
555
- app.get('/downloads/devbox/version', (_req, res) => {
556
- res.set('Cache-Control', 'public, max-age=60');
557
- res.json(devboxDownloads.versionJson());
558
- });
559
- app.get('/downloads/devbox/:target', (req, res) => {
560
- const url = devboxDownloads.redirectFor(req.params.target);
561
- if (!url) return res.fail('unknown_target', 404, { targets: Object.keys(devboxDownloads.latestJson().assets) });
562
- res.set('Cache-Control', 'public, max-age=60');
563
- res.redirect(302, url);
564
- });
565
- }
566
-
567
531
  function mountInternalSurfaces(app) {
568
532
  // Prometheus instrumentation (task #1680, V4.R28a). The counting middleware
569
533
  // mounts FIRST so it wraps every surface registered below (and, since
@@ -691,24 +655,14 @@ function mountInternalSurfaces(app) {
691
655
  app.use(prefix, gdsRouter);
692
656
  }
693
657
 
694
- // Dev Box desktop app downloads (#904, ADR 0045). Branded redirect surface:
695
- // the installers live as release assets on the public releases repo (they
696
- // can't ride the git deploy — see src/devbox-downloads.js), and these routes
697
- // keep every builder-facing URL on the apex. Mounted right after the API and
698
- // BEFORE the host shims so the same paths work on the apex AND on
699
- // builders.<apex> (the builders rewrite shim below only prefixes non-/api
700
- // paths, so without this placement /downloads/* would be rewritten to
701
- // /builders/downloads/* and 404).
702
- const devboxCfg = tryLoadInstanceConfig('devbox-app.json');
703
- if (devboxCfg) {
704
- mountDevboxDownloads(app, buildDevboxDownloads(devboxCfg));
705
- }
706
-
707
658
  // Standalone `bongos` CLI downloads + the one-line installer (task 1248,
708
- // V4.R81). Same off-repo release pattern as the Dev Box block above: the
709
- // compiled per-platform binaries live as release assets on the public releases
710
- // repo (config/bongos-cli.json → src/bongos-downloads.js), and these routes keep
711
- // every user-facing URL on the apex. The installer scripts are served with the
659
+ // V4.R81). Off-repo release pattern: the compiled per-platform binaries live
660
+ // as release assets on the public releases repo (config/bongos-cli.json →
661
+ // src/bongos-downloads.js), and these routes keep every user-facing URL on the
662
+ // apex. Mounted right after the API and BEFORE the host shims so the same
663
+ // paths work on the apex AND on builders.<apex> (the builders rewrite shim
664
+ // below only prefixes non-/api paths, so without this placement /downloads/*
665
+ // would be rewritten to /builders/downloads/* and 404). The installer scripts are served with the
712
666
  // server's PINNED origin substituted for __GDS_API_BASE__ — never a request
713
667
  // header, so a spoofed Host can't redirect a `curl | sh` (the box-connect.sh rule).
714
668
  const bongosCfg = tryLoadInstanceConfig('bongos-cli.json');
@@ -1357,4 +1311,4 @@ function mountInternalSurfaces(app) {
1357
1311
  return app;
1358
1312
  }
1359
1313
 
1360
- module.exports = { mountInternalSurfaces, mountDevboxDownloads, isStatusHost, isBuildersHost, injectBrandingGlobal, injectBrandingInto, injectModuleHallWidgets, themeMarkup, applyBrandTokens, resolveOpenapiJson, openapiJson, tryLoadInstanceConfig };
1314
+ module.exports = { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal, injectBrandingInto, injectModuleHallWidgets, themeMarkup, applyBrandTokens, resolveOpenapiJson, openapiJson, tryLoadInstanceConfig };
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.883'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.885'; // 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');
@@ -13,7 +13,6 @@
13
13
  // * the builders' hall host — modules/hall-ui/public/ (the hall-ui module's
14
14
  // web surface, BV1.R85 — served by its dedicated
15
15
  // block in serve-internal)
16
- // * /downloads/devbox/* — the Dev Box installer redirects
17
16
  //
18
17
  // WHAT IT DELIBERATELY DOES NOT CONSTRUCT — the host game runtime:
19
18
  // * NO Colyseus server, NO WebSocketTransport
@@ -37,7 +37,6 @@ const ALLOWED = {
37
37
  box_ship_permissions: '.claude/settings.json (package-excluded)',
38
38
  conductor: '.claude/hooks/conductor.js (package-excluded)',
39
39
  deploy_instance: 'scripts/deploy/deploy-instance.sh (host topology)',
40
- devbox_downloads: 'config/devbox-app.json (instance content)',
41
40
  eviction_policy: '.claude/hooks/eviction-policy.js (package-excluded)',
42
41
  first_run_engine_check: '.claude/hooks/session-start.js (package-excluded)',
43
42
  hook_core_resolve: '.claude/hooks/_core-resolve.js (package-excluded)',
@@ -1,15 +1,16 @@
1
1
  // tests/instance_release_config.mjs
2
2
  //
3
- // serve-internal mounts /downloads/devbox/* and /downloads/bongos/* only when the
4
- // instance ships the matching RELEASE config (config/devbox-app.json,
5
- // config/bongos-cli.json). Those configs are boundary-EXCLUDED from the packaged core,
3
+ // serve-internal mounts /downloads/bongos/* only when the instance ships the matching
4
+ // RELEASE config (config/bongos-cli.json). That config is boundary-EXCLUDED from the packaged core,
6
5
  // so they live at the INSTANCE root — which on a vendored instance (@bongos/core in
7
6
  // node_modules, ADR 0108) is a DIFFERENT directory from the core package dir
8
7
  // serve-internal.js sits in. The old core-relative `require('../../config/…')` looked
9
- // only in node_modules/@bongos/core/config/, so a vendored instance's Dev Box download
10
- // routes never mounted (task 1002371; ADR 0145 papered over it by copying the config
11
- // into node_modules — lost on the next npm ci). Same class as ADR 0144's vendored
12
- // infra/ path bug.
8
+ // only in node_modules/@bongos/core/config/, so a vendored instance's download routes
9
+ // never mounted (task 1002371; ADR 0145 papered over it by copying the config into
10
+ // node_modules — lost on the next npm ci). Same class as ADR 0144's vendored infra/
11
+ // path bug. This test was written against the /downloads/devbox/* twin, which task
12
+ // 1003891 deleted with the Dev Box app; it now proves the SAME ladder against
13
+ // /downloads/bongos/*, the surviving surface that still depends on it.
13
14
  //
14
15
  // The first test is THE regression: a config present ONLY at the instance root must be
15
16
  // found. It fails against the old resolution (which could not see the instance root at
@@ -29,12 +30,12 @@ import path from 'node:path';
29
30
 
30
31
  const require = createRequire(import.meta.url);
31
32
 
32
- // A minimal but VALID release config — buildDevboxDownloads rejects a bad
33
+ // A minimal but VALID release config — buildBongosDownloads rejects a bad
33
34
  // releases_repo/version at boot, so the end-to-end mount below proves real parsing.
34
- const DEVBOX_CFG = {
35
- releases_repo: 'example-owner/example-devbox',
35
+ const CLI_CFG = {
36
+ releases_repo: 'example-owner/example-cli',
36
37
  version: '9.9.9',
37
- assets: { 'mac-arm64': 'Example-Dev-Box-9.9.9-arm64.dmg' },
38
+ assets: { 'mac-arm64': 'bongos-9.9.9-macos-arm64' },
38
39
  aliases: { mac: 'mac-arm64' },
39
40
  };
40
41
 
@@ -50,23 +51,23 @@ function makeRoot(name, contents) {
50
51
 
51
52
  // Point the process at a temp INSTANCE root BEFORE serve-internal loads, so the default
52
53
  // (uninjected) resolution runs the vendored shape: config at the instance root, absent
53
- // from the core package dir. This checkout ships no config/devbox-app.json — it is
54
+ // from the core package dir. This checkout ships no config/bongos-cli.json — it is
54
55
  // instance-only — so the core tier genuinely has nothing to find, exactly like a
55
56
  // freshly-vendored box.
56
- const INSTANCE_ROOT = makeRoot('devbox-app.json', JSON.stringify(DEVBOX_CFG));
57
+ const INSTANCE_ROOT = makeRoot('bongos-cli.json', JSON.stringify(CLI_CFG));
57
58
  process.env.CLOUDBONGOS_INSTANCE_ROOT = INSTANCE_ROOT;
58
59
  const { tryLoadInstanceConfig, mountInternalSurfaces } = require('../src/bongos/serve-internal.js');
59
60
 
60
61
  test('finds a release config that exists ONLY at the instance root (the vendored bug)', () => {
61
- const instance = makeRoot('devbox-app.json', '{"version":"1.0.0"}');
62
+ const instance = makeRoot('bongos-cli.json', '{"version":"1.0.0"}');
62
63
  const core = makeRoot(null);
63
- assert.deepEqual(tryLoadInstanceConfig('devbox-app.json', [instance, core]), { version: '1.0.0' });
64
+ assert.deepEqual(tryLoadInstanceConfig('bongos-cli.json', [instance, core]), { version: '1.0.0' });
64
65
  });
65
66
 
66
67
  test('the instance root wins over the core package dir when both carry a copy', () => {
67
- const instance = makeRoot('devbox-app.json', '{"from":"instance"}');
68
- const core = makeRoot('devbox-app.json', '{"from":"core"}');
69
- assert.equal(tryLoadInstanceConfig('devbox-app.json', [instance, core]).from, 'instance');
68
+ const instance = makeRoot('bongos-cli.json', '{"from":"instance"}');
69
+ const core = makeRoot('bongos-cli.json', '{"from":"core"}');
70
+ assert.equal(tryLoadInstanceConfig('bongos-cli.json', [instance, core]).from, 'instance');
70
71
  });
71
72
 
72
73
  test('falls back to the core package dir (single checkout + the copied-in workaround)', () => {
@@ -76,16 +77,16 @@ test('falls back to the core package dir (single checkout + the copied-in workar
76
77
  });
77
78
 
78
79
  test('absent from every root → null, so the download surface just does not mount', () => {
79
- assert.equal(tryLoadInstanceConfig('devbox-app.json', [makeRoot(null), makeRoot(null)]), null);
80
- assert.equal(tryLoadInstanceConfig('devbox-app.json', []), null);
80
+ assert.equal(tryLoadInstanceConfig('bongos-cli.json', [makeRoot(null), makeRoot(null)]), null);
81
+ assert.equal(tryLoadInstanceConfig('bongos-cli.json', []), null);
81
82
  });
82
83
 
83
84
  test('a MALFORMED config still throws — only a MISSING file is tolerated', () => {
84
- const bad = makeRoot('devbox-app.json', '{ this is not valid json ');
85
- assert.throws(() => tryLoadInstanceConfig('devbox-app.json', [bad]), SyntaxError);
85
+ const bad = makeRoot('bongos-cli.json', '{ this is not valid json ');
86
+ assert.throws(() => tryLoadInstanceConfig('bongos-cli.json', [bad]), SyntaxError);
86
87
  });
87
88
 
88
- test('/downloads/devbox/version mounts from an INSTANCE-root config (end to end)', async () => {
89
+ test('/downloads/bongos/version mounts from an INSTANCE-root config (end to end)', async () => {
89
90
  const express = require('express');
90
91
  const app = express();
91
92
  mountInternalSurfaces(app);
@@ -93,11 +94,11 @@ test('/downloads/devbox/version mounts from an INSTANCE-root config (end to end)
93
94
  const s = app.listen(0, () => resolve(s));
94
95
  });
95
96
  try {
96
- const res = await fetch(`http://127.0.0.1:${server.address().port}/downloads/devbox/version`);
97
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/downloads/bongos/version`);
97
98
  assert.equal(res.status, 200, 'the route must mount when only the INSTANCE root has the config');
98
99
  const body = await res.json();
99
- assert.equal(body.version, DEVBOX_CFG.version);
100
- assert.match(body.assets['mac-arm64'], /^https:\/\/github\.com\/example-owner\/example-devbox\//);
100
+ assert.equal(body.version, CLI_CFG.version);
101
+ assert.match(body.assets['mac-arm64'], /^https:\/\/github\.com\/example-owner\/example-cli\//);
101
102
  } finally {
102
103
  await new Promise((r) => server.close(r));
103
104
  }
@@ -471,11 +471,11 @@ await t('POST /tasks/:id/attest-gate records the pushed tip on the claim, even w
471
471
  const router = buildTasksRouter();
472
472
  const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
473
473
  const handler = layer.route.stack[layer.route.stack.length - 1].handle;
474
- const out = { failCode: null, jsonBody: null };
474
+ const out = { failCode: null, failOpts: null, jsonBody: null };
475
475
  const res = {
476
476
  status() { return this; },
477
477
  json(b) { out.jsonBody = b; return this; },
478
- fail(code) { out.failCode = code; return this; },
478
+ fail(code, opts) { out.failCode = code; out.failOpts = opts; return this; },
479
479
  };
480
480
  // rank archon → gateTaskOwnership passes with no DB lookup, keeping the test hermetic.
481
481
  const req = { params: { id: '77' }, body: { head_sha: 'a'.repeat(40) }, builder: { id: 95, rank: 'archon' } };
@@ -483,9 +483,77 @@ await t('POST /tasks/:id/attest-gate records the pushed tip on the claim, even w
483
483
  assert.equal(out.failCode, 'push_unconfigured', 'no credential → still the usual 503 to the caller');
484
484
  assert.deepEqual(recorded, [{ taskId: 77, builderId: 95, sha: 'a'.repeat(40) }],
485
485
  'the tip is recorded BEFORE the credential check — the wave-strand cure');
486
+ // task 1004055: and the 503 must SAY the tip landed. This exit is the one a
487
+ // caller is most likely to misread as total failure; ship.js decides whether
488
+ // to warn the builder off this flag, so it has to travel on the error too.
489
+ assert.equal(out.failOpts && out.failOpts.details && out.failOpts.details.tip_recorded, true,
490
+ 'the 503 must carry tip_recorded:true — the write happened before it');
491
+ });
492
+ } finally {
493
+ dbMod.setClaimPublishedSha = savedSet;
494
+ }
495
+ });
496
+
497
+ // task 1004055: the SILENT MISS. setClaimPublishedSha returns null when the UPDATE
498
+ // matched no claim row, and the old route discarded that return value — so a caller
499
+ // that had already merged to main was told nothing was wrong, and only found out one
500
+ // call later as an opaque ship_land_not_proven 409, with the task parked at
501
+ // 'confirmed' gating its builder's whole queue. `tip_recorded` is what makes that
502
+ // answerable at the moment it happens.
503
+ await t('POST /tasks/:id/attest-gate reports tip_recorded:false when no claim row matched', async () => {
504
+ const dbMod = require('../modules/lifecycle/db.js');
505
+ const savedSet = dbMod.setClaimPublishedSha;
506
+ const savedHolder = dbMod.getLastClaimHolder;
507
+ // null = "the UPDATE matched nothing", the shape db-ship.js really returns.
508
+ dbMod.setClaimPublishedSha = async () => null;
509
+ dbMod.getLastClaimHolder = async () => ({ builder_id: 95 });
510
+ try {
511
+ // A credential IS configured, so the handler reaches res.json; a below-Metic rank
512
+ // means postGateAuthorTrust declines on the pure rank check and never fetches.
513
+ await withEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: 'ghp_fake_for_isConfigured' }, async () => {
514
+ const router = buildTasksRouter();
515
+ const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
516
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
517
+ const out = { failCode: null, jsonBody: null };
518
+ const res = {
519
+ status() { return this; },
520
+ json(b) { out.jsonBody = b; return this; },
521
+ fail(code) { out.failCode = code; return this; },
522
+ };
523
+ const req = { params: { id: '77' }, body: { head_sha: 'b'.repeat(40) }, builder: { id: 95, rank: 'thetes' } };
524
+ await handler(req, res);
525
+ assert.equal(out.failCode, null, 'a below-Metic attest is a 200, not a failure');
526
+ assert.equal(out.jsonBody.ok, true);
527
+ assert.equal(out.jsonBody.attested, false, 'below Metic → no attestation, as before');
528
+ assert.equal(out.jsonBody.tip_recorded, false,
529
+ 'nothing was recorded, and the response must say so rather than reading as success');
530
+ });
531
+ } finally {
532
+ dbMod.setClaimPublishedSha = savedSet;
533
+ dbMod.getLastClaimHolder = savedHolder;
534
+ }
535
+ });
536
+
537
+ await t('POST /tasks/:id/attest-gate reports tip_recorded:true on the happy path', async () => {
538
+ const dbMod = require('../modules/lifecycle/db.js');
539
+ const savedSet = dbMod.setClaimPublishedSha;
540
+ const savedHolder = dbMod.getLastClaimHolder;
541
+ dbMod.setClaimPublishedSha = async () => 4242; // a claim id = the UPDATE matched
542
+ dbMod.getLastClaimHolder = async () => ({ builder_id: 95 });
543
+ try {
544
+ await withEnv({ GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: 'ghp_fake_for_isConfigured' }, async () => {
545
+ const router = buildTasksRouter();
546
+ const layer = findRouteLayer(router, '/tasks/:id/attest-gate', 'post');
547
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
548
+ const out = { jsonBody: null };
549
+ const res = { status() { return this; }, json(b) { out.jsonBody = b; return this; }, fail() { return this; } };
550
+ const req = { params: { id: '77' }, body: { head_sha: 'c'.repeat(40) }, builder: { id: 95, rank: 'thetes' } };
551
+ await handler(req, res);
552
+ assert.equal(out.jsonBody.tip_recorded, true);
486
553
  });
487
554
  } finally {
488
555
  dbMod.setClaimPublishedSha = savedSet;
556
+ dbMod.getLastClaimHolder = savedHolder;
489
557
  }
490
558
  });
491
559