@bongos/core 1.18.18 → 1.19.2

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 (251) hide show
  1. package/.bongos-core.json +515 -195
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +63 -41
  10. package/clients/bongos-client/index.cjs +63 -41
  11. package/clients/bongos-client/index.d.ts +78 -44
  12. package/clients/bongos-client/index.mjs +63 -41
  13. package/docs/adr/0020-security-model-public-stub.md +1 -1
  14. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  15. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  16. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  17. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  18. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  19. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  20. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  21. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  22. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  23. package/docs/adr/README.md +8 -0
  24. package/docs/api/openapi.json +1209 -233
  25. package/docs/api-reference.md +61 -46
  26. package/docs/architecture.md +1 -1
  27. package/docs/canonical-permissions.md +61 -16
  28. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  29. package/docs/file-map.md +4 -1
  30. package/docs/onboarding/diagrams/assertions.json +3 -3
  31. package/docs/recipes/core-release-pipeline.md +179 -0
  32. package/docs/recipes/packaging-the-core.md +2 -0
  33. package/docs/recipes/parallel-goal-waves.md +1 -3
  34. package/docs/recipes/private-npm-distribution.md +2 -0
  35. package/docs/recipes/upgrading-the-core.md +2 -0
  36. package/migrations/core_197_display_name_source.sql +56 -0
  37. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  38. package/modules/autonomy/routes/autonomy.js +1 -1
  39. package/modules/builder-settings/db.js +7 -1
  40. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  41. package/modules/dev-box/routes/box.js +11 -9
  42. package/modules/discord/auth.js +3 -1
  43. package/modules/discord/routes/discord.js +8 -2
  44. package/modules/discord/ship-broadcast.js +48 -1
  45. package/modules/governance/catalog.js +70 -20
  46. package/modules/governance/db.js +102 -0
  47. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  48. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  49. package/modules/governance/routes/governance.js +235 -6
  50. package/modules/grading/grader-workers/index.js +18 -9
  51. package/modules/hall-ui/public/gate.js +6 -2
  52. package/modules/hall-ui/public/goals-page.js +6 -2
  53. package/modules/hall-ui/public/governance.css +100 -0
  54. package/modules/hall-ui/public/governance.html +77 -0
  55. package/modules/hall-ui/public/governance.js +353 -0
  56. package/modules/hall-ui/public/harbor.js +14 -8
  57. package/modules/hall-ui/public/instances-panel.js +3 -1
  58. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  59. package/modules/hall-ui/public/people.html +51 -0
  60. package/modules/hall-ui/public/people.js +100 -0
  61. package/modules/hall-ui/public/profile.html +2 -1
  62. package/modules/hall-ui/public/profile.js +9 -3
  63. package/modules/hall-ui/public/sessions.js +4 -2
  64. package/modules/hall-ui/public/shell.js +28 -7
  65. package/modules/hall-ui/public/task-detail.js +6 -3
  66. package/modules/hall-ui/public/watch.js +17 -9
  67. package/modules/ideas/blockers.js +44 -7
  68. package/modules/ideas/routes/blockers.js +12 -7
  69. package/modules/ideas/routes/inbox.js +2 -2
  70. package/modules/lifecycle/db.js +84 -17
  71. package/modules/lifecycle/github-push.js +37 -10
  72. package/modules/lifecycle/publish-reconciler.js +128 -31
  73. package/modules/lifecycle/routes/claims.js +10 -4
  74. package/modules/lifecycle/routes/dependencies.js +2 -2
  75. package/modules/lifecycle/routes/done-when.js +4 -4
  76. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  77. package/modules/lifecycle/routes/goals.js +5 -5
  78. package/modules/lifecycle/routes/tasks.js +16 -16
  79. package/modules/lifecycle/routes/versions.js +13 -2
  80. package/modules/lifecycle/ship-card.js +38 -7
  81. package/modules/memory/routes/memory.js +2 -2
  82. package/modules/onboarding/routes/access-requests.js +3 -3
  83. package/modules/platform-identity/routes/projects.js +10 -6
  84. package/modules/platform-identity/routes/scouting.js +2 -2
  85. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  86. package/modules/provisioning/provisioning.js +24 -6
  87. package/modules/provisioning/routes/provisioning.js +24 -6
  88. package/modules/security/artifact-scan/aggregate.js +133 -0
  89. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  90. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  91. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  92. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  93. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  94. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  95. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  96. package/modules/security/artifact-scan/detectors/network.js +67 -0
  97. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  98. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  99. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  100. package/modules/security/artifact-scan/fence-local.js +99 -0
  101. package/modules/security/artifact-scan/fetch.js +330 -0
  102. package/modules/security/artifact-scan/floor.js +319 -0
  103. package/modules/security/artifact-scan/limits.js +66 -0
  104. package/modules/security/artifact-scan/manifest.js +203 -0
  105. package/modules/security/artifact-scan/mirror.js +158 -0
  106. package/modules/security/artifact-scan/quarantine.js +149 -0
  107. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  108. package/modules/security/artifact-scan/report.js +155 -0
  109. package/modules/security/artifact-scan/sanitize.js +317 -0
  110. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  111. package/modules/security/module.json +2 -2
  112. package/modules/security/routes/reports.js +11 -7
  113. package/modules/security/routes/scans.js +115 -0
  114. package/modules/security/scan-store.js +214 -0
  115. package/modules/security/tests/aggregate.mjs +283 -0
  116. package/modules/security/tests/artifact_scan.mjs +591 -0
  117. package/modules/security/tests/floor.mjs +549 -0
  118. package/modules/security/tests/mirror.mjs +350 -0
  119. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  120. package/modules/security/tests/scan_store.mjs +250 -0
  121. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  122. package/modules/sessions/routes/sessions.js +6 -5
  123. package/package-lock.json +2 -2
  124. package/package.json +1 -1
  125. package/public-landing/create.html +13 -3
  126. package/scripts/gds/abandon.js +1 -1
  127. package/scripts/gds/audit-authorship.sh +6 -0
  128. package/scripts/gds/box.js +1 -1
  129. package/scripts/gds/claim.js +15 -0
  130. package/scripts/gds/cli-lib.js +16 -4
  131. package/scripts/gds/gating.js +28 -13
  132. package/scripts/gds/gen-api-docs.js +1 -1
  133. package/scripts/gds/goal.js +234 -0
  134. package/scripts/gds/init.js +70 -15
  135. package/scripts/gds/main-audit.js +80 -11
  136. package/scripts/gds/mirror-redact.js +6 -0
  137. package/scripts/gds/newcomer-floor.js +2 -2
  138. package/scripts/gds/provision.js +230 -94
  139. package/scripts/gds/run-unit-tests.js +9 -1
  140. package/scripts/gds/sandbox-stage.js +66 -9
  141. package/scripts/gds/search-ingest-md.js +1 -0
  142. package/scripts/gds/sequence.js +67 -8
  143. package/scripts/gds/ship.js +183 -50
  144. package/scripts/hall-preview/server.js +1 -1
  145. package/scripts/migrate.sh +99 -14
  146. package/src/bongos/auth.js +129 -27
  147. package/src/bongos/db-kernel.js +66 -5
  148. package/src/bongos/db.js +6 -7
  149. package/src/bongos/github-repos.js +19 -7
  150. package/src/bongos/goal-scope-check.js +1 -1
  151. package/src/bongos/module-scope-map.js +3 -1
  152. package/src/bongos/permission-path-check.js +1 -1
  153. package/src/bongos/route-rank-check.js +73 -30
  154. package/src/bongos/routes/audit-log.js +4 -2
  155. package/src/bongos/routes/auth.js +8 -4
  156. package/src/bongos/routes/backup.js +7 -4
  157. package/src/bongos/routes/builders.js +23 -11
  158. package/src/bongos/routes/github.js +15 -4
  159. package/src/bongos/routes/llm-cache.js +5 -2
  160. package/src/bongos/routes/modules.js +9 -5
  161. package/src/bongos/routes/security.js +21 -12
  162. package/src/bongos/serve-internal.js +22 -7
  163. package/src/module-api.js +86 -1
  164. package/src/platform-server.js +14 -0
  165. package/tests/api_client.mjs +18 -2
  166. package/tests/api_path_404.mjs +3 -0
  167. package/tests/art_stage.mjs +2 -1
  168. package/tests/atlas.mjs +32 -4
  169. package/tests/auth_admission_gate.mjs +17 -5
  170. package/tests/auth_error_brand.mjs +41 -21
  171. package/tests/auth_resolve_failure.mjs +155 -0
  172. package/tests/blocker_link.mjs +88 -0
  173. package/tests/bongos_downloads.mjs +2 -1
  174. package/tests/box_dns_repoint.mjs +2 -0
  175. package/tests/box_ship_permissions.mjs +2 -0
  176. package/tests/box_terminal_ssrf.mjs +23 -17
  177. package/tests/claim_error_surface.mjs +23 -0
  178. package/tests/claude_materialize.mjs +4 -0
  179. package/tests/cli_box_scope.mjs +40 -0
  180. package/tests/cloud_bongos_pack.mjs +34 -4
  181. package/tests/conductor.mjs +2 -1
  182. package/tests/criterion_authoring_gate.mjs +28 -5
  183. package/tests/currency_label.mjs +10 -1
  184. package/tests/deploy_instance.mjs +2 -0
  185. package/tests/devbox_downloads.mjs +2 -1
  186. package/tests/display_name.mjs +95 -6
  187. package/tests/eviction_policy.mjs +2 -1
  188. package/tests/file_map.mjs +10 -0
  189. package/tests/first_admin_bootstrap.mjs +10 -2
  190. package/tests/first_run_engine_check.mjs +2 -1
  191. package/tests/fitness.mjs +26 -6
  192. package/tests/gate_approvals.mjs +15 -2
  193. package/tests/github_push_land_proof.mjs +50 -4
  194. package/tests/github_repos_helpers.mjs +30 -5
  195. package/tests/github_token_store.mjs +23 -0
  196. package/tests/goal_abuse_matrix.mjs +13 -3
  197. package/tests/goal_cli.mjs +89 -0
  198. package/tests/governance_abuse_matrix.mjs +386 -0
  199. package/tests/governance_fold.mjs +156 -0
  200. package/tests/governance_fold_bench.mjs +50 -0
  201. package/tests/governance_live_proof.mjs +201 -0
  202. package/tests/governance_page_gates.mjs +21 -9
  203. package/tests/governance_parity.mjs +11 -2
  204. package/tests/governance_require_permission.mjs +125 -1
  205. package/tests/governance_routes.mjs +334 -0
  206. package/tests/grader.mjs +7 -3
  207. package/tests/hall_nav.mjs +14 -4
  208. package/tests/helpers.mjs +73 -0
  209. package/tests/hook_core_resolve.mjs +2 -1
  210. package/tests/host_topology_skips.mjs +97 -0
  211. package/tests/infra_exec_paths.mjs +14 -4
  212. package/tests/init.mjs +128 -0
  213. package/tests/known_failing_ratchet.mjs +89 -0
  214. package/tests/lifecycle-card.mjs +2 -1
  215. package/tests/main_audit.mjs +72 -1
  216. package/tests/memory_isolation.mjs +56 -9
  217. package/tests/migrate_root_resolution.mjs +124 -1
  218. package/tests/mirror_redact.mjs +19 -0
  219. package/tests/module_api.mjs +3 -1
  220. package/tests/module_loader.mjs +4 -0
  221. package/tests/modules.mjs +35 -7
  222. package/tests/modules_route.mjs +29 -5
  223. package/tests/onboarding_observability.mjs +49 -11
  224. package/tests/onboarding_route_signals.mjs +1 -0
  225. package/tests/one_active_claim_per_session.mjs +80 -0
  226. package/tests/pre_compact.mjs +2 -1
  227. package/tests/preview_server.mjs +2 -1
  228. package/tests/profile_route.mjs +1 -0
  229. package/tests/provision.mjs +170 -0
  230. package/tests/provisioning_onboard_mode.mjs +180 -0
  231. package/tests/publish_manifest.mjs +16 -3
  232. package/tests/publish_reconciler.mjs +181 -10
  233. package/tests/rank_tier_single_source.mjs +205 -0
  234. package/tests/recall_hints.mjs +2 -1
  235. package/tests/repo_map.mjs +4 -0
  236. package/tests/route_permission_check.mjs +14 -5
  237. package/tests/rubric_fixtures.mjs +3 -0
  238. package/tests/run_routine_two_root.mjs +2 -0
  239. package/tests/sandbox_stage.mjs +69 -7
  240. package/tests/search_ingest.mjs +7 -0
  241. package/tests/security_docs.mjs +28 -6
  242. package/tests/sequence.mjs +64 -0
  243. package/tests/session-cards.mjs +2 -1
  244. package/tests/session_start_freshness.mjs +2 -1
  245. package/tests/ship_broadcast_server.mjs +63 -1
  246. package/tests/ship_card.mjs +66 -0
  247. package/tests/ship_ci_deploy.mjs +66 -1
  248. package/tests/ship_smoke_gate.mjs +76 -0
  249. package/tests/versions_route.mjs +15 -0
  250. package/tests/workflow_exec_paths.mjs +16 -4
  251. package/tests/worktree_guard.mjs +2 -1
@@ -315,6 +315,10 @@ test('the demo proof module mounts + serves through the loader (BV1.R52 / task 1
315
315
  // BV1.R52 acceptance: the net-new scaffolded module is discovered, is enabled
316
316
  // by config/modules.json, and its route factory builds a real router with the
317
317
  // health read route + the rank-gated ping write route — all with NO core edit.
318
+ // modules/demo/ is content-excluded from the published core (ADR 0098), exactly as
319
+ // the roster comment above already notes — so on a core checkout there is nothing
320
+ // to mount and this acceptance proof does not apply (task 1002470).
321
+ if (!fs.existsSync(path.join(ROOT, 'modules', 'demo'))) return;
318
322
  loader._reset();
319
323
  const { isModuleEnabled } = require(path.join(ROOT, 'src', 'modules.js'));
320
324
  assert.equal(isModuleEnabled('demo'), true, 'config/modules.json enables demo');
package/tests/modules.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // tests/modules.mjs — the feature-module selection resolver (task 1193 / V4.R54, ADR 0062 §4).
2
2
  import assert from 'node:assert/strict';
3
3
  import { test } from 'node:test';
4
+ import { existsSync } from 'node:fs';
4
5
  import modulesMod from '../src/modules.js';
5
6
 
6
7
  const {
@@ -17,6 +18,14 @@ const OTB = { modules: { game: true, 'art-pipeline': true, discord: true, 'dev-b
17
18
  // loadModules() does in production. Membership across the move is preserved here.
18
19
  const EFFECTIVE = modulesMod.effectiveRegistry();
19
20
 
21
+ // Sample feature-module keys for the PURE resolver tests below. `game` and
22
+ // `art-pipeline` left the core at the CB→OTB split, so the effective registry no
23
+ // longer knows them and passing them made resolveModules throw `cannot enable
24
+ // unknown module` — the tests were asserting a topology, not the resolver (task
25
+ // 1002470). Synthesized here so the precedence/merge/freeze semantics stay under
26
+ // test on any instance, whatever it happens to have installed.
27
+ const SAMPLE_REG = { ...EFFECTIVE, game: { default: false }, 'art-pipeline': { default: false } };
28
+
20
29
  test('vanilla (no config) resolves every module OFF — fail-to-vanilla, never fail-to-OTB', () => {
21
30
  const m = resolveModules({});
22
31
  for (const k of KNOWN_MODULES) assert.equal(m[k], false, `${k} should default off`);
@@ -28,7 +37,7 @@ test('every registry module has an explicit default of false', () => {
28
37
  });
29
38
 
30
39
  test('instance config turns modules on', () => {
31
- const m = resolveModules({ neutral: VANILLA, instance: OTB, registry: EFFECTIVE });
40
+ const m = resolveModules({ neutral: VANILLA, instance: OTB, registry: SAMPLE_REG });
32
41
  assert.equal(m.game, true);
33
42
  assert.equal(m['dev-box'], true);
34
43
  assert.equal(isModuleEnabled('discord', m), true);
@@ -41,19 +50,31 @@ test('instance config turns modules on', () => {
41
50
  // gate + builder economy + idea inbox + red-team intake + prefs + onboarding +
42
51
  // session corpus + the MAS autonomy machinery + the status dashboard + the
43
52
  // builders' hall ship to every instance — see each module.json).
44
- assert.deepEqual(enabledModules(m).sort(), ['art-pipeline', 'autonomy', 'builder-settings', 'dev-box', 'discord', 'economy', 'game', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'security', 'sessions', 'status-ui']);
53
+ // Asserted as a PROPERTY rather than a frozen 16-key list (which named modules the
54
+ // core no longer carries — task 1002470).
55
+ // Everything the instance switched on resolves on...
56
+ for (const k of Object.keys(OTB.modules)) assert.equal(m[k], true, `instance-enabled '${k}' must resolve on`);
57
+ // ...every `default: true` core-domain module resolves on WITHOUT being listed...
58
+ const coreDefaults = Object.keys(SAMPLE_REG).filter((k) => SAMPLE_REG[k].default);
59
+ assert.ok(coreDefaults.length >= 5, `expected a real core-domain roster, found ${coreDefaults.length}`);
60
+ for (const k of coreDefaults) assert.equal(m[k], true, `default-on core module '${k}' must resolve on unlisted`);
61
+ // ...and a registry key that is neither listed nor default-on stays off.
62
+ for (const k of Object.keys(SAMPLE_REG)) {
63
+ if (SAMPLE_REG[k].default || k in OTB.modules) continue;
64
+ assert.equal(m[k], false, `unlisted opt-in module '${k}' must stay off`);
65
+ }
45
66
  });
46
67
 
47
68
  test('instance config overrides the neutral starter per-flag (deep merge of the modules map)', () => {
48
69
  const neutral = { modules: { game: false, discord: false, 'dev-box': false, 'art-pipeline': false } };
49
70
  const instance = { modules: { discord: true } }; // only flip one
50
- const m = resolveModules({ neutral, instance, registry: EFFECTIVE });
71
+ const m = resolveModules({ neutral, instance, registry: SAMPLE_REG });
51
72
  assert.equal(m.discord, true);
52
73
  assert.equal(m.game, false); // neutral value survives
53
74
  });
54
75
 
55
76
  test('env override beats instance config (precedence), with prefix + snake-casing', () => {
56
- const m = resolveModules({ instance: OTB, env: { OTB_MODULE_DEV_BOX: '0' }, envPrefix: 'OTB', registry: EFFECTIVE });
77
+ const m = resolveModules({ instance: OTB, env: { OTB_MODULE_DEV_BOX: '0' }, envPrefix: 'OTB', registry: SAMPLE_REG });
57
78
  assert.equal(m['dev-box'], false); // env off beat instance on
58
79
  assert.equal(m.game, true); // others untouched
59
80
  });
@@ -111,19 +132,23 @@ test('disciplines: vanilla offers engineer + ideator (core); art-pipeline adds a
111
132
  // instance), so it is offered with no modules on; only 'artist' is module-gated.
112
133
  // art-pipeline is now a DISCOVERED module (BV1.R48), so pass EFFECTIVE registry.
113
134
  assert.deepEqual(enabledDisciplines(resolveModules({})), ['engineer', 'ideator']);
114
- const m = resolveModules({ instance: { modules: { 'art-pipeline': true } }, registry: EFFECTIVE });
135
+ // The 'artist' leg reads art-pipeline's OWN module.json contributes, so it only
136
+ // means anything where that module is installed (task 1002470). The core half
137
+ // above — ideation ships to every instance — is asserted unconditionally.
138
+ if (!EFFECTIVE['art-pipeline']) return;
139
+ const m = resolveModules({ instance: { modules: { 'art-pipeline': true } }, registry: SAMPLE_REG });
115
140
  assert.deepEqual(enabledDisciplines(m), ['engineer', 'ideator', 'artist']);
116
141
  });
117
142
 
118
143
  test('clientModules is a safe boolean projection covering every known module + disciplines', () => {
119
- const c = clientModules(resolveModules({ instance: OTB, registry: EFFECTIVE }));
144
+ const c = clientModules(resolveModules({ instance: OTB, registry: SAMPLE_REG }));
120
145
  for (const k of KNOWN_MODULES) assert.equal(typeof c.enabled[k], 'boolean');
121
146
  assert.ok(Array.isArray(c.disciplines));
122
147
  assert.ok(c.disciplines.includes('engineer'));
123
148
  });
124
149
 
125
150
  test('the resolved set is frozen (callers cannot mutate the singleton)', () => {
126
- const m = resolveModules({ instance: OTB, registry: EFFECTIVE });
151
+ const m = resolveModules({ instance: OTB, registry: SAMPLE_REG });
127
152
  assert.throws(() => { m.game = false; }, TypeError);
128
153
  });
129
154
 
@@ -131,6 +156,9 @@ test('the committed config files resolve: OTB enables all four, on disk', () =>
131
156
  // loadModules reads config/modules.json over config/modules.neutral.json.
132
157
  // No GDS_BRANDING_FILE → still the default OTB instance pack (regression guard
133
158
  // for the GDS_MODULES_FILE selector below).
159
+ // config/modules.json is INSTANCE content — the neutral core ships only
160
+ // config/modules.neutral.json, so there is no instance pack to assert (task 1002470).
161
+ if (!existsSync(new URL('../config/modules.json', import.meta.url))) return;
134
162
  const m = loadModules({ env: {} });
135
163
  assert.equal(m.game, true);
136
164
  assert.equal(m['art-pipeline'], true);
@@ -7,14 +7,21 @@ import { test } from 'node:test';
7
7
  import { createRequire } from 'node:module';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { onDiskModuleKeys } from './helpers.mjs';
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
14
  const { listModules } = require(path.join(ROOT, 'src', 'bongos', 'routes', 'modules.js'));
14
15
 
16
+ // The roster is compared against a direct disk read (helpers.mjs onDiskModuleKeys) rather
17
+ // than a hardcoded floor — that floor (>=18) went stale when modules left the core at the
18
+ // CB→OTB split, turning a real exhaustiveness check into a census (task 1002470).
19
+
15
20
  test('listModules: every on-disk module is present, sorted, and well-formed', () => {
16
21
  const rows = listModules();
17
- assert.ok(rows.length >= 18, `expected every on-disk module (got ${rows.length})`);
22
+ const expected = onDiskModuleKeys();
23
+ assert.ok(expected.length >= 5, `expected a real on-disk module roster, found ${expected.length}`);
24
+ assert.deepEqual(rows.map((m) => m.key).sort(), expected, 'listModules must return exactly the on-disk modules');
18
25
  for (const m of rows) {
19
26
  assert.equal(typeof m.key, 'string');
20
27
  assert.ok(['own', 'core'].includes(m.provenance), `${m.key}: provenance is own|core`);
@@ -24,15 +31,32 @@ test('listModules: every on-disk module is present, sorted, and well-formed', ()
24
31
  assert.deepEqual(keys, [...keys].sort(), 'listModules is sorted by key');
25
32
  });
26
33
 
27
- test('listModules: today, every module is own-built (ADR 0107 audit — no module names a foreign origin yet)', () => {
34
+ // This used to assert a CENSUS ("today, every module is own-built") that was true only
35
+ // while the instance was OTB. On a neutral core, provisioning + platform-identity carry
36
+ // an origin naming another project and correctly classify 'core' — the census was stale,
37
+ // not the code (task 1002470). Assert the ADR 0107 §9 RULE instead, which holds on every
38
+ // instance: no origin => own, and an origin naming this project => own.
39
+ test('listModules: provenance follows the ADR 0107 §9 rule (no origin, or a self-naming origin, is own-built)', () => {
28
40
  const rows = listModules();
29
- for (const m of rows) assert.equal(m.provenance, 'own', `${m.key} unexpectedly classifies core-loaded`);
41
+ let withOrigin = 0;
42
+ for (const m of rows) {
43
+ if (!m.origin) {
44
+ assert.equal(m.provenance, 'own', `${m.key} declares no origin, so it must classify own-built`);
45
+ continue;
46
+ }
47
+ withOrigin += 1;
48
+ assert.ok(['own', 'core'].includes(m.provenance), `${m.key}: provenance must be own|core`);
49
+ }
50
+ // Non-vacuity: the no-origin branch must actually have been exercised.
51
+ assert.ok(rows.length > withOrigin, 'expected at least one module with no declared origin');
30
52
  });
31
53
 
32
- test('listModules: game module is on-disk, enabled in the OTB instance config, and carries no origin', () => {
54
+ test('listModules: an installed game module is enabled and carries no origin', () => {
33
55
  const rows = listModules();
34
56
  const game = rows.find((m) => m.key === 'game');
35
- assert.ok(game, 'game module discovered');
57
+ // modules/game/ left the core at the CB→OTB split; where it IS installed the
58
+ // original contract still holds (task 1002470).
59
+ if (!game) return;
36
60
  assert.equal(game.enabled, true);
37
61
  assert.equal(game.origin, null);
38
62
  });
@@ -43,6 +43,12 @@ async function t(name, fn) {
43
43
  // src/bongos/routes, so we patch the resolved module before requiring the
44
44
  // routes.
45
45
  const authPath = require.resolve('../src/bongos/auth.js');
46
+ const catalog = require('../modules/governance/catalog.js');
47
+ // Mirrors the canonical ladder in modules/governance/catalog.js — omitting a rung
48
+ // would resolve its floor to Infinity and deny everyone, including archon. Kept at
49
+ // module scope (BV1.R104) so requireRank and requirePermission below share ONE
50
+ // ladder; R105's branch still had the stale 3-rung inline copy that dropped thetes.
51
+ const RANK_ORDER = { xenos: 1, thetes: 2, metic: 3, archon: 4 };
46
52
  const stubAuth = {
47
53
  requireBuilder: (req, _res, next) => {
48
54
  // The test harness sets req._stub_builder beforehand.
@@ -52,8 +58,7 @@ const stubAuth = {
52
58
  },
53
59
  requireRank: (rank) => (req, res, next) => {
54
60
  if (!req.builder) return res.status(401).json({ error: 'unauth' });
55
- const order = { xenos: 1, metic: 2, archon: 3 };
56
- if ((order[req.builder.rank] || 0) < (order[rank] || 0)) {
61
+ if ((RANK_ORDER[req.builder.rank] || 0) < (RANK_ORDER[rank] || 0)) {
57
62
  return res.status(403).json({
58
63
  error: 'rank_forbidden',
59
64
  required: rank,
@@ -62,6 +67,28 @@ const stubAuth = {
62
67
  }
63
68
  next();
64
69
  },
70
+ // BV1.R104/R105 migrated these gates to requirePermission — core routes in R104,
71
+ // module routes (here: POST /claims/:id/release-on-behalf) in R105. Resolve each
72
+ // atom's floor from the REAL catalog and apply the same threshold as the rank stub
73
+ // above, so the stub admits exactly who the requireRank spelling did — the seeded
74
+ // rank-roles make the two gates equivalent (ADR 0151). An unknown atom denies
75
+ // (Infinity) — fail-closed, matching the live gate; a stub that admitted on an
76
+ // unknown key would hide precisely the typo this suite exists to catch.
77
+ requirePermission: (...perms) => (req, res, next) => {
78
+ if (!req.builder) return res.status(401).json({ error: 'unauth' });
79
+ const floor = perms.reduce(
80
+ (max, key) => Math.max(max, RANK_ORDER[catalog.byKey(key)?.floor] || Infinity),
81
+ 0,
82
+ );
83
+ if ((RANK_ORDER[req.builder.rank] || 0) < floor) {
84
+ return res.status(403).json({
85
+ error: 'permission_forbidden',
86
+ required: perms,
87
+ held: [],
88
+ });
89
+ }
90
+ next();
91
+ },
65
92
  };
66
93
  require.cache[authPath] = {
67
94
  id: authPath,
@@ -190,23 +217,28 @@ function send({ port, method, path, body, headers = {} }) {
190
217
 
191
218
  const { server, port } = await listen(buildApp());
192
219
 
193
- await t('GET /builders/roster as xenos → 403 rank_forbidden', async () => {
220
+ // BV1.R104 migrated this gate to requirePermission('builder.roster.read') — an
221
+ // archon-floored atom, so the SAME callers are refused; only the 403 code and the
222
+ // `required` shape changed (permission_forbidden + the atom list, not a rank name).
223
+ await t('GET /builders/roster as xenos → 403 permission_forbidden', async () => {
194
224
  const r = await send({
195
225
  port, method: 'GET', path: '/api/gds/builders/roster',
196
226
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'xenos' },
197
227
  });
198
228
  assert.equal(r.status, 403);
199
- assert.equal(r.json?.error, 'rank_forbidden');
200
- assert.equal(r.json?.required, 'archon');
229
+ assert.equal(r.json?.error, 'permission_forbidden');
230
+ assert.deepEqual(r.json?.required, ['builder.roster.read']);
201
231
  });
202
232
 
203
- await t('GET /builders/roster as metic → 403 rank_forbidden', async () => {
233
+ // ADR 0157 lowered `builder.roster.read` archon→metic: reading who is on the roster is
234
+ // administration, not authority over who holds authority. The refusal assertion that
235
+ // still guards the surface is the sub-Metic one above (thetes/xenos → 403).
236
+ await t('GET /builders/roster as metic → admitted (ADR 0157)', async () => {
204
237
  const r = await send({
205
238
  port, method: 'GET', path: '/api/gds/builders/roster',
206
239
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'metic' },
207
240
  });
208
- assert.equal(r.status, 403);
209
- assert.equal(r.json?.required, 'archon');
241
+ assert.notEqual(r.status, 403, 'a metic now holds builder.roster.read');
210
242
  });
211
243
 
212
244
  await t('GET /builders/roster as archon → 200', async () => {
@@ -279,16 +311,22 @@ await t('POST /claims/:id/release-on-behalf as xenos → 403', async () => {
279
311
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'xenos' },
280
312
  });
281
313
  assert.equal(r.status, 403);
282
- assert.equal(r.json?.error, 'rank_forbidden');
314
+ // BV1.R105: the route now gates on `claim.release.any`, so the denial code is
315
+ // requirePermission's `permission_forbidden`. The AUDIENCE is unchanged (the atom
316
+ // floors at archon) — only the spelling of the refusal moved. This is the R97
317
+ // 403-contract change reaching its first module route.
318
+ assert.equal(r.json?.error, 'permission_forbidden');
283
319
  });
284
320
 
285
- await t('POST /claims/:id/release-on-behalf as metic → 403', async () => {
321
+ // ADR 0157 lowered `claim.release.any` archon→metic: freeing another builder's stale
322
+ // claim is queue housekeeping, not an authority grant.
323
+ await t('POST /claims/:id/release-on-behalf as metic → admitted (ADR 0157)', async () => {
286
324
  const r = await send({
287
325
  port, method: 'POST', path: '/api/gds/claims/123/release-on-behalf',
288
326
  body: { reason: 'stale' },
289
327
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'metic' },
290
328
  });
291
- assert.equal(r.status, 403);
329
+ assert.notEqual(r.status, 403, 'a metic now holds claim.release.any');
292
330
  });
293
331
 
294
332
  await t('POST /claims/:id/release-on-behalf w/o reason → 400 validation_failed', async () => {
@@ -41,6 +41,7 @@ require.cache[authPath] = {
41
41
  exports: {
42
42
  requireBuilder: (_req, _res, next) => next(),
43
43
  requireRank: () => (_req, _res, next) => next(),
44
+ requirePermission: () => (_req, _res, next) => next(),
44
45
  },
45
46
  };
46
47
 
@@ -183,5 +183,85 @@ t('names the in-flight task in the MATCHING worktree, not the first row', () =>
183
183
  assert.equal(d.inFlightTitle, 'Two');
184
184
  });
185
185
 
186
+ // =========================================================================
187
+ // task 1002589 (idea 1000638) — the DB backstop's ERROR SURFACE. A claims
188
+ // INSERT can trip TWO partial unique indexes; claimTask used to map every pg
189
+ // 23505 to ALREADY_CLAIMED, so a trip of migration 180's per-session index
190
+ // read as "task taken by another session" while the task sat 'ready'.
191
+ // claimUniqueViolationError branches on err.constraint: the per-task index
192
+ // keeps ALREADY_CLAIMED, the per-session index gets its own honest code, and
193
+ // an UNKNOWN constraint fails CLOSED on ALREADY_CLAIMED (never widen).
194
+ // =========================================================================
195
+ const { claimUniqueViolationError } = require('../modules/lifecycle/db.js');
196
+
197
+ t('23505 on uniq_active_claim_per_task → ALREADY_CLAIMED (race lost, unchanged)', () => {
198
+ const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_task' });
199
+ assert.equal(e.code, 'ALREADY_CLAIMED');
200
+ });
201
+
202
+ t('23505 on uniq_active_claim_per_session → session_already_has_bound_claim', () => {
203
+ const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_session' });
204
+ assert.equal(e.code, 'session_already_has_bound_claim');
205
+ assert.match(e.what_to_do, /builder-ship|builder-release|[Ss]hip or release/,
206
+ 'what_to_do should name the ship-or-release way out');
207
+ assert.match(e.what_to_do, /bind_session/,
208
+ 'what_to_do should name the bind_session:false escape hatch');
209
+ });
210
+
211
+ // task 1002570: core_204 re-keys the index per (session, worktree) under a
212
+ // NEW name. Both generations must map to the honest code — a deploy applies
213
+ // code and migration at different instants, and an instance may still carry the
214
+ // migration-180 session-only index.
215
+ t('23505 on uniq_active_claim_per_session_worktree (core_204) → same honest code', () => {
216
+ const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_session_worktree' });
217
+ assert.equal(e.code, 'session_already_has_bound_claim');
218
+ assert.match(e.what_to_do, /worktree/, 'what_to_do should name the separate-worktree way out');
219
+ assert.match(e.what_to_do, /bind_session/);
220
+ });
221
+
222
+ t('23505 with an unknown constraint fails CLOSED → ALREADY_CLAIMED', () => {
223
+ assert.equal(claimUniqueViolationError({ code: '23505', constraint: 'uniq_something_future' }).code, 'ALREADY_CLAIMED');
224
+ assert.equal(claimUniqueViolationError({ code: '23505' }).code, 'ALREADY_CLAIMED');
225
+ assert.equal(claimUniqueViolationError(null).code, 'ALREADY_CLAIMED');
226
+ });
227
+
228
+ // =========================================================================
229
+ // task 1002570 — the core_204 re-key itself. Text tripwires on the
230
+ // migration file so the index can't silently drift from the guard's boundary
231
+ // (the exact mismatch this whole family of bugs came from). DB-free: pins the
232
+ // SQL's shape, not its execution.
233
+ // =========================================================================
234
+ import { readFileSync } from 'node:fs';
235
+ import { fileURLToPath } from 'node:url';
236
+ import { dirname, join } from 'node:path';
237
+
238
+ // Read lazily inside each pin so a missing/renamed file fails THESE tests, not
239
+ // the whole suite file at import time.
240
+ const migSql = () => readFileSync(
241
+ join(dirname(fileURLToPath(import.meta.url)),
242
+ '../migrations/core_204_rekey_active_claim_per_session_worktree.sql'),
243
+ 'utf8');
244
+
245
+ t('core_204 drops the session-only index and creates the per-worktree one', () => {
246
+ assert.match(migSql(), /DROP INDEX IF EXISTS uniq_active_claim_per_session\b/,
247
+ 'must remove the worktree-blind index');
248
+ assert.match(migSql(), /CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_worktree/,
249
+ 'must create the re-keyed index idempotently');
250
+ });
251
+
252
+ t('core_204 folds NULL worktrees so a shared clone still self-collides', () => {
253
+ // In a plain multi-column unique index NULLs are DISTINCT — without the fold,
254
+ // two worktree-less claims would both insert and re-open the task-1642
255
+ // commingling hole. COALESCE is the version-safe fold (no PG15 requirement).
256
+ assert.match(migSql(), /COALESCE\(worktree_name,\s*''\)/i);
257
+ });
258
+
259
+ t('core_204 keeps the 180 predicate (active + bound rows only) and heals first', () => {
260
+ assert.match(migSql(), /WHERE released_at IS NULL AND creator_session_id IS NOT NULL/,
261
+ 'partial predicate must match migration 180 (web claims stay excluded)');
262
+ assert.match(migSql(), /PARTITION BY c\.creator_session_id,\s*COALESCE\(c\.worktree_name,\s*''\)/i,
263
+ 'the pre-CREATE heal must group by the NEW key');
264
+ });
265
+
186
266
  console.log(`\n${passed} passed, ${failed} failed`);
187
267
  process.exit(failed ? 1 : 0);
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { strict as assert } from 'node:assert';
6
6
  import { createRequire } from 'node:module';
7
+ import { requireHostModule } from './helpers.mjs';
7
8
  const require = createRequire(import.meta.url);
8
- const { gateDecision, buildDirective, FLAG } = require('../.claude/hooks/pre-compact.js');
9
+ const { gateDecision, buildDirective, FLAG } = requireHostModule(require, '../.claude/hooks/pre-compact.js');
9
10
 
10
11
  let passed = 0, failed = 0;
11
12
  function test(name, fn) {
@@ -10,12 +10,13 @@
10
10
 
11
11
  import { strict as assert } from 'node:assert';
12
12
  import { createRequire } from 'node:module';
13
+ import { requireHostModule } from './helpers.mjs';
13
14
  import fs from 'node:fs';
14
15
  import os from 'node:os';
15
16
  import path from 'node:path';
16
17
 
17
18
  const require = createRequire(import.meta.url);
18
- const { previewCacheControl, injectLivereload, renderArtGallery } = require('../src/preview-server.js');
19
+ const { previewCacheControl, injectLivereload, renderArtGallery } = requireHostModule(require, '../src/preview-server.js');
19
20
 
20
21
  // A minimal valid 1×1 PNG (transparent), base64-decoded to bytes.
21
22
  const TINY_PNG = Buffer.from(
@@ -83,6 +83,7 @@ require.cache[authPath] = {
83
83
  exports: {
84
84
  requireBuilder: (_req, _res, next) => next(),
85
85
  requireRank: () => (_req, _res, next) => next(),
86
+ requirePermission: () => (_req, _res, next) => next(),
86
87
  },
87
88
  };
88
89
 
@@ -588,6 +588,176 @@ await ta('scaffoldStandaloneRepo: no owner token → falls back to the SSH origi
588
588
  }
589
589
  });
590
590
 
591
+ // ── ADOPT vs GREENFIELD onboarding (ADR 0121, task 1002562) ───────────────────────
592
+ // The runner used to run the greenfield scaffold unconditionally: `git init` a fresh tree
593
+ // and push HEAD:main. Against a repo that already has history that is an unrelated-history
594
+ // non-fast-forward, so adopting an existing repo could never succeed.
595
+
596
+ t('onboardMode: adopt only when the row says so; anything else → greenfield (the SAFE default)', () => {
597
+ assert.equal(P.onboardMode({ onboard_mode: 'adopt' }), 'adopt');
598
+ assert.equal(P.onboardMode({ onboard_mode: 'greenfield' }), 'greenfield');
599
+ // A row written BEFORE provisioning_004 added the column, and any unrecognized value,
600
+ // must resolve to greenfield — which refuses to push over existing history — never to
601
+ // adopt, which would start layering onto a repo nobody declared as pre-existing.
602
+ assert.equal(P.onboardMode({}), 'greenfield');
603
+ assert.equal(P.onboardMode({ onboard_mode: null }), 'greenfield');
604
+ assert.equal(P.onboardMode({ onboard_mode: 'ADOPT' }), 'greenfield');
605
+ assert.equal(P.onboardMode({ onboard_mode: 'something-else' }), 'greenfield');
606
+ assert.equal(P.onboardMode(null), 'greenfield');
607
+ });
608
+
609
+ t('alreadyScaffolded: the marker is mode-dependent — package.json alone does NOT mean an adopt is done', () => {
610
+ const dir = mkdtempSync(join(tmpdir(), 'marker-'));
611
+ try {
612
+ assert.equal(P.alreadyScaffolded(dir, 'greenfield'), false, 'no package.json → not scaffolded');
613
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
614
+
615
+ // An ordinary existing Node project: package.json present, no core dep. Greenfield's
616
+ // marker would call this "done" — for ADOPT that is the bug this guards, because the
617
+ // layering would be skipped on the very first run and the instance never become bootable.
618
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'their-app', dependencies: { react: '^18' } }));
619
+ assert.equal(P.alreadyScaffolded(dir, 'greenfield'), true, 'greenfield: package.json IS the marker (unchanged)');
620
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), false, 'adopt: an existing project is NOT already adopted');
621
+
622
+ // Once the layer has added the core dep, adopt IS done. Both scope aliases count.
623
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { react: '^18', '@bongos/core': 'file:vendor/x.tgz' } }));
624
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), true);
625
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@cloudbongos/core': '^1.0.0' } }));
626
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), true, 'the @cloudbongos alias counts too');
627
+
628
+ // Unparseable → NOT already done, so the adopt layer runs and reports it, rather than
629
+ // silently skipping on a file it could not read.
630
+ writeFileSync(join(dir, 'package.json'), '{ not json');
631
+ assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
632
+ } finally {
633
+ rmSync(dir, { recursive: true, force: true });
634
+ }
635
+ });
636
+
637
+ // Drive the scaffold in a given mode against a fresh base and return the joined argv.
638
+ async function scaffoldInMode(mode, { token = null, seedRoot = null } = {}) {
639
+ const base = mkdtempSync(join(tmpdir(), `mode-${mode}-`));
640
+ const origBase = P.CONFIG.standaloneBase;
641
+ P.CONFIG.standaloneBase = base;
642
+ try {
643
+ if (seedRoot) { mkdirSync(join(base, 'demo'), { recursive: true }); seedRoot(join(base, 'demo')); }
644
+ const { deps, cmds } = recorder({ apply: true });
645
+ const writes = [];
646
+ deps.writeFile = (p, body, opts) => { writes.push({ p, body, opts }); return { ok: true }; };
647
+ if (token) deps.getGithubToken = async () => ({ token, scope: 'read:user,repo' });
648
+ await P.scaffoldStandaloneRepo(
649
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: mode },
650
+ { ...deps, instanceRoot: base },
651
+ );
652
+ return { joined: cmds.join('\n'), cmds, writes, root: join(base, 'demo') };
653
+ } finally {
654
+ P.CONFIG.standaloneBase = origBase;
655
+ rmSync(base, { recursive: true, force: true });
656
+ }
657
+ }
658
+
659
+ await ta('scaffoldStandaloneRepo(adopt): CLONES the existing repo instead of git-init-ing a fresh tree', async () => {
660
+ const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
661
+ assert.match(joined, /clone https:\/\/x-access-token@github\.com\/lars589\/demo\.git/, 'clones over the token URL (covers a PRIVATE repo the shared deploy key cannot read)');
662
+ assert.ok(!/git -C \S+ init -q/.test(joined), 'never git-inits a fresh tree on the adopt path — that is what produced the unrelated history');
663
+ assert.ok(!/checkout -B main/.test(joined), 'does not force-create the branch over the cloned one');
664
+ assert.match(joined, /init\.js --from .* --no-seed --adopt --accept /, 'materializes via init --adopt --accept (layering, accept-gated)');
665
+ // origin must end up as the SSH remote, like greenfield leaves it: the ongoing deploy
666
+ // (`git pull --ff-only origin main`, and the box timer's `git reset --hard origin/main`)
667
+ // reads origin, and the one-shot token URL cannot authenticate once its credential file
668
+ // is deleted.
669
+ assert.match(joined, /git -C \S+ remote set-url origin git@github-deploy:lars589\/demo\.git/, 'normalizes origin to the SSH remote after cloning');
670
+ });
671
+
672
+ // THE SAFETY PROPERTY. Since ADR 0155 this credential can write to EVERY repo the owner
673
+ // owns, so a --force here would be a route to destroying a user's work. A rejected push is
674
+ // the correct, recoverable outcome.
675
+ await ta('scaffoldStandaloneRepo(adopt): NEVER force-pushes and never passes --force to init', async () => {
676
+ const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
677
+ assert.ok(!/--force/.test(joined), 'no --force anywhere in the adopt argv');
678
+ assert.ok(!/ -f\b/.test(joined.replace(/rm -f /g, '')), 'no -f force shorthand (rm -f of the credential file excepted)');
679
+ assert.ok(!/\+refs|\+HEAD|:\+/.test(joined), 'no leading-+ refspec (the other way to force a push)');
680
+ assert.match(joined, /push https:\/\/x-access-token@github\.com\/lars589\/demo\.git HEAD:main/, 'a plain fast-forward push');
681
+ });
682
+
683
+ await ta('scaffoldStandaloneRepo(greenfield): unchanged — git init + --force, no clone', async () => {
684
+ const { joined } = await scaffoldInMode('greenfield', { token: 'gho_TOK' });
685
+ assert.match(joined, /git -C \S+ init -q/, 'still git-inits the empty repo');
686
+ assert.match(joined, /checkout -B main/);
687
+ assert.match(joined, /init\.js --from .* --no-seed --force /, 'greenfield keeps --force (safe on an empty repo)');
688
+ assert.ok(!/--adopt/.test(joined), 'no --adopt on the greenfield path');
689
+ assert.ok(!/ clone /.test(joined), 'nothing to clone from an empty repo');
690
+ });
691
+
692
+ await ta('scaffoldStandaloneRepo(adopt): no owner token → clones over the SSH remote (no credential helper)', async () => {
693
+ const { joined } = await scaffoldInMode('adopt'); // recorder default getGithubToken → null
694
+ assert.match(joined, /clone git@github-deploy:lars589\/demo\.git/, 'falls back to the SSH remote');
695
+ assert.ok(!/credential\.helper=/.test(joined), 'no credential helper without a token');
696
+ assert.match(joined, /push -u origin main/, 'and the SSH origin push');
697
+ });
698
+
699
+ await ta('scaffoldStandaloneRepo(adopt): does NOT clobber an existing .gitignore — appends only what the deploy needs', async () => {
700
+ const { writes } = await scaffoldInMode('adopt', {
701
+ token: 'gho_TOK',
702
+ seedRoot: (root) => writeFileSync(join(root, '.gitignore'), 'their-secrets/\ncoverage/\n'),
703
+ });
704
+ const gi = writes.find((w) => /\.gitignore$/.test(w.p));
705
+ assert.ok(gi, 'wrote .gitignore');
706
+ assert.match(String(gi.body), /their-secrets\//, 'the owner’s existing rules survive');
707
+ assert.match(String(gi.body), /coverage\//);
708
+ assert.match(String(gi.body), /node_modules\//, 'and the entries `npm ci` + the deploy require are added');
709
+ });
710
+
711
+ await ta('scaffoldStandaloneRepo(adopt): a default branch that is not `main` fails LOUDLY, not silently', async () => {
712
+ // The rest of provisioning is main-only (git pull --ff-only origin main; the box deploy
713
+ // does git reset --hard origin/main), so layering onto another branch would push somewhere
714
+ // the deploy never reads — a half-working instance with no error.
715
+ const base = mkdtempSync(join(tmpdir(), 'mode-branch-'));
716
+ const origBase = P.CONFIG.standaloneBase;
717
+ P.CONFIG.standaloneBase = base;
718
+ try {
719
+ const { deps } = recorder({ apply: true });
720
+ deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
721
+ deps.exec = (cmd) => ({ ok: true, stdout: /rev-parse --abbrev-ref HEAD/.test(cmd) ? 'master\n' : '' });
722
+ await assert.rejects(
723
+ () => P.scaffoldStandaloneRepo(
724
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
725
+ { ...deps, instanceRoot: base },
726
+ ),
727
+ /default branch is "master", not "main"/,
728
+ );
729
+ } finally {
730
+ P.CONFIG.standaloneBase = origBase;
731
+ rmSync(base, { recursive: true, force: true });
732
+ }
733
+ });
734
+
735
+ await ta('scaffoldStandaloneRepo(adopt): the credential file is removed even when a git step THROWS', async () => {
736
+ const base = mkdtempSync(join(tmpdir(), 'mode-cred-'));
737
+ const origBase = P.CONFIG.standaloneBase;
738
+ P.CONFIG.standaloneBase = base;
739
+ try {
740
+ const { deps, cmds } = recorder({ apply: true });
741
+ deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
742
+ deps.exec = (cmd) => {
743
+ cmds.push(cmd);
744
+ if (/ push /.test(cmd)) throw new Error('remote rejected: non-fast-forward');
745
+ return { ok: true, stdout: '' };
746
+ };
747
+ await assert.rejects(
748
+ () => P.scaffoldStandaloneRepo(
749
+ { slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
750
+ { ...deps, instanceRoot: base },
751
+ ),
752
+ /non-fast-forward/,
753
+ );
754
+ assert.match(cmds.join('\n'), /rm -f [^\n]*bongos-gitcred-demo/, 'a failed push must not leave the token on disk');
755
+ } finally {
756
+ P.CONFIG.standaloneBase = origBase;
757
+ rmSync(base, { recursive: true, force: true });
758
+ }
759
+ });
760
+
591
761
  await ta('provisionInstance is idempotent: an already-active instance is a no-op', async () => {
592
762
  const { deps, cmds } = recorder();
593
763
  const r = await P.provisionInstance({ ...coTenant, status: 'active' }, deps);