@bongos/core 1.19.1032 → 1.19.1034

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 (151) hide show
  1. package/.bongos-core.json +322 -117
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-audit/SKILL.md +1 -1
  4. package/.claude/skills/grade-recover/SKILL.md +2 -1
  5. package/.claude/skills/grader-health/SKILL.md +5 -1
  6. package/.claude/skills/merge-mode/SKILL.md +1 -1
  7. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  8. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  9. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  10. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  11. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  12. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  13. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  14. package/docs/adr/README.md +4 -4
  15. package/docs/api/openapi.json +1 -1
  16. package/docs/architecture.md +4 -3
  17. package/docs/canonical-permissions.md +2 -2
  18. package/docs/copy-inventory.md +34 -30
  19. package/docs/copy-registry.json +69 -33
  20. package/docs/file-map.md +4 -3
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/recipes/autobongos-windows-host.md +3 -1
  23. package/docs/recipes/local-dev.md +45 -3
  24. package/docs/recipes/packaging-the-core.md +3 -1
  25. package/docs/recipes/redteam-patrol.md +2 -2
  26. package/docs/recipes/upgrading-the-core.md +1 -1
  27. package/modules/autonomy/cadence.js +52 -0
  28. package/modules/autonomy/gauge.js +57 -9
  29. package/modules/government/protected-surfaces.json +1 -0
  30. package/modules/grading/grade-engine.js +267 -0
  31. package/modules/grading/grader-subagent.js +4 -3
  32. package/modules/grading/grader-workers/index.js +67 -11
  33. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  34. package/modules/grading/grader.js +109 -88
  35. package/modules/hall-ui/public/deploy.js +35 -0
  36. package/modules/hall-ui/public/project-settings.css +7 -0
  37. package/modules/hall-ui/records/builders-roster.md +10 -0
  38. package/modules/hall-ui/records/catalog.md +10 -0
  39. package/modules/hall-ui/records/collaboration.md +14 -0
  40. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  41. package/modules/hall-ui/records/oversight-family.md +19 -0
  42. package/modules/hall-ui/records/panel-family.md +19 -0
  43. package/modules/hall-ui/records/reading-rooms.md +12 -0
  44. package/modules/hall-ui/records/record-family.md +13 -0
  45. package/modules/hall-ui/records/reference-room.md +9 -0
  46. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  47. package/modules/hall-ui/records/sky.md +23 -0
  48. package/modules/lifecycle/dependency-advisory.js +2 -1
  49. package/modules/lifecycle/routes/tasks.js +19 -4
  50. package/modules/lifecycle/ship-preflight.js +91 -0
  51. package/modules/provisioning/control-plane-guard.js +14 -13
  52. package/modules/provisioning/core-upgrade.js +30 -17
  53. package/modules/provisioning/public-refusal.js +44 -0
  54. package/modules/provisioning/routes/disconnect.js +2 -1
  55. package/modules/provisioning/routes/env-manifest.js +5 -4
  56. package/modules/provisioning/routes/provisioning.js +16 -15
  57. package/modules/provisioning/routes/repo-private.js +2 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +7481 -0
  61. package/scripts/gds/address-park-sweep.sh +3 -1
  62. package/scripts/gds/autobongos-run.js +91 -3
  63. package/scripts/gds/backfill-cost.js +20 -9
  64. package/scripts/gds/baseline-staleness.js +1 -1
  65. package/scripts/gds/claim.js +2 -2
  66. package/scripts/gds/credential-presence.js +281 -0
  67. package/scripts/gds/db-backup-nightly.sh +9 -4
  68. package/scripts/gds/db-backup-offsite.sh +4 -2
  69. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  70. package/scripts/gds/dev-lib.js +212 -5
  71. package/scripts/gds/dev.js +102 -50
  72. package/scripts/gds/doctor.js +3 -2
  73. package/scripts/gds/grade-correlation-audit.js +280 -0
  74. package/scripts/gds/grade-replay.js +237 -0
  75. package/scripts/gds/hooks/pre-push.js +7 -3
  76. package/scripts/gds/main-audit.js +19 -10
  77. package/scripts/gds/memory-map.js +3 -1
  78. package/scripts/gds/onboarding-config.js +3 -2
  79. package/scripts/gds/package-core.js +72 -1
  80. package/scripts/gds/plain-cards.js +4 -0
  81. package/scripts/gds/preflight.js +12 -4
  82. package/scripts/gds/provision-core-upgrade.js +282 -80
  83. package/scripts/gds/provision-repo-auth.js +15 -0
  84. package/scripts/gds/provision.js +1 -1
  85. package/scripts/gds/publish-credential-check.js +3 -0
  86. package/scripts/gds/push-main.js +14 -3
  87. package/scripts/gds/push-path-brief.js +3 -2
  88. package/scripts/gds/redteam-patrol.js +4 -4
  89. package/scripts/gds/release-notes.js +271 -0
  90. package/scripts/gds/run-routine.js +18 -5
  91. package/scripts/gds/run-unit-tests.js +14 -3
  92. package/scripts/gds/seed-board-room-tasks.js +1 -1
  93. package/scripts/gds/session-cost.js +12 -3
  94. package/scripts/gds/ship-deploy-target.js +11 -6
  95. package/scripts/gds/ship-flow.js +5 -3
  96. package/scripts/gds/ship-grade-steps.js +33 -0
  97. package/scripts/gds/ship-land.js +241 -16
  98. package/scripts/gds/ship-merge.js +3 -2
  99. package/scripts/gds/ship-preflight-steps.js +86 -11
  100. package/scripts/gds/ship.js +1 -1
  101. package/scripts/gds/stray-unit-sweep.sh +3 -1
  102. package/scripts/gds/upgrade-migrate.js +67 -0
  103. package/scripts/gds/upgrade.js +9 -7
  104. package/scripts/hall-preview/README.md +20 -3
  105. package/scripts/hall-preview/live-identity.js +1 -1
  106. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  107. package/scripts/hall-preview/scrub-identity.js +296 -0
  108. package/src/bongos/routes/security.js +3 -3
  109. package/src/build-info.js +4 -2
  110. package/src/module-api.js +1 -1
  111. package/tests/adr_0089_scope.mjs +70 -0
  112. package/tests/adr_0162_citation.mjs +184 -0
  113. package/tests/autobongos_cadence.mjs +74 -0
  114. package/tests/autobongos_loop.mjs +114 -0
  115. package/tests/autonomy_gauge.mjs +56 -0
  116. package/tests/consumer_layout_boot.mjs +363 -31
  117. package/tests/core_upgrade_door.mjs +40 -6
  118. package/tests/core_upgrade_runner.mjs +329 -5
  119. package/tests/credential_presence.mjs +153 -0
  120. package/tests/db_backup_nightly.mjs +21 -3
  121. package/tests/db_backup_offsite.mjs +81 -0
  122. package/tests/deploy_audit_gate.mjs +5 -2
  123. package/tests/deploy_page_projects.mjs +63 -2
  124. package/tests/dev_lib.mjs +2 -1
  125. package/tests/dev_windows.mjs +324 -0
  126. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  127. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  128. package/tests/government_protected_surfaces.mjs +10 -0
  129. package/tests/grade_correlation_audit.mjs +159 -0
  130. package/tests/grade_engine.mjs +249 -0
  131. package/tests/grade_engine_replay.mjs +99 -0
  132. package/tests/grader_prompt_window_bound.mjs +132 -0
  133. package/tests/grader_unbacked_score.mjs +194 -0
  134. package/tests/hall_preview_scrub.mjs +214 -0
  135. package/tests/hall_records.mjs +85 -0
  136. package/tests/helpers.mjs +25 -0
  137. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  138. package/tests/integration_parked_ratchet.mjs +24 -1
  139. package/tests/plain_cards.mjs +1 -1
  140. package/tests/provisioning_public_refusal.mjs +195 -0
  141. package/tests/publish_status_branch.mjs +128 -0
  142. package/tests/redteam_patrol.mjs +1 -1
  143. package/tests/release_notes.mjs +327 -0
  144. package/tests/shell_stat_portability.mjs +49 -0
  145. package/tests/ship_land_conflict.mjs +181 -0
  146. package/tests/ship_land_red_pr.mjs +236 -0
  147. package/tests/ship_preflight.mjs +226 -3
  148. package/tests/start_env_guard.mjs +16 -0
  149. package/tests/sweep_app_account.mjs +15 -2
  150. package/tests/upgrade.mjs +20 -1
  151. package/tests/upgrade_migrate.mjs +195 -0
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/upgrade-migrate.js — WHICH command `bongos upgrade` migrates an instance
4
+ // with (task 1004274, found preparing the task 1004047 catch-up pass). Kept out of
5
+ // upgrade.js, which sits at the 1500-line cap.
6
+ //
7
+ // THE GAP. upgrade.js migrated with `npm run migrate`, and that only works where the
8
+ // instance's package.json DECLARES a migrate script. A greenfield scaffold always does
9
+ // (init.js buildInstancePackageJson writes INSTANCE_MIGRATE_SCRIPT), but the ADOPT layer
10
+ // does not: init.js mergeCoreDependency adds the core dependency and nothing else, and
11
+ // for a repo that had no package.json of its own it mints `{ name, private,
12
+ // dependencies }`. So an adopted project that is not a JavaScript app has no scripts at
13
+ // all. There `npm run migrate` answers "Missing script" — AFTER the pin has moved and
14
+ // the new core is installed — and the upgrade rolls itself back, restarting someone's
15
+ // live app to change nothing. mercury (a Python/Flask project; its package.json is
16
+ // exactly name/private/dependencies) is that shape, and it is why the catch-up pass
17
+ // could not reach it. The dry run could not warn either, because it never looked.
18
+ //
19
+ // THE FALLBACK IS NOT A NEW MIGRATION PATH. It is the command a scaffold's own script
20
+ // IS, and the one the control plane ran to create that instance's database at stand-up
21
+ // (provision-repo.js standaloneMigrateCmd): the installed core's scripts/migrate.sh,
22
+ // run from the instance root. A declared script still wins, whatever it says — an
23
+ // owner who wrote their own migrate step meant it — and `--skip-migrate` stays the
24
+ // only way to run no migration at all, which on a real jump leaves the new code
25
+ // running against the old schema.
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ // Relative to the instance root, POSIX on purpose: it is an argument to bash, and it
31
+ // must stay byte-identical to the scaffold's script (tests/upgrade_migrate.mjs pins it
32
+ // against init.js INSTANCE_MIGRATE_SCRIPT).
33
+ const CORE_MIGRATE_SCRIPT = 'node_modules/@bongos/core/scripts/migrate.sh';
34
+
35
+ /**
36
+ * How this instance is migrated: its own declared script, or the core's migrate.sh.
37
+ *
38
+ * Only a package.json we could READ, and that names no migrate script, earns the
39
+ * fallback. A missing or unparseable one keeps the old command, which then fails the
40
+ * way it always did — guessing at a file this could not parse would be worse.
41
+ *
42
+ * `env` exists only on the fallback, and only to hand migrate.sh what `npm run` would:
43
+ * migrate.sh finds the instance from INIT_CWD before its own cwd (task 2180), and npm
44
+ * sets it to the directory the script runs in. Launched directly, a stray INIT_CWD
45
+ * inherited from whoever started this upgrade would aim it at the wrong project.
46
+ *
47
+ * `note` is a pre-flight line, so the dry run says which command the move will run.
48
+ */
49
+ function migratePlan(instanceDir, { fsImpl = fs, env = process.env } = {}) {
50
+ let pkg = null;
51
+ try { pkg = JSON.parse(fsImpl.readFileSync(path.join(instanceDir, 'package.json'), 'utf8')); } catch { /* absent or unreadable */ }
52
+ const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
53
+ const declared = typeof scripts.migrate === 'string' && scripts.migrate.trim() !== '';
54
+ if (!pkg || declared) {
55
+ return { via: 'npm-script', cmd: 'npm', args: ['run', 'migrate'], label: 'npm run migrate', env: null, note: null };
56
+ }
57
+ return {
58
+ via: 'core-script',
59
+ cmd: 'bash',
60
+ args: [CORE_MIGRATE_SCRIPT],
61
+ label: `bash ${CORE_MIGRATE_SCRIPT}`,
62
+ env: { ...env, INIT_CWD: instanceDir },
63
+ note: 'migrate: this project declares no migrate script (an adopted repo), so the core\'s own scripts/migrate.sh will run — the command a scaffold\'s script runs',
64
+ };
65
+ }
66
+
67
+ module.exports = { migratePlan, CORE_MIGRATE_SCRIPT };
@@ -8,7 +8,7 @@
8
8
  // pre-flight clean git tree + module-compat vs the TARGET core version + the artist gate (ADR 0241)
9
9
  // → bump pin vendor the new core .tgz, rewrite the package.json `file:` dependency
10
10
  // → npm install node_modules/@bongos/core becomes the new version
11
- // → migrate `npm run migrate` → the core's migrate.sh applies additive `core_*` migrations
11
+ // → migrate `npm run migrate` (no such script: the core's migrate.sh itself, upgrade-migrate.js) applies additive `core_*` migrations
12
12
  // → materialize refresh .claude/ skills+settings+hooks from the new core (claude-materialize / W3)
13
13
  // → restart restart the instance's service so the new core is live
14
14
  // → verify installed package version == target (+ optional /healthz + ledger row)
@@ -246,7 +246,7 @@ async function resolvePinPushAuth({ instanceDir, remote = 'origin' }, deps = {},
246
246
  if (!repo) return null;
247
247
  const auth = require('./provision-repo-auth');
248
248
  const inst = await auth.lookupInstanceRow(instanceDir, deps);
249
- if (!inst) return null;
249
+ if (!inst) { const key = pinnedDeployKey(instanceDir, run); if (key) log(auth.missingRowNote(key, remote)); return null; } // task 1004065
250
250
  const rowRepo = auth.parseTargetRef(inst.target_ref);
251
251
  const same = (a, b) => a && b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
252
252
  if (!same(rowRepo, repo)) {
@@ -407,7 +407,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
407
407
  const repo = remoteGithubRepo(instanceDir, remote, run);
408
408
  err(` ! PIN NOT PUSHED — GitHub refused the push to ${remote}, so this bump is NOT durable.`);
409
409
  err(` ${remote} authenticates with the deploy key at ${key}, which is registered READ-ONLY on the`);
410
- err(` repository (ADR 0176): it can pull, never push. No owner GitHub token is stored to push with instead.${gitSaid(pushed)}`);
410
+ err(` repository (ADR 0176): it can pull, never push. No owner GitHub token was available to push with instead (none is stored, or — on a hosted project's own box — the hub's record of it is not readable from here).${gitSaid(pushed)}`);
411
411
  revert();
412
412
  err(' Fix: have the instance owner reconnect GitHub with repository access (that stores a short-lived repo token),');
413
413
  err(` then re-run \`bongos upgrade --commit-pin\` where that token is readable — the control plane, with --instance ${q(instanceDir)};`);
@@ -415,7 +415,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
415
415
  err(` ${at} push ${q(`https://github.com/${repo ? `${repo.owner}/${repo.name}` : '<owner>/<repo>'}.git`)} ${q(target)}`);
416
416
  return {
417
417
  applicable: true, committed: true, pushed: false, dirty, refused: 'read-only-deploy-key',
418
- error: `GitHub refused the pin push: ${remote} authenticates with the read-only deploy key at ${key}, and no owner GitHub token is stored to push with — the pin is committed locally but not on the remote`,
418
+ error: `GitHub refused the pin push: ${remote} authenticates with the read-only deploy key at ${key}, and no owner GitHub token was available to push with — the pin is committed locally but not on the remote`,
419
419
  };
420
420
  }
421
421
  err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
@@ -538,7 +538,8 @@ function preflightDbIdentity({ instanceDir, db }, run = spawnSync) {
538
538
  }
539
539
 
540
540
  function runMigrate(instanceDir, run = spawnSync) {
541
- const r = run('npm', ['run', 'migrate'], { cwd: instanceDir, stdio: 'inherit' });
541
+ const { cmd, args, env } = require('./upgrade-migrate').migratePlan(instanceDir); // its own migrate script, else the core's migrate.sh (task 1004274)
542
+ const r = run(cmd, args, { cwd: instanceDir, stdio: 'inherit', ...(env ? { env } : {}) });
542
543
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
543
544
  }
544
545
 
@@ -1142,6 +1143,7 @@ async function runUpgrade(opts, deps = {}) {
1142
1143
  if (!dbId.ok) return { ok: false, error: dbId.reason };
1143
1144
  if (dbId.skipped) log(` • db identity pre-check${db ? ` (${db})` : ''}: ${dbId.skipped}`);
1144
1145
  else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as the current user`);
1146
+ const { note: migrateNote } = require('./upgrade-migrate').migratePlan(instanceDir); if (migrateNote) log(` ✓ pre-flight: ${migrateNote}`);
1145
1147
  }
1146
1148
 
1147
1149
  // 1a. artist gate (ADR 0241, task 1004205): an open review holds the pin, --force too; a downgrade never waits.
@@ -1181,7 +1183,7 @@ async function runUpgrade(opts, deps = {}) {
1181
1183
  log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → file:${vendorRel}${prevPin ? ` (was ${prevPin})` : ''}`);
1182
1184
  }
1183
1185
 
1184
- if (dryRun) { log(' [dry-run] would: npm install → npm run migrate → materialize .claude → regen API docs+client → restart → verify'); return { ok: true, dryRun: true, fromVersion, toVersion: targetVersion }; }
1186
+ if (dryRun) { log(` [dry-run] would: npm install → ${opts.skipMigrate ? '(no migrate)' : require('./upgrade-migrate').migratePlan(instanceDir).label} → materialize .claude → regen API docs+client → restart → verify`); return { ok: true, dryRun: true, fromVersion, toVersion: targetVersion }; }
1185
1187
 
1186
1188
  // 3. npm install.
1187
1189
  const inst = npmInstall(instanceDir, run);
@@ -1455,7 +1457,7 @@ async function main(argv = process.argv.slice(2)) {
1455
1457
  ' --health-url <url> poll after restart (~30s) until 2xx; a still-failing check fails the bump (auto-rollback). REQUIRED for a bump that restarts the service, unless --no-health-check.',
1456
1458
  ' --version-url <url> read the RUNNING process\'s coreVersion back after restart and require it to equal --to (default: /version on the --health-url origin). A health check alone cannot tell a new core from an old one that never went down.',
1457
1459
  ' --no-health-check explicitly skip the post-restart health confirmation (default: the check is ON — a bump that restarts the service must confirm it serves); also skips the served-version read-back',
1458
- ' --skip-migrate do not run `npm run migrate`',
1460
+ ' --skip-migrate do not migrate (the default runs the instance\'s `migrate` script, else the core\'s scripts/migrate.sh)',
1459
1461
  ' --skip-materialize do not refresh .claude/',
1460
1462
  ' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
1461
1463
  ' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
@@ -41,17 +41,18 @@ fetched live — read-only GETs — and layered over the fixtures in memory. The
41
41
  banner names whose hall you are looking at:
42
42
 
43
43
  ```
44
- account: @scootermack (archon) — your own, live
44
+ account: @your-login (archon) — your own, live
45
45
  ```
46
46
 
47
47
  Nothing is written to `fixtures/` (that's `refresh-fixtures.js`'s job), so the
48
48
  checked-in snapshot stays the fallback. It serves whenever the live read can't
49
49
  happen — signed out, expired session, offline, or an endpoint your rank can't
50
50
  call (that one keeps its own fixture and the rest of your identity still lands).
51
- Every fallback says why, and says the hall is about to show you someone else:
51
+ Every fallback says why, and says the hall is about to show you someone else — a
52
+ stand-in, since the snapshot's people are scrubbed (see [Fixtures](#fixtures)):
52
53
 
53
54
  ```
54
- account: @jaxri (metic) — NOT YOURS, the checked-in fixture: this machine has no Bongos CLI session — run /builder-setup
55
+ account: @builder-4 (metic) — NOT YOURS, the checked-in fixture: this machine has no Bongos CLI session — run /builder-setup
55
56
  ```
56
57
 
57
58
  **`--fixture-me` pins the snapshot.** Use it for the screenshot matrix below —
@@ -74,6 +75,22 @@ node scripts/hall-preview/refresh-fixtures.js
74
75
 
75
76
  Box/terminal endpoints are deliberately never snapshotted (connection details).
76
77
 
78
+ **No real person is ever checked in (task 1003647).** `fixtures/` is committed, so
79
+ `refresh-fixtures.js` runs every snapshot through [`scrub-identity.js`](scrub-identity.js)
80
+ *before* writing it: logins, display names, avatars, GitHub ids and emails become
81
+ stand-ins (`builder-3` / `Builder 3`, GitHub's ghost avatar), one per person across the
82
+ run, and the snapshotting account's own credit balance, karma and spend (`me`, `me__*`)
83
+ become fixed values. Instance-local ids and free text (task titles, descriptions) are
84
+ left alone. Hand-edited a fixture? Run the same pass over the files on disk — no
85
+ network, and it rewrites only the values that change:
86
+
87
+ ```bash
88
+ node scripts/hall-preview/refresh-fixtures.js --scrub-only
89
+ ```
90
+
91
+ `tests/hall_preview_scrub.mjs` fails while any committed fixture still carries a real
92
+ identity.
93
+
77
94
  ## The evidence matrix (per page-redesign ship)
78
95
 
79
96
  Screenshot every changed page at **{light, dark} × {1280w, 390w}**, plus one
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // The harness answers every read from checked-in fixtures, and fixtures/me.json
6
6
  // is one particular builder's snapshot. So every preview greeted whoever ran it
7
- // as somebody else ("Welcome back, Jackson Rini"), with that builder's credits,
7
+ // as somebody else ("Welcome back, <whoever was snapshotted>"), with that builder's credits,
8
8
  // achievements, goals and RANK — and because that snapshot is a metic, the
9
9
  // Archon-gated pages rendered their sealed notice, so a UI change to them could
10
10
  // not be reviewed at all. Reviewing your own UI change against someone else's
@@ -7,11 +7,18 @@
7
7
  // Read-only: GETs only. Endpoints that 403/404 for the caller's rank are
8
8
  // skipped (the existing fixture, if any, is kept). Box/terminal endpoints are
9
9
  // deliberately NOT snapshotted (they carry host/connection details).
10
+ //
11
+ // PEOPLE ARE SCRUBBED ON WRITE (task 1003647). fixtures/ is committed, so every
12
+ // snapshot goes through ./scrub-identity.js before it touches the disk: logins,
13
+ // names, avatars, GitHub ids and emails become stand-ins (`builder-3` / `Builder
14
+ // 3`), and the snapshotting account's own balance and spend become fixed values.
15
+ // Hand-edited a fixture? Re-run the same pass over the files on disk, no network:
16
+ // node scripts/hall-preview/refresh-fixtures.js --scrub-only
10
17
  'use strict';
11
18
 
12
19
  const fs = require('fs');
13
20
  const path = require('path');
14
- const { cliClient } = require('../gds/cli-lib');
21
+ const { createScrubber, isOwnAccountFixture, highestStandIn, rewriteScalars } = require('./scrub-identity');
15
22
 
16
23
  const FIXTURES = path.join(__dirname, 'fixtures');
17
24
 
@@ -77,20 +84,73 @@ const SNAPSHOTS = [
77
84
  ['public/versions', 'public__versions'],
78
85
  ];
79
86
 
80
- (async () => {
81
- const api = await cliClient();
82
- fs.mkdirSync(FIXTURES, { recursive: true });
87
+ // Every existing fixture's text — read so a run can number new stand-ins above the
88
+ // ones already in use (a hand-written fixture may carry `builder-7`).
89
+ function readFixtureTexts(dir) {
90
+ try {
91
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => fs.readFileSync(path.join(dir, f), 'utf8'));
92
+ } catch (e) {
93
+ if (e.code === 'ENOENT') return [];
94
+ throw e;
95
+ }
96
+ }
97
+
98
+ // refreshFixtures — GET each snapshot, scrub the people out of it, write it. The api,
99
+ // directory and logger are injected so tests/hall_preview_scrub.mjs drives this exact
100
+ // writer with no network and a temp directory.
101
+ async function refreshFixtures({ api, dir = FIXTURES, snapshots = SNAPSHOTS, log = console.log } = {}) {
102
+ fs.mkdirSync(dir, { recursive: true });
103
+ const scrubber = createScrubber({ firstNumber: highestStandIn(readFixtureTexts(dir)) + 1 });
83
104
  let wrote = 0;
84
- for (const [apiPath, name, trim] of SNAPSHOTS) {
105
+ for (const [apiPath, name, trim] of snapshots) {
85
106
  const res = await api.request('GET', `/${apiPath}`, {});
86
107
  if (!res.ok) {
87
- console.log(` skip ${apiPath} → ${res.status} (kept existing fixture if present)`);
108
+ log(` skip ${apiPath} → ${res.status} (kept existing fixture if present)`);
88
109
  continue;
89
110
  }
90
- const data = trim ? trim(res.data) : res.data;
91
- fs.writeFileSync(path.join(FIXTURES, `${name}.json`), `${JSON.stringify(data, null, 2)}\n`);
111
+ // Scrubbed BEFORE the write, never after: fixtures/ is committed, so a copy of
112
+ // the live response that touched the disk would already be one `git add` away.
113
+ const data = scrubber.scrub(trim ? trim(res.data) : res.data, { ownAccount: isOwnAccountFixture(name) });
114
+ fs.writeFileSync(path.join(dir, `${name}.json`), `${JSON.stringify(data, null, 2)}\n`);
92
115
  wrote += 1;
93
- console.log(` wrote ${name}.json (${apiPath})`);
116
+ log(` wrote ${name}.json (${apiPath})`);
94
117
  }
95
- console.log(`refresh-fixtures: ${wrote}/${SNAPSHOTS.length} snapshots written to ${FIXTURES}`);
96
- })().catch((e) => { console.error(e.message || e); process.exit(1); });
118
+ log(`refresh-fixtures: ${wrote}/${snapshots.length} snapshots written to ${dir} (people replaced by stand-ins)`);
119
+ return wrote;
120
+ }
121
+
122
+ // scrubFixturesOnDisk — the same pass over the files already in fixtures/: the
123
+ // hand-written ones (several copied real people in by hand) and anything written
124
+ // before the scrub existed. Only values that change are rewritten, so a fixture
125
+ // someone formatted by hand keeps its formatting.
126
+ function scrubFixturesOnDisk({ dir = FIXTURES, log = console.log } = {}) {
127
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
128
+ const texts = files.map((f) => fs.readFileSync(path.join(dir, f), 'utf8'));
129
+ const scrubber = createScrubber({ firstNumber: highestStandIn(texts) + 1 });
130
+ let changed = 0;
131
+ files.forEach((file, idx) => {
132
+ const clean = scrubber.scrub(JSON.parse(texts[idx]), { ownAccount: isOwnAccountFixture(file.slice(0, -'.json'.length)) });
133
+ const next = rewriteScalars(texts[idx], clean);
134
+ if (next === texts[idx]) return;
135
+ fs.writeFileSync(path.join(dir, file), next);
136
+ changed += 1;
137
+ log(` scrubbed ${file}`);
138
+ });
139
+ log(`refresh-fixtures --scrub-only: ${changed}/${files.length} fixture(s) rewritten in ${dir}`);
140
+ return changed;
141
+ }
142
+
143
+ module.exports = { refreshFixtures, scrubFixturesOnDisk, SNAPSHOTS };
144
+
145
+ if (require.main === module) {
146
+ (async () => {
147
+ if (process.argv.includes('--scrub-only')) {
148
+ scrubFixturesOnDisk();
149
+ return;
150
+ }
151
+ // Required here, not at the top: a test that loads this file for the writer must
152
+ // not also load the CLI session machinery.
153
+ const { cliClient } = require('../gds/cli-lib');
154
+ await refreshFixtures({ api: await cliClient() });
155
+ })().catch((e) => { console.error(e.message || e); process.exit(1); });
156
+ }
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+ // scripts/hall-preview/scrub-identity.js — replace the real people in a hall-preview
3
+ // fixture with stand-ins before the file is written (task 1003647).
4
+ //
5
+ // WHY. refresh-fixtures.js snapshots LIVE API responses into fixtures/, and fixtures/
6
+ // is committed. So every refresh published the GitHub login, display name, avatar and
7
+ // GitHub id of everyone the snapshot could see — and, in me.json, the snapshotting
8
+ // account's own credit balance and month-to-date spend — into the repo and every clone
9
+ // of it. A leak like that is fixed in the WRITER: cleaning the output once re-leaks on
10
+ // the next refresh. So refresh-fixtures.js passes every snapshot through here, and its
11
+ // --scrub-only mode runs the same pass over files already on disk (hand-written ones
12
+ // included, which copied real people in by hand).
13
+ //
14
+ // THE SCHEME.
15
+ // • A person becomes `builder-<n>` (login-shaped fields) / `Builder <n>` (names), one
16
+ // n per person for the whole run, so a builder reads the same on every page. The
17
+ // person is keyed by login; a name travels with the login beside it.
18
+ // • An avatar becomes GitHub's own "ghost" avatar — the account GitHub shows for
19
+ // deleted users. It is a real image, so pages still render, and it is nobody.
20
+ // • A GitHub id becomes the stand-in's n; an email, `builder-<n>@example.com`.
21
+ // • Private account figures (month-to-date spend, budgets) become fixed values
22
+ // wherever they appear. In the snapshotting account's OWN files (me, me__*), so do
23
+ // its credit balance and karma — the "caller's credits" this task was filed about.
24
+ // Other builders' balances are kept: with their names gone they describe "Builder
25
+ // 3", not a person, and flattening them would flatten the leaderboard and roster
26
+ // previews the harness exists for.
27
+ // • null stays null and "" stays "". Pages branch on absence (initials when there is
28
+ // no avatar, the login when there is no display name) and tests pin those states.
29
+ //
30
+ // NOT TOUCHED. Instance-local numeric ids (builder, task, goal) — the harness routes on
31
+ // them (analytics/builder/:id, /profile?id=…) and they name nobody outside that one
32
+ // instance. Free text (task titles, descriptions) — project content, not a person's
33
+ // record. The repo's own coordinates (instance.repo.owner is where the code lives).
34
+ //
35
+ // FIXED POINT. Every stand-in is recognised on the way back in (`builder-3` is person
36
+ // 3), so scrubbing an already-scrubbed fixture changes nothing. tests/hall_preview_scrub.mjs
37
+ // leans on exactly that: each committed fixture must already be a fixed point, which is
38
+ // the same as saying no real identity is committed.
39
+
40
+ // GitHub's "ghost" user (id 10137): the placeholder GitHub itself substitutes for a
41
+ // deleted account. An https URL on the host the real avatars use, because the hall
42
+ // renders avatars only through an http(s) check (roster.js / collab-lib.js httpUrl).
43
+ const GHOST_AVATAR_URL = 'https://avatars.githubusercontent.com/u/10137?v=4';
44
+
45
+ // The fixed values that replace private figures. Round, obviously fake, stable.
46
+ const OWN_CREDITS = 1000;
47
+ const OWN_KARMA = 10;
48
+ const PRIVATE_FIGURES = { spend_mtd_usd: 42, budget_mtd_usd: 100, monthly_budget_usd: 100 };
49
+ const PLACEHOLDER_TEXT = {
50
+ device_name: 'Preview device',
51
+ anthropic_user_id: 'anthropic-user-preview',
52
+ bio: 'A builder bio, replaced for the preview.',
53
+ };
54
+
55
+ const STAND_IN_LOGIN = /^builder-(\d+)$/i;
56
+ const STAND_IN_NAME = /^Builder (\d+)$/;
57
+ const STAND_IN_EMAIL = /^builder-(\d+)@example\.com$/i;
58
+
59
+ const isLoginKey = (k) => k === 'github_login' || k === 'login' || k === 'handle' || /_login$/.test(k);
60
+ const isAvatarKey = (k) => k === 'avatar_url' || /_avatar_url$/.test(k);
61
+ const isGithubIdKey = (k) => k === 'github_id' || /_github_id$/.test(k);
62
+ const isEmailKey = (k) => k === 'email' || /_email$/.test(k);
63
+ // `by` / `*_by` holds a builder ID almost everywhere (created_by, shipped_by); only a
64
+ // non-numeric value is a login (sandbox_review.by, who approved a staged change).
65
+ const isByKey = (k) => k === 'by' || /_by$/.test(k);
66
+ const looksLikeLogin = (v) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(v) && !/^\d+$/.test(v);
67
+
68
+ // The login a name-shaped key belongs to, or undefined when the key is not a person's
69
+ // name. `display_name` pairs with the object's own login; `<who>_name` and
70
+ // `<who>_display_name` pair with `<who>_login` beside them — which is what keeps
71
+ // `worktree_name` (no `worktree_login`) out.
72
+ function nameOwnerKey(key, obj) {
73
+ if (key === 'display_name') return ['github_login', 'login', 'handle'].find((k) => k in obj) || null;
74
+ const m = /^(.+?)(?:_display)?_name$/.exec(key);
75
+ if (!m) return undefined;
76
+ const loginKey = `${m[1]}_login`;
77
+ return loginKey in obj ? loginKey : undefined;
78
+ }
79
+
80
+ // createScrubber — one registry of people for one run, so a builder gets the same n in
81
+ // every file the run touches. `firstNumber` lets a run start above stand-ins already on
82
+ // disk, so a new person never collides with one a hand-written fixture already uses.
83
+ function createScrubber({ firstNumber = 1 } = {}) {
84
+ const people = new Map(); // 'login:<lower>' | 'name:<text>' | 'id:<github id>' | 'email:<lower>' → n
85
+ let next = Math.max(1, Number(firstNumber) || 1);
86
+
87
+ function numberFor(keys) {
88
+ for (const k of keys) if (k && people.has(k)) return remember(keys, people.get(k));
89
+ return remember(keys, next++);
90
+ }
91
+ function remember(keys, n) {
92
+ for (const k of keys) if (k) people.set(k, n);
93
+ if (n >= next) next = n + 1;
94
+ return n;
95
+ }
96
+
97
+ // The person an object describes, from whichever identity fields it carries. A
98
+ // stand-in keeps its own number; a real value gets the registry's.
99
+ function personOf(obj, loginKey, nameKey) {
100
+ const login = loginKey && typeof obj[loginKey] === 'string' ? obj[loginKey] : '';
101
+ const name = nameKey && typeof obj[nameKey] === 'string' ? obj[nameKey] : '';
102
+ const standIn = STAND_IN_LOGIN.exec(login) || STAND_IN_NAME.exec(name);
103
+ if (standIn) return remember([], Number(standIn[1]));
104
+ return numberFor([login && `login:${login.toLowerCase()}`, name && `name:${name}`]);
105
+ }
106
+
107
+ function scrubObject(obj, ctx) {
108
+ const out = {};
109
+ // The object's own person, resolved once so its login, name, avatar, GitHub id and
110
+ // email all land on the same n.
111
+ const ownLoginKey = ['github_login', 'login', 'handle'].find((k) => typeof obj[k] === 'string' && obj[k]);
112
+ const ownNameKey = typeof obj.display_name === 'string' && obj.display_name ? 'display_name' : null;
113
+ let selfN = null;
114
+ const self = () => {
115
+ if (selfN == null) selfN = (ownLoginKey || ownNameKey) ? personOf(obj, ownLoginKey, ownNameKey) : null;
116
+ return selfN;
117
+ };
118
+ const isPersonRecord = Boolean(ownLoginKey || ownNameKey);
119
+
120
+ for (const [key, value] of Object.entries(obj)) {
121
+ out[key] = scrubValue(key, value, obj, ctx, self, isPersonRecord);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ function scrubArray(arr, ctx) {
127
+ return arr.map((v) => (Array.isArray(v) ? scrubArray(v, ctx) : v && typeof v === 'object' ? scrubObject(v, ctx) : v));
128
+ }
129
+
130
+ function scrubValue(key, value, obj, ctx, self, isPersonRecord) {
131
+ if (value && typeof value === 'object') {
132
+ if (key === 'links' && isPersonRecord) return scrubLinks(value);
133
+ const inner = Array.isArray(value) ? scrubArray(value, ctx) : scrubObject(value, ctx);
134
+ // An OWN-account file's roll-up totals are the account's own credits too.
135
+ if (ctx.ownAccount && key === 'totals' && !Array.isArray(inner)) {
136
+ if (typeof inner.credits === 'number') inner.credits = OWN_CREDITS;
137
+ if (typeof inner.karma === 'number') inner.karma = OWN_KARMA;
138
+ }
139
+ return inner;
140
+ }
141
+ if (value == null || value === '') return value; // absence is a state the pages render
142
+ if (key in PRIVATE_FIGURES) return typeof value === 'string' ? String(PRIVATE_FIGURES[key]) : PRIVATE_FIGURES[key];
143
+
144
+ if (typeof value === 'string') {
145
+ if (isLoginKey(key)) {
146
+ if (STAND_IN_LOGIN.test(value)) return value;
147
+ const n = ['github_login', 'login', 'handle'].includes(key) ? self() : personOf(obj, key, siblingNameKey(key, obj));
148
+ return `builder-${n}`;
149
+ }
150
+ const owner = nameOwnerKey(key, obj);
151
+ if (owner !== undefined) {
152
+ if (STAND_IN_NAME.test(value)) return value;
153
+ const n = key === 'display_name' ? self() : personOf(obj, owner, key);
154
+ return `Builder ${n}`;
155
+ }
156
+ if (isAvatarKey(key)) return GHOST_AVATAR_URL;
157
+ if (isEmailKey(key)) {
158
+ if (STAND_IN_EMAIL.test(value)) return value;
159
+ const n = isPersonRecord ? self() : numberFor([`email:${value.toLowerCase()}`]);
160
+ return `builder-${n}@example.com`;
161
+ }
162
+ if (isByKey(key) && looksLikeLogin(value)) {
163
+ if (STAND_IN_LOGIN.test(value)) return value;
164
+ return `builder-${numberFor([`login:${value.toLowerCase()}`])}`;
165
+ }
166
+ // A PR / commit author is a GitHub login; elsewhere `author` credits an
167
+ // organisation (a module's author), which is nobody's personal record.
168
+ if (key === 'author' && 'head_sha' in obj && looksLikeLogin(value)) {
169
+ if (STAND_IN_LOGIN.test(value)) return value;
170
+ return `builder-${numberFor([`login:${value.toLowerCase()}`])}`;
171
+ }
172
+ if (key in PLACEHOLDER_TEXT) return PLACEHOLDER_TEXT[key];
173
+ if (isGithubIdKey(key)) return String(githubIdFor(obj, value, self, isPersonRecord));
174
+ return value;
175
+ }
176
+
177
+ if (typeof value === 'number') {
178
+ if (isGithubIdKey(key)) return githubIdFor(obj, value, self, isPersonRecord);
179
+ if (ctx.ownAccount && isPersonRecord) {
180
+ if (key === 'total_credits') return OWN_CREDITS;
181
+ if (key === 'karma' || key === 'total_karma') return OWN_KARMA;
182
+ }
183
+ }
184
+ return value;
185
+ }
186
+
187
+ // A GitHub id names an account anywhere on GitHub, so it follows its person's n.
188
+ function githubIdFor(obj, value, self, isPersonRecord) {
189
+ if (isPersonRecord) return self();
190
+ const n = Number(value);
191
+ // A standalone small id is already a stand-in (real GitHub ids of any account a
192
+ // builder holds today are seven-plus digits).
193
+ if (Number.isInteger(n) && n > 0 && n < 100000) return n;
194
+ return numberFor([`id:${value}`]);
195
+ }
196
+
197
+ // The login key that sits beside a `<who>_name` key, for pairing the two.
198
+ function siblingNameKey(loginKey, obj) {
199
+ const who = loginKey.replace(/_login$/, '');
200
+ return [`${who}_name`, `${who}_display_name`].find((k) => typeof obj[k] === 'string' && obj[k]) || null;
201
+ }
202
+
203
+ // A profile's links are the person's own sites. Keep the shape, lose the addresses.
204
+ function scrubLinks(value) {
205
+ if (Array.isArray(value)) return value.map((v) => (typeof v === 'string' ? 'https://example.com/' : (v && typeof v === 'object' ? scrubLinks(v) : v)));
206
+ const out = {};
207
+ for (const [k, v] of Object.entries(value)) out[k] = typeof v === 'string' && v ? 'https://example.com/' : (v && typeof v === 'object' ? scrubLinks(v) : v);
208
+ return out;
209
+ }
210
+
211
+ // scrub(data, { ownAccount }) — a scrubbed deep copy; the input is not mutated.
212
+ // ownAccount: the fixture IS the snapshotting account (me, me__*), so its own
213
+ // credit balance and karma are replaced too.
214
+ function scrub(data, { ownAccount = false } = {}) {
215
+ const ctx = { ownAccount };
216
+ if (Array.isArray(data)) return scrubArray(data, ctx);
217
+ return data && typeof data === 'object' ? scrubObject(data, ctx) : data;
218
+ }
219
+
220
+ return { scrub };
221
+ }
222
+
223
+ // A fixture named `me` or `me__…` is the snapshotting account's own record.
224
+ const isOwnAccountFixture = (name) => name === 'me' || String(name).startsWith('me__');
225
+
226
+ // The highest stand-in number already used in some fixture texts, so a new run can
227
+ // start above it (createScrubber's firstNumber).
228
+ function highestStandIn(texts) {
229
+ let max = 0;
230
+ for (const t of texts) {
231
+ for (const m of String(t).matchAll(/"builder-(\d+)(?:@example\.com)?"|"Builder (\d+)"/gi)) {
232
+ max = Math.max(max, Number(m[1] || m[2]));
233
+ }
234
+ }
235
+ return max;
236
+ }
237
+
238
+ // rewriteScalars(text, next) — the JSON text of a hand-written fixture with ONLY the
239
+ // changed scalar values replaced, byte-for-byte everywhere else. A plain
240
+ // JSON.stringify would also reformat every compact array and inline object a person
241
+ // wrote by hand, burying a handful of identity edits in a whole-file rewrite.
242
+ function rewriteScalars(text, next) {
243
+ const splices = [];
244
+ let i = 0;
245
+ const ws = () => { while (i < text.length && /\s/.test(text[i])) i++; };
246
+ const readString = () => {
247
+ const start = i++;
248
+ while (text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
249
+ i++;
250
+ return { start, end: i, value: JSON.parse(text.slice(start, i)) };
251
+ };
252
+ const readScalar = () => {
253
+ if (text[i] === '"') return readString();
254
+ const start = i;
255
+ while (i < text.length && !/[\s,\]}]/.test(text[i])) i++;
256
+ return { start, end: i, value: JSON.parse(text.slice(start, i)) };
257
+ };
258
+ const visit = (after) => {
259
+ ws();
260
+ if (text[i] === '{') {
261
+ i++; ws();
262
+ if (text[i] === '}') { i++; return; }
263
+ for (;;) {
264
+ ws(); const { value: key } = readString(); ws(); i++; // the ':'
265
+ visit(after == null ? undefined : after[key]);
266
+ ws();
267
+ if (text[i] === ',') { i++; continue; }
268
+ i++; return; // the '}'
269
+ }
270
+ }
271
+ if (text[i] === '[') {
272
+ i++; ws();
273
+ if (text[i] === ']') { i++; return; }
274
+ for (let idx = 0; ; idx++) {
275
+ visit(after == null ? undefined : after[idx]);
276
+ ws();
277
+ if (text[i] === ',') { i++; continue; }
278
+ i++; return; // the ']'
279
+ }
280
+ }
281
+ const tok = readScalar();
282
+ if (after !== undefined && after !== tok.value) splices.push([tok.start, tok.end, JSON.stringify(after)]);
283
+ };
284
+ visit(next);
285
+ let out = text;
286
+ for (const [start, end, repl] of splices.reverse()) out = out.slice(0, start) + repl + out.slice(end);
287
+ return out;
288
+ }
289
+
290
+ module.exports = {
291
+ createScrubber,
292
+ isOwnAccountFixture,
293
+ highestStandIn,
294
+ rewriteScalars,
295
+ GHOST_AVATAR_URL,
296
+ };
@@ -21,7 +21,7 @@ const fs = require('node:fs/promises');
21
21
  const auth = require('../auth');
22
22
  const db = require('../db');
23
23
  const { validateOrRespond } = require('./_helpers');
24
- const { resolveEnv } = require('../../instance-config');
24
+ const { resolveEnv, envPrefix } = require('../../instance-config');
25
25
 
26
26
  // Slug shape for security_docs (task 991): lowercase kebab, 1–128 chars. Used to
27
27
  // validate POST /security/docs and to safely interpolate :slug into the GET.
@@ -102,8 +102,8 @@ module.exports = function buildSecurityRouter() {
102
102
  }
103
103
  const hint =
104
104
  result.reason === 'env_not_set'
105
- ? 'GDS_SECURITY_ADR_PATH is unset on the server; an Archon must provision the file and the env var.'
106
- : 'GDS_SECURITY_ADR_PATH is set but the file is unreadable (missing, wrong perms, or wrong path).';
105
+ ? `${envPrefix()}_SECURITY_ADR_PATH is unset on the server; an Archon must provision the file and the env var.`
106
+ : `${envPrefix()}_SECURITY_ADR_PATH (or its legacy GDS_ spelling) is set but the file is unreadable (missing, wrong perms, or wrong path).`;
107
107
  return res.fail('security_adr_not_provisioned', 404, { reason: result.reason, hint });
108
108
  }
109
109
  );
package/src/build-info.js CHANGED
@@ -8,13 +8,15 @@
8
8
  // deployedCommit === mergeCommit before flipping a task to shipped.
9
9
  //
10
10
  // Resolution order (resolveCommit is pure + exported for the test):
11
- // 1. env OTB_GIT_SHA — set by ~/deploy.sh at restart if/when it chooses to;
11
+ // 1. env <PREFIX>_GIT_SHA (BONGOS_GIT_SHA on a vanilla instance; the legacy
12
+ // OTB_GIT_SHA is still read) — set by ~/deploy.sh at restart if/when it chooses to;
12
13
  // authoritative + cheap, and survives a checkout with no usable .git.
13
14
  // 2. `git rev-parse HEAD` in the checkout — accurate after deploy.sh's
14
15
  // `git reset --hard origin/main`.
15
16
  // 3. null — unknown (callers must degrade gracefully, never hard-fail).
16
17
 
17
18
  const { execSync } = require('node:child_process');
19
+ const { resolveEnv } = require('./instance-config');
18
20
 
19
21
  const SHA_RE = /^[0-9a-f]{7,40}$/i;
20
22
 
@@ -62,7 +64,7 @@ let _cache = null;
62
64
  // restarts the service), so caching is correct and keeps /version allocation-free.
63
65
  function buildInfo() {
64
66
  if (_cache) return _cache;
65
- const fromEnv = resolveCommit({ envSha: process.env.OTB_GIT_SHA });
67
+ const fromEnv = resolveCommit({ envSha: resolveEnv('GIT_SHA') });
66
68
  const commit = fromEnv || resolveCommit({ gitSha: readGitSha() });
67
69
  _cache = {
68
70
  commit,