@bongos/core 1.19.1072 → 1.19.1074

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 (297) hide show
  1. package/.bongos-core.json +320 -465
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -60
  5. package/clients/bongos-client/index.cjs +4 -60
  6. package/clients/bongos-client/index.d.ts +5 -90
  7. package/clients/bongos-client/index.mjs +4 -60
  8. package/config/branding.neutral.json +1 -5
  9. package/config/modules.neutral.json +2 -3
  10. package/config/scheduled-routines.json +0 -2
  11. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  12. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  13. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  14. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  15. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +6 -2
  16. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  17. package/docs/adr/README.md +2 -1
  18. package/docs/api/openapi.json +171 -1661
  19. package/docs/api-reference.md +9 -41
  20. package/docs/architecture.md +16 -1
  21. package/docs/branding-contract.md +0 -3
  22. package/docs/copy-inventory.md +454 -479
  23. package/docs/copy-registry.json +746 -978
  24. package/docs/file-map.md +5 -34
  25. package/docs/module-api-changelog.md +4 -0
  26. package/docs/modules-contract.md +13 -1
  27. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  28. package/docs/onboarding/diagrams/README.md +3 -3
  29. package/docs/onboarding/diagrams/assertions.json +2 -22
  30. package/docs/onboarding/primer.md +9 -28
  31. package/docs/page-inventory.json +4 -1
  32. package/docs/page-readings.json +1032 -1077
  33. package/docs/recipes/render-pilot.md +29 -10
  34. package/migrations/core_257_module_store_registry.sql +87 -0
  35. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  36. package/modules/agents/module.json +1 -0
  37. package/modules/autonomy/module.json +1 -0
  38. package/modules/builder-settings/module.json +1 -0
  39. package/modules/builder-settings/render-prefs.js +4 -5
  40. package/modules/copy-desk/docx.js +78 -5
  41. package/modules/copy-desk/module.json +1 -0
  42. package/modules/copy-desk/routes/copy-desk.js +9 -9
  43. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  44. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  45. package/modules/discord/module.json +1 -0
  46. package/modules/economy/module.json +1 -0
  47. package/modules/economy/routes/credits.js +1 -2
  48. package/modules/government/catalog.js +13 -17
  49. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  50. package/modules/government/module.json +1 -0
  51. package/modules/government/protected-surfaces.json +3 -11
  52. package/modules/government/resolver.js +3 -46
  53. package/modules/government/routes/government.js +0 -2
  54. package/modules/government/session-scopes.js +43 -83
  55. package/modules/government/session-scopes.json +8 -18
  56. package/modules/grading/module.json +1 -0
  57. package/modules/hall-ui/module.json +1 -0
  58. package/modules/hall-ui/public/board-lib.js +2 -2
  59. package/modules/hall-ui/public/builders.js +29 -40
  60. package/modules/hall-ui/public/collab.html +1 -1
  61. package/modules/hall-ui/public/collab.js +1 -1
  62. package/modules/hall-ui/public/diagrams.html +3 -3
  63. package/modules/hall-ui/public/dom-utils.js +1 -1
  64. package/modules/hall-ui/public/gate.js +2 -2
  65. package/modules/hall-ui/public/gate.states.json +1 -1
  66. package/modules/hall-ui/public/hall-render.js +43 -79
  67. package/modules/hall-ui/public/index.html +5 -1
  68. package/modules/hall-ui/public/modules.js +1 -0
  69. package/modules/hall-ui/public/oversight.css +5 -5
  70. package/modules/hall-ui/public/palette.js +0 -11
  71. package/modules/hall-ui/public/primer.js +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings-sessions.js +1 -1
  74. package/modules/hall-ui/public/settings.css +0 -1
  75. package/modules/hall-ui/public/settings.html +4 -33
  76. package/modules/hall-ui/public/settings.js +3 -33
  77. package/modules/hall-ui/public/settings.states.json +2 -2
  78. package/modules/hall-ui/public/studio.html +1 -1
  79. package/modules/hall-ui/public/style.css +10 -13
  80. package/modules/hall-ui/public/task.html +4 -1
  81. package/modules/hall-ui/public/task.js +36 -1
  82. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  83. package/modules/hall-ui/public/tweak-editor.css +16 -0
  84. package/modules/hall-ui/public/tweak-editor.html +26 -3
  85. package/modules/hall-ui/public/tweak-editor.js +32 -1
  86. package/modules/hall-ui/public/work.js +1 -1
  87. package/modules/hall-ui/records/tweak-editor.md +1 -0
  88. package/modules/ideas/module.json +1 -0
  89. package/modules/lifecycle/db-claim-reads.js +1 -58
  90. package/modules/lifecycle/db-ship.js +0 -5
  91. package/modules/lifecycle/db.js +0 -4
  92. package/modules/lifecycle/kickoff-checklist.js +0 -4
  93. package/modules/lifecycle/lifecycle.js +0 -8
  94. package/modules/lifecycle/module.json +1 -0
  95. package/modules/lifecycle/routes/tasks.js +5 -7
  96. package/modules/memory/module.json +1 -0
  97. package/modules/npm-release/module.json +1 -0
  98. package/modules/onboarding/module.json +1 -0
  99. package/modules/onboarding/onboarding-state.js +17 -36
  100. package/modules/onboarding/routes/access-requests.js +3 -3
  101. package/modules/platform-identity/module.json +1 -0
  102. package/modules/platform-identity/platform-identity.js +13 -5
  103. package/modules/platform-identity/routes/sso.js +2 -2
  104. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  105. package/modules/provisioning/module.json +1 -0
  106. package/modules/provisioning/provisioning.js +2 -2
  107. package/modules/provisioning/starter-bundles.js +5 -25
  108. package/modules/public-landing/module.json +1 -0
  109. package/modules/public-landing/public/assets/cosmos.css +1 -1
  110. package/modules/public-landing/public/contact.html +1 -1
  111. package/modules/public-landing/public/privacy.html +1 -1
  112. package/modules/public-landing/public/projects.html +101 -32
  113. package/modules/public-landing/public/projects.probes.json +2 -2
  114. package/modules/public-landing/public/projects.states.json +4 -3
  115. package/modules/public-landing/public/terms.html +1 -1
  116. package/modules/security/module.json +1 -0
  117. package/modules/sessions/module.json +1 -0
  118. package/modules/specialities/module.json +1 -0
  119. package/modules/status-ui/module.json +1 -0
  120. package/modules/ui-design/module.json +1 -0
  121. package/package-lock.json +2 -2
  122. package/package.json +1 -1
  123. package/release-notes.json +84 -0
  124. package/scripts/gds/artifact-format.js +203 -0
  125. package/scripts/gds/audit-authorship.sh +1 -13
  126. package/scripts/gds/claim.js +0 -15
  127. package/scripts/gds/cli-lib.js +8 -8
  128. package/scripts/gds/diagram-facts.js +12 -23
  129. package/scripts/gds/do-api.js +13 -80
  130. package/scripts/gds/doc-cli-guard.js +2 -0
  131. package/scripts/gds/doctor.js +1 -1
  132. package/scripts/gds/feedback-latest.js +139 -9
  133. package/scripts/gds/fitness-checks-identity.js +2 -7
  134. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  135. package/scripts/gds/fitness-ratchets.js +4 -4
  136. package/scripts/gds/fitness.js +10 -10
  137. package/scripts/gds/gen-diagrams.js +49 -71
  138. package/scripts/gds/http-api-client.js +12 -19
  139. package/scripts/gds/init.js +1 -1
  140. package/scripts/gds/lint-infra-exec.js +1 -1
  141. package/scripts/gds/local-preview-lib.js +8 -39
  142. package/scripts/gds/local-preview.js +2 -15
  143. package/scripts/gds/migration-namespace.js +3 -3
  144. package/scripts/gds/module-artifact.js +212 -0
  145. package/scripts/gds/module.js +95 -6
  146. package/scripts/gds/package-core.js +5 -116
  147. package/scripts/gds/provision-config.js +2 -2
  148. package/scripts/gds/provision-net.js +2 -3
  149. package/scripts/gds/provision.js +9 -9
  150. package/scripts/gds/publish-manifest.js +1 -2
  151. package/scripts/gds/regen-instance-docs.js +1 -1
  152. package/scripts/gds/run-unit-tests.js +2 -16
  153. package/scripts/gds/sandbox-stage.js +39 -341
  154. package/scripts/gds/ship-flow.js +6 -6
  155. package/scripts/gds/ship-land.js +3 -4
  156. package/scripts/gds/ship-regen.js +10 -41
  157. package/scripts/gds/ship.js +4 -24
  158. package/scripts/gds/start.js +0 -15
  159. package/scripts/hall-preview/README.md +5 -5
  160. package/scripts/hall-preview/server.js +6 -6
  161. package/scripts/render-diagrams.sh +1 -1
  162. package/src/bongos/api-errors.js +1 -1
  163. package/src/bongos/auth-github.js +2 -3
  164. package/src/bongos/auth.js +51 -126
  165. package/src/bongos/db-kernel.js +2 -3
  166. package/src/bongos/db.js +5 -28
  167. package/src/bongos/module-scope-map.js +14 -29
  168. package/src/bongos/module-store.js +141 -0
  169. package/src/bongos/route-rank-check.js +3 -16
  170. package/src/bongos/routes/auth.js +0 -116
  171. package/src/bongos/routes/builders.js +5 -15
  172. package/src/bongos/routes/instance.js +8 -14
  173. package/src/bongos/routes/me.js +6 -22
  174. package/src/bongos/routes/modules.js +83 -0
  175. package/src/bongos/routes.js +5 -0
  176. package/src/module-api.js +13 -6
  177. package/src/module-loader/catalog.js +3 -0
  178. package/src/module-loader/loader.js +4 -4
  179. package/src/module-loader/manifest-schema.js +15 -7
  180. package/src/module-seams.js +2 -3
  181. package/src/modules.js +42 -14
  182. package/tests/agents_spend_guard.mjs +1 -1
  183. package/tests/api_client.mjs +0 -15
  184. package/tests/auth_page_gate.mjs +10 -10
  185. package/tests/canonical_profile_url.mjs +1 -3
  186. package/tests/claim_action.mjs +4 -1
  187. package/tests/claim_from_task_and_home.mjs +127 -0
  188. package/tests/consumer_layout_boot.mjs +2 -0
  189. package/tests/copy_desk_page_docx.mjs +33 -2
  190. package/tests/core_upgrade_runner.mjs +6 -6
  191. package/tests/deploy_divergence_line.mjs +11 -11
  192. package/tests/diagram_facts_offset.mjs +1 -1
  193. package/tests/do_api.mjs +106 -0
  194. package/tests/feedback_latest.mjs +122 -0
  195. package/tests/fitness.mjs +44 -42
  196. package/tests/gate_approvals.mjs +0 -1
  197. package/tests/go_live.mjs +3 -3
  198. package/tests/government_abuse_matrix.mjs +20 -19
  199. package/tests/government_ownership_scope.mjs +33 -35
  200. package/tests/government_parity.mjs +2 -2
  201. package/tests/government_protected_surfaces.mjs +56 -37
  202. package/tests/government_require_permission.mjs +3 -3
  203. package/tests/government_seed.mjs +37 -4
  204. package/tests/government_session_scope.mjs +92 -366
  205. package/tests/hall_error_envelope.mjs +4 -0
  206. package/tests/hall_landing_boot.mjs +4 -1
  207. package/tests/hall_palette.mjs +1 -16
  208. package/tests/hall_record_world.mjs +6 -0
  209. package/tests/hall_tweak_editor.mjs +52 -1
  210. package/tests/host_topology_skips.mjs +1 -2
  211. package/tests/html_comment_nesting.mjs +137 -0
  212. package/tests/infra_exec_paths.mjs +9 -10
  213. package/tests/init.mjs +4 -5
  214. package/tests/instance_manifest.mjs +18 -25
  215. package/tests/kickoff_checklist.mjs +1 -1
  216. package/tests/lib_sh_resolution.mjs +0 -221
  217. package/tests/local_preview.mjs +0 -12
  218. package/tests/module-scope-map.mjs +9 -9
  219. package/tests/module_catalog.mjs +1 -1
  220. package/tests/module_cli.mjs +15 -15
  221. package/tests/module_contributions.mjs +6 -6
  222. package/tests/module_loader.mjs +6 -6
  223. package/tests/module_manifest.mjs +13 -4
  224. package/tests/module_store_publish.mjs +307 -0
  225. package/tests/module_store_publish_route.mjs +121 -0
  226. package/tests/module_store_registry_migration.mjs +70 -0
  227. package/tests/modules.mjs +56 -26
  228. package/tests/onboarding_route_signals.mjs +21 -31
  229. package/tests/onboarding_state.mjs +35 -49
  230. package/tests/permission_path.mjs +0 -3
  231. package/tests/platform_boot.mjs +1 -2
  232. package/tests/profile_route.mjs +1 -6
  233. package/tests/projects_hub.mjs +5 -5
  234. package/tests/projects_hub_app_step.mjs +138 -0
  235. package/tests/projects_hub_module_picker.mjs +2 -2
  236. package/tests/projects_hub_pre_uat.mjs +4 -5
  237. package/tests/provision.mjs +7 -7
  238. package/tests/provisioning_starter_bundles.mjs +0 -22
  239. package/tests/publish_branch_route.mjs +10 -18
  240. package/tests/publish_manifest.mjs +1 -2
  241. package/tests/rank_tier_single_source.mjs +1 -13
  242. package/tests/repo_map.mjs +3 -4
  243. package/tests/runner_drift.mjs +3 -21
  244. package/tests/sandbox_stage.mjs +57 -390
  245. package/tests/session_rename_fallback.mjs +0 -21
  246. package/tests/ship_error_shape.mjs +1 -1
  247. package/tests/task_visual_slots.mjs +31 -0
  248. package/tests/terms_acceptance.mjs +1 -1
  249. package/tests/upgrade.mjs +4 -4
  250. package/tests/watch_sealed_floor.mjs +0 -1
  251. package/tests/wizard_draft_resume.mjs +2 -2
  252. package/tests/wizard_intent_resume.mjs +21 -10
  253. package/tests/wizard_preselect_why.mjs +18 -18
  254. package/docs/onboarding/browser-terminal-guide.md +0 -73
  255. package/docs/recipes/managed-settings-remote-control.md +0 -416
  256. package/modules/dev-box/CLAUDE.md +0 -15
  257. package/modules/dev-box/box-access.js +0 -623
  258. package/modules/dev-box/box-credential.js +0 -94
  259. package/modules/dev-box/box-onboard.js +0 -486
  260. package/modules/dev-box/boxes.js +0 -1038
  261. package/modules/dev-box/db.js +0 -39
  262. package/modules/dev-box/module.json +0 -19
  263. package/modules/dev-box/routes/box.js +0 -1223
  264. package/scripts/gds/box-auth-check.js +0 -160
  265. package/scripts/gds/box-infra.js +0 -321
  266. package/scripts/gds/box-sync.js +0 -216
  267. package/scripts/gds/box.js +0 -1332
  268. package/scripts/gds/cf-tunnel.js +0 -558
  269. package/scripts/gds/smoke-box.sh +0 -150
  270. package/scripts/gds/tree-preflight.js +0 -241
  271. package/src/bongos/app-pair.js +0 -172
  272. package/tests/app_pair.mjs +0 -168
  273. package/tests/box_access.mjs +0 -578
  274. package/tests/box_auth_check.mjs +0 -114
  275. package/tests/box_code_staleness.mjs +0 -129
  276. package/tests/box_connect_e2e.mjs +0 -196
  277. package/tests/box_cost.mjs +0 -70
  278. package/tests/box_credential.mjs +0 -125
  279. package/tests/box_dns_repoint.mjs +0 -153
  280. package/tests/box_env.mjs +0 -64
  281. package/tests/box_host_keys.mjs +0 -126
  282. package/tests/box_infra_resolve.mjs +0 -246
  283. package/tests/box_onboard.mjs +0 -317
  284. package/tests/box_scope_session.mjs +0 -119
  285. package/tests/box_sweep_docs.mjs +0 -119
  286. package/tests/box_sync.mjs +0 -55
  287. package/tests/box_sync_scope_report.mjs +0 -126
  288. package/tests/box_task_scope.mjs +0 -169
  289. package/tests/box_task_scope_pause.mjs +0 -66
  290. package/tests/box_terminal_ssrf.mjs +0 -96
  291. package/tests/boxes.mjs +0 -1585
  292. package/tests/cf_ruleset_preflight.mjs +0 -142
  293. package/tests/cf_tunnel.mjs +0 -496
  294. package/tests/docs_scrubber_damage.mjs +0 -111
  295. package/tests/module_scope_active_claims.mjs +0 -94
  296. package/tests/ship_api_client_pathspec.mjs +0 -100
  297. package/tests/tree_preflight.mjs +0 -243
@@ -1,13 +1,12 @@
1
1
  // tests/sandbox_stage.mjs
2
2
  //
3
3
  // Hermetic unit smoke for scripts/gds/sandbox-stage.js — the sandbox-first
4
- // review step (#927, ADR 0046) that ship.js runs before resolving a claim, on a
5
- // dev box (ADR 0044) OR a local full clone (task 1056). Covers: box detection,
6
- // BOX_PREVIEW_HOSTNAME parsing, previewable-surface matching, the decideShipGate
7
- // decision table, resolvePreviewContext (box/local/none), marker round-trip +
8
- // tree-stamp freshness (real temp git repo), the runShipGate composition with
9
- // injected fs/exec/prompt, and the recorded self-approval (#1048) for box AND
10
- // local. NO network, NO systemd, NO real box.
4
+ // review step (#927, ADR 0046) that ship.js runs before resolving a claim on a
5
+ // local full clone (task 1056). Covers: previewable-surface matching, the
6
+ // decideShipGate decision table, resolvePreviewContext (local/none), marker
7
+ // round-trip + tree-stamp freshness (real temp git repo), the runShipGate
8
+ // composition with an injected preview context + exec/prompt, and the recorded
9
+ // self-approval (#1048). NO network, NO spawned preview.
11
10
  //
12
11
  // Run: node tests/sandbox_stage.mjs
13
12
 
@@ -32,35 +31,12 @@ async function ta(name, fn) {
32
31
  catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
33
32
  }
34
33
 
35
- // ---------- isDevBox ----------
36
- t('isDevBox: true only when box.env exists', () => {
37
- assert.equal(sbx.isDevBox({ fs: { existsSync: (p) => p === '/etc/otb/box.env' } }), true);
38
- assert.equal(sbx.isDevBox({ fs: { existsSync: () => false } }), false);
39
- });
40
-
41
- // ---------- readPreviewHostname ----------
42
- t('readPreviewHostname: plain, quoted, export-prefixed, missing, empty', () => {
43
- assert.equal(sbx.readPreviewHostname('BOX_PREVIEW_HOSTNAME=sandbox-somebuilder.example.com\n'), 'sandbox-somebuilder.example.com');
44
- assert.equal(sbx.readPreviewHostname('A=1\nBOX_PREVIEW_HOSTNAME="sandbox-x.example.com"\nB=2'), 'sandbox-x.example.com');
45
- assert.equal(sbx.readPreviewHostname("export BOX_PREVIEW_HOSTNAME='sandbox-y.example.com'"), 'sandbox-y.example.com');
46
- assert.equal(sbx.readPreviewHostname('BOX_TERMINAL_HOSTNAME=term-x.example.com'), null);
47
- assert.equal(sbx.readPreviewHostname('BOX_PREVIEW_HOSTNAME='), null);
48
- assert.equal(sbx.readPreviewHostname(''), null);
49
- assert.equal(sbx.readPreviewHostname(null), null);
50
- });
51
-
52
- t('previewUrl: reads box.env via injected fs; null when unreadable', () => {
53
- const deps = { fs: { readFileSync: () => 'BOX_PREVIEW_HOSTNAME=sandbox-z.example.com\n' } };
54
- assert.equal(sbx.previewUrl(deps), 'https://sandbox-z.example.com');
55
- assert.equal(sbx.previewUrl({ fs: { readFileSync: () => { throw new Error('ENOENT'); } } }), null);
56
- });
57
-
58
34
  // ---------- previewable surfaces ----------
59
35
  t('isPreviewableFile: game surfaces yes, internal surfaces no', () => {
60
36
  for (const f of ['modules/game/public/game/main.js', 'modules/game/world/terrain.js', 'modules/game/rooms/WorldRoom.js', 'server.js']) {
61
37
  assert.equal(sbx.isPreviewableFile(f), true, `${f} should be previewable`);
62
38
  }
63
- for (const f of ['scripts/gds/ship.js', 'modules/dev-box/routes/box.js', 'docs/adr/0046.md', 'modules/status-ui/public/index.html', 'modules/hall-ui/public/hall.js', 'migrations/075.sql', 'tests/sandbox_stage.mjs', 'server.js.bak']) {
39
+ for (const f of ['scripts/gds/ship.js', 'modules/lifecycle/routes/tasks.js', 'docs/adr/0046.md', 'modules/status-ui/public/index.html', 'modules/hall-ui/public/hall.js', 'migrations/075.sql', 'tests/sandbox_stage.mjs', 'server.js.bak']) {
64
40
  assert.equal(sbx.isPreviewableFile(f), false, `${f} should NOT be previewable`);
65
41
  }
66
42
  });
@@ -74,9 +50,8 @@ t('anyPreviewable: mixed diff counts as previewable; empty/none does not', () =>
74
50
 
75
51
  // ---------- decideShipGate decision table ----------
76
52
  t('decideShipGate: full decision table', () => {
77
- // gateActive = a sandbox exists here (a dev box OR a local game preview). The
78
- // table is identical for both; only HOW we stage + the URL differ downstream.
79
- const base = { gateActive: true, previewUrl: 'https://sandbox-x', previewable: true, stagedFresh: false, tty: true, skipStage: false };
53
+ // gateActive = a sandbox exists here (a local game preview).
54
+ const base = { gateActive: true, previewUrl: 'http://localhost:3100', previewable: true, stagedFresh: false, tty: true, skipStage: false };
80
55
  assert.equal(sbx.decideShipGate({ ...base, skipStage: true }).action, 'skip');
81
56
  assert.equal(sbx.decideShipGate({ ...base, gateActive: false }).action, 'skip');
82
57
  assert.equal(sbx.decideShipGate({ ...base, previewable: false }).action, 'skip');
@@ -90,20 +65,9 @@ t('decideShipGate: full decision table', () => {
90
65
  assert.equal(sbx.decideShipGate({ ...base, skipStage: true, stagedFresh: true }).action, 'skip');
91
66
  });
92
67
 
93
- // ---------- resolvePreviewContext: box vs local vs none (task 1056) ----------
94
- t('resolvePreviewContext: dev box → box kind with the named-tunnel URL', () => {
95
- const ctx = sbx.resolvePreviewContext({
96
- fs: {
97
- existsSync: (p) => p === sbx.BOX_ENV_PATH,
98
- readFileSync: () => 'BOX_PREVIEW_HOSTNAME=sandbox-x.example.com\n',
99
- },
100
- });
101
- assert.equal(ctx.kind, 'box');
102
- assert.equal(ctx.url, 'https://sandbox-x.example.com');
103
- });
104
-
105
- t('resolvePreviewContext: local full clone (no box, game entry present) → local kind at localhost', () => {
106
- const gameEntryFs = { existsSync: (p) => p !== sbx.BOX_ENV_PATH && /preview-server\.js$/.test(String(p)) };
68
+ // ---------- resolvePreviewContext: local vs none (task 1056) ----------
69
+ t('resolvePreviewContext: local full clone (game entry present) → local kind at localhost', () => {
70
+ const gameEntryFs = { existsSync: (p) => /preview-server\.js$/.test(String(p)) };
107
71
  const ctx = sbx.resolvePreviewContext({ env: {}, fs: gameEntryFs });
108
72
  assert.equal(ctx.kind, 'local');
109
73
  assert.equal(ctx.url, 'http://localhost:3100');
@@ -112,51 +76,14 @@ t('resolvePreviewContext: local full clone (no box, game entry present) → loca
112
76
  assert.equal(ctx2.url, 'http://localhost:4200');
113
77
  });
114
78
 
115
- t('resolvePreviewContext: partial clone (no box, no game entry) → null (no sandbox)', () => {
79
+ t('resolvePreviewContext: a checkout with no game entry → null (no sandbox)', () => {
116
80
  assert.equal(sbx.resolvePreviewContext({ fs: { existsSync: () => false } }), null);
117
81
  });
118
82
 
119
- // task 1443: a box whose box.env is missing/unreadable must STILL be detected as
120
- // a box (via the box-preview wrapper or the service unit) and must NEVER fall
121
- // through to the localhost local-preview branch — localhost is unreachable from
122
- // the builder's browser on a remote box.
123
- t('resolvePreviewContext: box via the box-preview wrapper + unreadable box.env → box kind, NEVER localhost', () => {
124
- const ctx = sbx.resolvePreviewContext({
125
- fs: {
126
- // wrapper present (a box) + game entry present (a box has the game), but box.env unreadable
127
- existsSync: (p) => p === '/usr/local/bin/box-preview' || /preview-server\.js$/.test(String(p)),
128
- readFileSync: () => { throw new Error('EACCES'); },
129
- },
130
- });
131
- assert.equal(ctx.kind, 'box', 'a box marker wins over hasGameEntry → box branch, not local');
132
- assert.equal(ctx.url, null, 'no readable hostname → null url (stage gives a clear message), never a localhost URL');
133
- });
134
-
135
- t('resolvePreviewContext: box via the box-game-preview.service unit → box kind', () => {
136
- const ctx = sbx.resolvePreviewContext({
137
- fs: {
138
- existsSync: (p) => p === '/etc/systemd/system/box-game-preview.service',
139
- readFileSync: () => { throw new Error('ENOENT'); },
140
- },
141
- });
142
- assert.equal(ctx.kind, 'box');
143
- assert.equal(ctx.url, null);
144
- });
145
-
146
- await ta('stage: box with no readable hostname → clear "no sandbox hostname" message, not a localhost link', async () => {
147
- const staged = await sbx.stage({ taskId: 1443 }, {
148
- fs: { existsSync: (p) => p === '/usr/local/bin/box-preview', readFileSync: () => { throw new Error('EACCES'); } },
149
- });
150
- assert.equal(staged.ok, false);
151
- assert.match(staged.reason, /no sandbox hostname/i);
152
- assert.match(staged.reason, /box-preview status/);
153
- assert.doesNotMatch(staged.reason, /http:\/\/localhost/, 'must not hand back a localhost URL on a box');
154
- });
155
-
156
83
  // ---------- tree stamp + marker (real temp git repo) ----------
157
84
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-sbx-test-'));
158
- // Markers live OUTSIDE the repo — on a real box the marker is in /tmp, not
159
- // /workspace, so it must never perturb the tree stamp it certifies.
85
+ // Markers live OUTSIDE the repo (the OS temp dir), so they must never perturb the
86
+ // tree stamp they certify.
160
87
  const markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-sbx-markers-'));
161
88
  function git(...args) { execFileSync('git', args, { cwd: tmp, encoding: 'utf8' }); }
162
89
  git('init', '-q');
@@ -224,46 +151,40 @@ t('marker: round-trip via injected path; unreadable → null', () => {
224
151
  assert.equal(m.task_id, 927);
225
152
  });
226
153
 
227
- // ---------- runShipGate composition (BOX) ----------
228
- // Fake "box" deps: box.env exists + carries a preview hostname; restart is a
229
- // no-op; health answers 200 immediately; marker lives in the temp dir.
230
- function boxDeps(markerFile, overrides = {}) {
154
+ // ---------- runShipGate composition ----------
155
+ // Inject the local preview context directly so there is NO real spawn / network:
156
+ // restart is a no-op, health answers like a real preview, git is real on the
157
+ // temp repo, and the marker lives in the temp dir.
158
+ const PREVIEW_URL = 'http://localhost:3100';
159
+ function gateDeps(markerFile, overrides = {}) {
231
160
  return {
161
+ previewContext: {
162
+ kind: 'local',
163
+ url: PREVIEW_URL,
164
+ healthUrl: 'http://127.0.0.1:3100/healthz',
165
+ restart: () => { /* pretend the local preview (re)started */ },
166
+ },
232
167
  fs: {
233
- existsSync: (p) => p === '/etc/otb/box.env' || fs.existsSync(p),
234
- readFileSync: (p, enc) => p === '/etc/otb/box.env'
235
- ? 'BOX_PREVIEW_HOSTNAME=sandbox-test.example.com\n'
236
- : fs.readFileSync(p, enc),
168
+ existsSync: (p) => fs.existsSync(p),
169
+ readFileSync: (p, enc) => fs.readFileSync(p, enc),
237
170
  writeFileSync: (p, d) => fs.writeFileSync(p, d),
238
171
  },
239
- execFileSync: (cmd, args, opts) => {
240
- // git goes through for real (the temp repo); systemctl/box-preview no-op.
241
- if (cmd === 'git') return execFileSync(cmd, args, opts);
242
- return '';
243
- },
172
+ execFileSync: (cmd, args, opts) => (cmd === 'git' ? execFileSync(cmd, args, opts) : ''),
244
173
  // task 1002607: emulate a real PREVIEW — 200 on the health URL, 404 on the
245
174
  // /api/bongos/healthz discriminator (the game-only entry mounts no /api/bongos).
246
- // A flat always-200 mock would now read as "the MAIN server answered".
175
+ // A flat always-200 mock would read as "the MAIN server answered".
247
176
  fetchImpl: async (url) => ({ status: String(url).includes('/api/bongos/healthz') ? 404 : 200 }),
248
177
  markerPath: markerFile,
249
178
  cwd: tmp,
250
179
  pollMs: 1,
251
180
  timeoutMs: 1000,
252
- previewWrapper: '/nonexistent-box-preview',
253
- // #1068: the box stage() now syncs the worktree into /workspace + verifies
254
- // the served bytes. Inject no-ops here so the gate-composition tests don't
255
- // touch the real /workspace or need a text()-capable fetch mock — the real
256
- // syncWorktreeToServed / verifyServedFresh get their OWN dedicated tests.
257
- syncWorktreeToServed: () => ({ ok: true, synced: false }),
258
- verifyServedFresh: async () => ({ ok: true, skipped: true }),
259
181
  ...overrides,
260
182
  };
261
183
  }
262
184
 
263
- await ta('runShipGate: no sandbox available (not a box, no local game entry) → ok, no staging', async () => {
264
- // existsSync()=>false means neither /etc/otb/box.env NOR src/preview-server.js
265
- // is present — a partial clone with no game. (A full local clone WITH the game
266
- // is covered by the LOCAL tests below, where it DOES stage.)
185
+ await ta('runShipGate: no sandbox available (no local game entry) → ok, no staging', async () => {
186
+ // existsSync()=>false means src/preview-server.js is absent — a checkout with
187
+ // no game. (A clone WITH the game is covered by the tests below, where it stages.)
267
188
  const r = await sbx.runShipGate(
268
189
  { taskId: 1, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true },
269
190
  { fs: { existsSync: () => false } }
@@ -271,30 +192,31 @@ await ta('runShipGate: no sandbox available (not a box, no local game entry) →
271
192
  assert.equal(r.ok, true);
272
193
  });
273
194
 
274
- await ta('runShipGate: box + non-game diff → ok, no staging', async () => {
195
+ await ta('runShipGate: non-game diff → ok, no staging', async () => {
275
196
  const mp = path.join(markerDir, 'm1.json');
276
- const r = await sbx.runShipGate({ taskId: 1, files: ['docs/x.md'], skipStage: false, tty: true }, boxDeps(mp));
197
+ const r = await sbx.runShipGate({ taskId: 1, files: ['docs/x.md'], skipStage: false, tty: true }, gateDeps(mp));
277
198
  assert.equal(r.ok, true);
278
199
  assert.equal(fs.existsSync(mp), false, 'no marker written — nothing staged');
279
200
  });
280
201
 
281
- await ta('runShipGate: box + game diff + TTY, reviewer says yes → stages then ok, records tty sign-off', async () => {
202
+ await ta('runShipGate: game diff + TTY, reviewer says yes → stages then ok, records tty sign-off', async () => {
282
203
  const mp = path.join(markerDir, 'm2.json');
283
- const deps = boxDeps(mp, { promptYesNo: async () => true });
204
+ const deps = gateDeps(mp, { promptYesNo: async () => true });
284
205
  const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/world/terrain.js'], skipStage: false, tty: true }, deps);
285
206
  assert.equal(r.ok, true);
286
207
  const m = JSON.parse(fs.readFileSync(mp, 'utf8'));
287
- assert.equal(m.url, 'https://sandbox-test.example.com');
208
+ assert.equal(m.url, PREVIEW_URL);
209
+ assert.equal(m.kind, 'local');
288
210
  assert.ok(m.stamp, 'marker carries the staged tree stamp');
289
211
  // #1048: a TTY 'y' IS the explicit sign-off → recorded as method 'tty'.
290
212
  assert.equal(r.sandboxReview.method, 'tty', 'TTY yes records a tty sign-off');
291
- assert.equal(r.sandboxReview.url, 'https://sandbox-test.example.com');
213
+ assert.equal(r.sandboxReview.url, PREVIEW_URL);
292
214
  assert.ok(r.sandboxReview.stamp, 'approval record carries the reviewed tree stamp');
293
215
  });
294
216
 
295
217
  await ta('runShipGate: reviewer says NO → aborts with claim-preserving reason', async () => {
296
218
  const mp = path.join(markerDir, 'm3.json');
297
- const deps = boxDeps(mp, { promptYesNo: async () => false });
219
+ const deps = gateDeps(mp, { promptYesNo: async () => false });
298
220
  const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
299
221
  assert.equal(r.ok, false);
300
222
  assert.match(r.reason, /sandbox review declined/);
@@ -302,8 +224,8 @@ await ta('runShipGate: reviewer says NO → aborts with claim-preserving reason'
302
224
 
303
225
  await ta('runShipGate: fresh marker → pass without prompting', async () => {
304
226
  const mp = path.join(markerDir, 'm4.json');
305
- const deps = boxDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
306
- sbx.writeMarker({ stamp: sbx.computeTreeStamp({ cwd: tmp }), url: 'https://sandbox-test.example.com' }, deps);
227
+ const deps = gateDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
228
+ sbx.writeMarker({ stamp: sbx.computeTreeStamp({ cwd: tmp }), url: PREVIEW_URL }, deps);
307
229
  const r = await sbx.runShipGate({ taskId: 927, files: ['server.js'], skipStage: false, tty: true }, deps);
308
230
  assert.equal(r.ok, true);
309
231
  });
@@ -311,8 +233,8 @@ await ta('runShipGate: fresh marker → pass without prompting', async () => {
311
233
  await ta('runShipGate: stale marker forces a re-stage (stamp mismatch)', async () => {
312
234
  const mp = path.join(markerDir, 'm5.json');
313
235
  let prompted = false;
314
- const deps = boxDeps(mp, { promptYesNo: async () => { prompted = true; return true; } });
315
- sbx.writeMarker({ stamp: 'stale-stamp', url: 'https://sandbox-test.example.com' }, deps);
236
+ const deps = gateDeps(mp, { promptYesNo: async () => { prompted = true; return true; } });
237
+ sbx.writeMarker({ stamp: 'stale-stamp', url: PREVIEW_URL }, deps);
316
238
  const r = await sbx.runShipGate({ taskId: 927, files: ['server.js'], skipStage: false, tty: true }, deps);
317
239
  assert.equal(r.ok, true);
318
240
  assert.equal(prompted, true, 'stale stamp must re-stage + re-prompt');
@@ -320,11 +242,11 @@ await ta('runShipGate: stale marker forces a re-stage (stamp mismatch)', async (
320
242
 
321
243
  await ta('runShipGate: non-TTY → stages then ABORTS with the review URL (no prompt, no hang)', async () => {
322
244
  const mp = path.join(markerDir, 'm6.json');
323
- const deps = boxDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
245
+ const deps = gateDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
324
246
  const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: false, tty: false }, deps);
325
247
  assert.equal(r.ok, false, 'unreviewed game work must not ship non-interactively');
326
248
  assert.equal(r.review, true, 'abort is flagged as a review pause');
327
- assert.match(r.reason, /staged at https:\/\/sandbox-test\.example\.com/i);
249
+ assert.match(r.reason, /staged at http:\/\/localhost:3100/i);
328
250
  assert.ok(fs.existsSync(mp), 'work is staged so the human can review at the URL');
329
251
  // ...and after the (notional) review, a bare re-run passes on the fresh marker
330
252
  // but records NO sign-off (no --approved) — back-compat / failure-open.
@@ -335,7 +257,7 @@ await ta('runShipGate: non-TTY → stages then ABORTS with the review URL (no pr
335
257
 
336
258
  await ta('runShipGate: uncommitted game edit counts as previewable even when committed diff does not', async () => {
337
259
  const mp = path.join(markerDir, 'm9.json');
338
- const deps = boxDeps(mp, { promptYesNo: async () => true });
260
+ const deps = gateDeps(mp, { promptYesNo: async () => true });
339
261
  fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'dirty working tree\n');
340
262
  const r = await sbx.runShipGate({ taskId: 927, files: ['docs/x.md'], skipStage: false, tty: true }, deps);
341
263
  assert.equal(r.ok, true);
@@ -345,7 +267,7 @@ await ta('runShipGate: uncommitted game edit counts as previewable even when com
345
267
 
346
268
  await ta('runShipGate: --skip-stage → ok, untouched', async () => {
347
269
  const mp = path.join(markerDir, 'm7.json');
348
- const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: true, tty: true }, boxDeps(mp));
270
+ const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: true, tty: true }, gateDeps(mp));
349
271
  assert.equal(r.ok, true);
350
272
  assert.equal(fs.existsSync(mp), false);
351
273
  });
@@ -356,7 +278,7 @@ await ta('runShipGate: --skip-stage → ok, untouched', async () => {
356
278
  // as the explicit bypass, and the reason says plainly the review DID NOT run.
357
279
  await ta('1002607 runShipGate: staging failure (health never green) ABORTS — review gate cannot silently turn itself off', async () => {
358
280
  const mp = path.join(markerDir, 'm8.json');
359
- const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
281
+ const deps = gateDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
360
282
  const r = await sbx.runShipGate({ taskId: 927, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
361
283
  assert.equal(r.ok, false, 'a failed stage must not ship as if the review happened');
362
284
  assert.equal(r.review, undefined, 'a failure to present is NOT a calm review pause');
@@ -365,23 +287,23 @@ await ta('1002607 runShipGate: staging failure (health never green) ABORTS — r
365
287
  assert.match(r.reason, /claim is still active/i, 'claim-preserving');
366
288
  });
367
289
 
368
- // ---------- recorded self-approval (#1048), box ----------
290
+ // ---------- recorded self-approval (#1048) ----------
369
291
  await ta('runShipGate: --approved re-run on a fresh stage records a flag sign-off (#1048)', async () => {
370
292
  const mp = path.join(markerDir, 'm10.json');
371
- const deps = boxDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
293
+ const deps = gateDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
372
294
  // Simulate "already staged + reviewed on a prior run": fresh marker, then the
373
295
  // builder approves via the flag on the re-run.
374
- sbx.writeMarker({ stamp: sbx.computeTreeStamp({ cwd: tmp }), url: 'https://sandbox-test.example.com' }, deps);
296
+ sbx.writeMarker({ stamp: sbx.computeTreeStamp({ cwd: tmp }), url: PREVIEW_URL }, deps);
375
297
  const r = await sbx.runShipGate({ taskId: 1048, files: ['server.js'], skipStage: false, tty: false, approved: true }, deps);
376
298
  assert.equal(r.ok, true);
377
299
  assert.equal(r.sandboxReview.method, 'flag', 'approved re-run records a flag sign-off');
378
- assert.equal(r.sandboxReview.url, 'https://sandbox-test.example.com');
300
+ assert.equal(r.sandboxReview.url, PREVIEW_URL);
379
301
  assert.ok(r.sandboxReview.stamp, 'record carries the reviewed tree stamp');
380
302
  });
381
303
 
382
304
  await ta('runShipGate: --approved cannot pre-approve an UNstaged change (still stage_aborts)', async () => {
383
305
  const mp = path.join(markerDir, 'm11.json');
384
- const deps = boxDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
306
+ const deps = gateDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
385
307
  // No marker → not staged-fresh → --approved is ignored; the gate still stages
386
308
  // + aborts for review. You cannot sign off on something never shown.
387
309
  const r = await sbx.runShipGate({ taskId: 1048, files: ['modules/game/public/game/a.js'], skipStage: false, tty: false, approved: true }, deps);
@@ -390,147 +312,6 @@ await ta('runShipGate: --approved cannot pre-approve an UNstaged change (still s
390
312
  assert.equal(r.sandboxReview, undefined, 'nothing recorded — nothing was staged to approve');
391
313
  });
392
314
 
393
- // ---------- #1068: worktree → served sync + freshness verify ----------
394
-
395
- // A box serves /workspace (the MAIN checkout), but a session edits a linked
396
- // worktree. syncWorktreeToServed must materialize the branch's previewable
397
- // content into the served root. Build a real main checkout + a `git worktree`
398
- // off a feature branch that changed public/game.js, then assert the served copy
399
- // gets the branch content.
400
- await ta('syncWorktreeToServed: brings the branch content into the served checkout', async () => {
401
- const served = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-served-'));
402
- const g = (...a) => execFileSync('git', a, { cwd: served, encoding: 'utf8' });
403
- g('init', '-q');
404
- g('config', 'user.email', 't@e.com'); g('config', 'user.name', 't');
405
- fs.mkdirSync(path.join(served, 'modules', 'game', 'public'), { recursive: true });
406
- fs.writeFileSync(path.join(served, 'modules', 'game', 'public', 'game.js'), 'MAIN content\n');
407
- g('add', '.'); g('commit', '-qm', 'main');
408
- // a linked worktree on a feature branch with different game content
409
- const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-wt-')) + '/co';
410
- g('worktree', 'add', '-q', '-b', 'claude/feat', wt);
411
- fs.writeFileSync(path.join(wt, 'modules', 'game', 'public', 'game.js'), 'BRANCH content\n');
412
- execFileSync('git', ['add', '.'], { cwd: wt });
413
- execFileSync('git', ['commit', '-qm', 'branch edit'], { cwd: wt });
414
-
415
- const r = sbx.syncWorktreeToServed({ cwd: wt, servedRoot: served });
416
- assert.equal(r.ok, true);
417
- assert.equal(r.synced, true);
418
- assert.equal(r.branch, 'claude/feat');
419
- // The claim is WHICH content landed, not how its lines end: git checks the fixture
420
- // out under the machine's own autocrlf, so on Windows the file comes back CRLF and a
421
- // byte-exact compare failed for a reason unrelated to the sync (task 1003529).
422
- assert.ok(sameContent(fs.readFileSync(path.join(served, 'modules', 'game', 'public', 'game.js'), 'utf8'), 'BRANCH content\n'),
423
- 'served /workspace now shows the SESSION branch content, not stale main');
424
-
425
- g('worktree', 'remove', '--force', wt);
426
- fs.rmSync(served, { recursive: true, force: true });
427
- });
428
-
429
- t('syncWorktreeToServed: no-op when the session checkout IS the served root', () => {
430
- const r = sbx.syncWorktreeToServed({ cwd: tmp, servedRoot: tmp });
431
- assert.equal(r.ok, true);
432
- assert.equal(r.synced, false);
433
- });
434
-
435
- t('syncWorktreeToServed: a SYMLINKED servedRoot pointing at the session checkout is a no-op (idea #278)', () => {
436
- // The macOS footgun: `git rev-parse --show-toplevel` returns the REAL path,
437
- // but servedRoot may be reached through a symlink (macOS /tmp -> /private/var,
438
- // or any symlinked served path). A raw path.resolve comparison false-negatives
439
- // "same dir" and the code would wrongly try to git-sync the checkout onto
440
- // itself. realPathOr fixes it. This test builds an EXPLICIT symlink so it holds
441
- // on any OS (it FAILS against the old path.resolve code, PASSES with realpath).
442
- const linkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-served-link-'));
443
- const link = path.join(linkDir, 'served');
444
- // A 'dir' symlink needs elevation (or Developer Mode) on Windows and threw EPERM,
445
- // taking the case out on every Windows checkout. A JUNCTION is the unprivileged
446
- // Windows equivalent for a directory and realpath resolves it the same way — the
447
- // spelling scripts/gds/worktree.js already uses for exactly this reason (task 1003529).
448
- fs.symlinkSync(fs.realpathSync(tmp), link, process.platform === 'win32' ? 'junction' : 'dir');
449
- try {
450
- const r = sbx.syncWorktreeToServed({ cwd: tmp, servedRoot: link });
451
- assert.equal(r.ok, true);
452
- assert.equal(r.synced, false, 'a symlinked served root pointing at the session checkout must be recognized as the same dir, not synced as stale');
453
- } finally {
454
- fs.rmSync(linkDir, { recursive: true, force: true });
455
- }
456
- });
457
-
458
- t('syncWorktreeToServed: detached HEAD / unresolvable branch → not ok', () => {
459
- // execFileSync stub: toplevel resolves, but rev-parse --abbrev-ref returns HEAD
460
- const stub = (cmd, args) => {
461
- if (args.includes('--show-toplevel')) return '/some/worktree\n';
462
- if (args.includes('--abbrev-ref')) return 'HEAD\n';
463
- return '';
464
- };
465
- const r = sbx.syncWorktreeToServed({ execFileSync: stub, servedRoot: '/served' });
466
- assert.equal(r.ok, false);
467
- assert.match(r.reason, /could not resolve the session branch/);
468
- });
469
-
470
- await ta('verifyServedFresh: served bytes match worktree → ok/verified', async () => {
471
- // dirty a tracked game file so pickVerifyTarget selects it
472
- fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'served-match\n');
473
- const r = await sbx.verifyServedFresh({
474
- cwd: tmp,
475
- healthUrl: 'http://127.0.0.1:3000/',
476
- fetchImpl: async (url) => { assert.match(url, /\/game\.js$/); return { status: 200, text: async () => 'served-match\n' }; },
477
- });
478
- assert.equal(r.ok, true);
479
- assert.equal(r.verified, 'modules/game/public/game.js');
480
- execFileSync('git', ['checkout', '--', 'modules/game/public/game.js'], { cwd: tmp });
481
- });
482
-
483
- await ta('verifyServedFresh: served bytes DIFFER → not ok (serving stale code)', async () => {
484
- fs.writeFileSync(path.join(tmp, 'modules', 'game', 'public', 'game.js'), 'worktree-new\n');
485
- const r = await sbx.verifyServedFresh({
486
- cwd: tmp,
487
- fetchImpl: async () => ({ status: 200, text: async () => 'stale-old\n' }),
488
- });
489
- assert.equal(r.ok, false);
490
- assert.match(r.reason, /serving stale code/);
491
- execFileSync('git', ['checkout', '--', 'modules/game/public/game.js'], { cwd: tmp });
492
- });
493
-
494
- await ta('verifyServedFresh: no served static change → skipped (ok)', async () => {
495
- // clean tree, no origin/main ref in the temp repo → pickVerifyTarget null
496
- const r = await sbx.verifyServedFresh({ cwd: tmp, fetchImpl: async () => { throw new Error('must not fetch'); } });
497
- assert.equal(r.ok, true);
498
- assert.equal(r.skipped, true);
499
- });
500
-
501
- await ta('stage(box): a HARD sync failure → {ok:false,hard:true}, no marker; runShipGate ABORTS', async () => {
502
- const mp = path.join(markerDir, 'hard1.json');
503
- const deps = boxDeps(mp, { syncWorktreeToServed: () => ({ ok: false, reason: 'checkout failed' }) });
504
- const staged = await sbx.stage({ taskId: 1068 }, deps);
505
- assert.equal(staged.ok, false);
506
- assert.equal(staged.hard, true);
507
- assert.equal(fs.existsSync(mp), false, 'no marker on a hard sync failure');
508
- // and the gate turns a hard failure into a claim-preserving abort
509
- const r = await sbx.runShipGate({ taskId: 1068, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
510
- assert.equal(r.ok, false);
511
- assert.equal(r.review, true);
512
- assert.match(r.reason, /could not be trusted/);
513
- });
514
-
515
- await ta('stage(box): a HARD verify failure → {ok:false,hard:true}, no marker', async () => {
516
- const mp = path.join(markerDir, 'hard2.json');
517
- const deps = boxDeps(mp, { verifyServedFresh: async () => ({ ok: false, reason: 'served stale' }) });
518
- const staged = await sbx.stage({ taskId: 1068 }, deps);
519
- assert.equal(staged.ok, false);
520
- assert.equal(staged.hard, true);
521
- assert.equal(fs.existsSync(mp), false, 'no marker on a hard verify failure');
522
- });
523
-
524
- await ta('1002607 runShipGate(box): a SOFT failure (preview never healthy) now fails closed — abort, claim intact', async () => {
525
- const mp = path.join(markerDir, 'soft1.json');
526
- // sync ok (injected), but health never green → the ship must NOT continue
527
- // as if the review ran (that was the silent-disable defect).
528
- const deps = boxDeps(mp, { fetchImpl: async () => ({ status: 502 }), timeoutMs: 10 });
529
- const r = await sbx.runShipGate({ taskId: 1068, files: ['modules/game/public/game/a.js'], skipStage: false, tty: true }, deps);
530
- assert.equal(r.ok, false, 'the soft failure aborts instead of silently skipping the review');
531
- assert.match(r.reason, /sandbox staging failed/);
532
- });
533
-
534
315
  // ---------- task 1002607: waitHealthy truth table ----------
535
316
 
536
317
  await ta('1002607 waitHealthy: 404 at the health URL is NOT healthy (was: anything < 500 passed)', async () => {
@@ -579,73 +360,6 @@ t('1002607 NO_SANDBOX_MSG: names what is actually missing, not the builder\'s cl
579
360
  assert.doesNotMatch(sbx.NO_SANDBOX_MSG, /this clone has no game preview entry/, 'the old your-clone-is-at-fault phrasing is gone');
580
361
  });
581
362
 
582
- // ---------- runShipGate: LOCAL preview context + recorded approval (task 1056) ----------
583
- // A full local clone (laptop) gets the SAME enforced gate + recorded-approval as
584
- // a box (#1048), but staged at http://localhost:<port>. Inject the local context
585
- // directly so there is NO real spawn / network: restart is a no-op, health
586
- // answers 200, git is real on the temp repo, marker lives in the temp dir.
587
- function localDeps(markerFile, overrides = {}) {
588
- return {
589
- previewContext: {
590
- kind: 'local',
591
- url: 'http://localhost:3100',
592
- healthUrl: 'http://127.0.0.1:3100/healthz',
593
- restart: () => { /* pretend the local preview (re)started */ },
594
- },
595
- fs: {
596
- existsSync: (p) => fs.existsSync(p),
597
- readFileSync: (p, enc) => fs.readFileSync(p, enc),
598
- writeFileSync: (p, d) => fs.writeFileSync(p, d),
599
- },
600
- execFileSync: (cmd, args, opts) => (cmd === 'git' ? execFileSync(cmd, args, opts) : ''),
601
- // task 1002607: preview-shaped mock — health 200, API-healthz discriminator 404.
602
- fetchImpl: async (url) => ({ status: String(url).includes('/api/bongos/healthz') ? 404 : 200 }),
603
- markerPath: markerFile,
604
- cwd: tmp,
605
- pollMs: 1,
606
- timeoutMs: 1000,
607
- ...overrides,
608
- };
609
- }
610
-
611
- await ta('runShipGate(local): game diff + non-TTY → stages then ABORTS with the localhost URL', async () => {
612
- const mp = path.join(markerDir, 'lm1.json');
613
- const r = await sbx.runShipGate({ taskId: 1056, files: ['modules/game/public/game/a.js'], skipStage: false, tty: false }, localDeps(mp));
614
- assert.equal(r.ok, false, 'unreviewed game work must not ship non-interactively on a laptop');
615
- assert.equal(r.review, true);
616
- assert.match(r.reason, /staged at http:\/\/localhost:3100/i);
617
- assert.ok(fs.existsSync(mp), 'work is staged so the human can review at localhost');
618
- });
619
-
620
- await ta('runShipGate(local): TTY yes → records a tty sign-off with the localhost URL + kind=local marker', async () => {
621
- const mp = path.join(markerDir, 'lm2.json');
622
- const r = await sbx.runShipGate({ taskId: 1056, files: ['modules/game/world/terrain.js'], skipStage: false, tty: true }, localDeps(mp, { promptYesNo: async () => true }));
623
- assert.equal(r.ok, true);
624
- assert.equal(r.sandboxReview.method, 'tty');
625
- assert.equal(r.sandboxReview.url, 'http://localhost:3100');
626
- assert.ok(r.sandboxReview.stamp);
627
- const m = JSON.parse(fs.readFileSync(mp, 'utf8'));
628
- assert.equal(m.url, 'http://localhost:3100');
629
- assert.equal(m.kind, 'local');
630
- });
631
-
632
- await ta('runShipGate(local): --approved re-run on a fresh stage records a flag sign-off', async () => {
633
- const mp = path.join(markerDir, 'lm3.json');
634
- const deps = localDeps(mp, { promptYesNo: async () => { throw new Error('must not prompt'); } });
635
- sbx.writeMarker({ stamp: sbx.computeTreeStamp({ cwd: tmp }), url: 'http://localhost:3100' }, deps);
636
- const r = await sbx.runShipGate({ taskId: 1056, files: ['server.js'], skipStage: false, tty: false, approved: true }, deps);
637
- assert.equal(r.ok, true);
638
- assert.equal(r.sandboxReview.method, 'flag');
639
- assert.equal(r.sandboxReview.url, 'http://localhost:3100');
640
- });
641
-
642
- await ta('runShipGate(local): non-game diff (clean tree) → skip, no staging', async () => {
643
- const mp = path.join(markerDir, 'lm4.json');
644
- const r = await sbx.runShipGate({ taskId: 1056, files: ['docs/x.md'], skipStage: false, tty: true }, localDeps(mp));
645
- assert.equal(r.ok, true);
646
- assert.equal(fs.existsSync(mp), false, 'no marker — nothing previewable');
647
- });
648
-
649
363
  fs.rmSync(tmp, { recursive: true, force: true });
650
364
 
651
365
  // ---------- 1002062: the surface list must describe a REAL tree ----------
@@ -653,7 +367,7 @@ fs.rmSync(tmp, { recursive: true, force: true });
653
367
  // WHY THESE EXIST. Every case above feeds sandbox-stage its own fixture paths,
654
368
  // so the previewable list matched NOTHING in the real repo for 69 days and all
655
369
  // 47 tests stayed green. A suite that supplies the paths it tests cannot notice
656
- // that the paths are wrong. These four look at the real thing instead.
370
+ // that the paths are wrong. These two look at the real thing instead.
657
371
 
658
372
  t('1002062 the three surface spellings are DERIVED, so they cannot drift apart', () => {
659
373
  // They used to be three hand-maintained copies. The module carve updated none
@@ -680,53 +394,6 @@ t('1002062 no PRE-CARVE relic survives in the surface list', () => {
680
394
  `${relic} is the pre-carve spelling and matches nothing — it must not be back`
681
395
  );
682
396
  }
683
- assert.ok(sbx.PREVIEWABLE_STATIC_DIR.startsWith('modules/'), 'the served static dir is module-carved');
684
- });
685
-
686
- t('1002062 a client directory that matched NOTHING is reported, not swallowed', () => {
687
- // THE ACTUAL BUG. The sync collapsed to one boolean, so a run that synced only
688
- // the entrypoints and missed every client dir reported plain success — the
689
- // reporter saw "synced previewable surfaces" and got a stale sandbox.
690
- const served = fs.mkdtempSync(path.join(os.tmpdir(), "sbx-missed-"));
691
- const session = fs.mkdtempSync(path.join(os.tmpdir(), "sbx-sess-"));
692
- const calls = [];
693
- const execFileSyncStub = (bin, args) => {
694
- const a = args.join(" ");
695
- if (a.includes("rev-parse")) return "feature-branch\n";
696
- // Entrypoints resolve; every client directory is absent from the branch.
697
- const ps = args[args.length - 1];
698
- if (sbx.PREVIEWABLE_DIRS.includes(ps) && a.includes("feature-branch")) {
699
- throw new Error(`pathspec ${ps} did not match any file(s) known to git`);
700
- }
701
- calls.push(a);
702
- return "";
703
- };
704
- const r = sbx.syncWorktreeToServed({
705
- execFileSync: execFileSyncStub,
706
- servedRoot: served,
707
- sessionRoot: session,
708
- cwd: session,
709
- });
710
- assert.equal(r.ok, true, "the entrypoints synced, so the sync itself is not a hard failure");
711
- assert.deepEqual(
712
- r.missedDirs, sbx.PREVIEWABLE_DIRS,
713
- "every client directory missed, and the caller must be told which"
714
- );
715
- });
716
-
717
- t('1002062 pickVerifyTarget maps the CARVED static dir onto the served URL', () => {
718
- // modules/game/public/game.js is served at /game.js. The old code stripped a
719
- // hardcoded "public/" prefix, which post-carve would have left the whole
720
- // modules/game/ path in the URL.
721
- const rel = `${sbx.PREVIEWABLE_STATIC_DIR}/game.js`;
722
- const target = sbx.pickVerifyTarget({
723
- cwd: "/nowhere",
724
- fs: { existsSync: () => true },
725
- execFileSync: (bin, args) => (args.includes("--porcelain") ? ` M ${rel}\n` : ""),
726
- });
727
- assert.ok(target, "a changed file under the static dir must be pickable");
728
- assert.equal(target.relPath, rel);
729
- assert.equal(target.urlPath, "/game.js", "the carved prefix is stripped, not just \"public/\"");
730
397
  });
731
398
 
732
399
  console.log(`\nsandbox_stage: ${passed} passed, ${failed} failed`);