@bongos/core 1.19.1073 → 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 +2 -0
  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 +1 -0
  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 +2 -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 +78 -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,1332 +0,0 @@
1
- #!/usr/bin/env node
2
- // scripts/gds/box.js — per-builder cloud dev box lifecycle CLI (ADR 0031 §2/§5, #598).
3
- //
4
- // The control-plane entrypoint for the box lifecycle. Like provision-staging.sh it
5
- // must RUN ON A TRUSTED CONTROL PLANE (the prod droplet or an operator's machine),
6
- // NOT inside a builder box — a box provisioning boxes is privilege escalation, and
7
- // the builder firewall doesn't even allow-list the DO API. The rank gate reads the
8
- // builder's LIVE DB rank (what requireRank reads), so it is a real ADR-0016
9
- // server-side check, not local prose.
10
- //
11
- // Subcommands:
12
- // provision <builder> create + boot a box for a builder (rank-gated)
13
- // park <builder> snapshot + destroy the droplet (stop compute billing)
14
- // wake <builder> recreate the droplet from its snapshot
15
- // deprovision <builder> destroy droplet AND snapshot (full reclaim)
16
- // sweep-idle PARK every active box idle > BOX_IDLE_MINUTES (snapshot kept)
17
- // sweep-dormant warn, then deprovision every box parked > BOX_DORMANT_DAYS
18
- // (the ONLY automated path that destroys a snapshot)
19
- // status print the box roster + estimated cost
20
- //
21
- // <builder> is a GitHub login or a numeric builder id.
22
- //
23
- // SAFETY: dry-run is the DEFAULT — nothing touches DigitalOcean or writes the DB
24
- // without --apply, so run any command bare first to see the plan (as with
25
- // `pulse.js --release-stale --dry-run`):
26
- // node scripts/gds/box.js sweep-idle # plan only
27
- // node scripts/gds/box.js sweep-idle --apply # actually park idle boxes
28
- //
29
- // Config (env): DO_API_TOKEN (live only), BOX_PROVISION_MIN_RANK (default xenos),
30
- // BOX_REGION (nyc3), BOX_SIZE (s-2vcpu-4gb), BOX_BASE_IMAGE (ubuntu-24-04-x64),
31
- // BOX_IDLE_MINUTES (10), BOX_UNATTENDED_MAX_HOURS (12), BOX_DORMANT_DAYS (14),
32
- // BOX_SSH_KEY_IDS (comma list),
33
- // BOX_DNS_HOOK (optional command, gets BOX_HOSTNAME/BOX_IP in env), BOX_TAG.
34
-
35
- const fs = require('node:fs');
36
- const path = require('node:path');
37
- const crypto = require('node:crypto');
38
- const zlib = require('node:zlib');
39
- const { execFileSync } = require('node:child_process');
40
-
41
- // DigitalOcean hard-caps droplet user-data (cloud-init) at 64 KiB. The rendered
42
- // cloud-init base64-inlines several infra scripts, so it sits close to the cap;
43
- // #903 (after #898 tipped it over) gzips the biggest one and adds a fail-fast
44
- // guard. Keep a little headroom under the true 65536 so a stray key/byte is safe.
45
- const MAX_USERDATA_BYTES = 64 * 1024;
46
-
47
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
48
-
49
- // The box cloud-init (infra/builder-box-cloud-init.yaml + its ~9 gz+b64-inlined
50
- // scripts) is INSTANCE content, deliberately NOT shipped in @bongos/core (the R89
51
- // packaging split). On a VENDORED instance box.js runs from
52
- // node_modules/@bongos/core, so REPO_ROOT/infra does not exist — and the box then
53
- // boots BARE (no clone, no toolchain, no terminal). So resolve infra/ from the
54
- // INSTANCE root, riding ADR 0108's resolvers, instead of assuming it sits beside
55
- // box.js. ADR 0150 (BV1.R97/R98).
56
- const instanceConfig = require(path.join(REPO_ROOT, 'src/instance-config'));
57
- const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
58
- const { coreVersionSafe } = require('./provision-config.js');
59
- const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
60
-
61
- // Ordered candidate infra/ dirs — first that actually holds the cloud-init
62
- // template wins. Pure (env + repoRoot injected) so it is unit-testable without
63
- // touching the real filesystem layout.
64
- const { CF, CONFIG, boxInfraCandidates, dropletName, hasFlag, hostnameFor, numEnv, readCloudInit, resolveBoxInfraDir, runDnsHook, runDnsHookBlackhole, shortStamp } = require('./box-infra.js');
65
- function namedTunnelEnabled() { return CF.enabled; }
66
- function namedTunnelConfigured() { return CF.enabled && CF.apiToken && CF.accountId; }
67
-
68
- function makeCf(apply) {
69
- const { CfTunnelApi } = loadDeps();
70
- return new CfTunnelApi({
71
- dryRun: !apply,
72
- token: CF.apiToken,
73
- accountId: CF.accountId,
74
- log: (m) => console.log(' ' + m),
75
- });
76
- }
77
-
78
- // SECURITY PREFLIGHT (task 1002511, ADR 0053 / SR#11). The edge cookie-strip
79
- // Transform Rule is applied BEST-EFFORT deep inside ensureNamedTunnel, so a
80
- // CLOUDFLARE_API_TOKEN missing the zone "Transform Rules" edit scope fails as one
81
- // buried log line while the apex session cookie keeps reaching box subdomains. This
82
- // probe runs BEFORE any DigitalOcean spend (dry-run included — a read-only lookup in
83
- // a plan is the drift-reconcile precedent) and shouts the exact fix.
84
- //
85
- // HONESTY: the probe is a GET, so it can only DISPROVE the scope. A readable phase
86
- // does NOT mean the rule can be written — only the PUT inside ensureNamedTunnel
87
- // proves that. Never print this as a confirmed control. Never blocks provisioning:
88
- // the rule is defense-in-depth and a box without it still works.
89
- async function preflightCookieStripScope() {
90
- const { cfTunnel } = loadDeps();
91
- let scope;
92
- try {
93
- scope = await cfTunnel.checkRulesetScope({
94
- zone: CF.zone, phase: cfTunnel.COOKIE_STRIP_PHASE, token: CF.apiToken,
95
- });
96
- } catch (e) {
97
- scope = { readable: false, reason: `ruleset preflight crashed: ${e.message}`, guidance: '' };
98
- }
99
- if (scope.readable) {
100
- console.log(` cookie-strip ruleset is readable on ${CF.zone} — edit scope stays UNPROVEN until the rule is applied (ADR 0053)`);
101
- return scope;
102
- }
103
- console.log('');
104
- console.log(' ⚠⚠ SECURITY (ADR 0053 / SR#11) — the edge cookie-strip rule cannot be confirmed');
105
- console.log(` ${scope.reason}`);
106
- console.log(` IMPACT: the apex session cookie (Domain=.${CF.zone}) is sent by the browser to`);
107
- console.log(` term-*.${CF.terminalZone} and sandbox-*.${CF.terminalZone} — hosts OUTSIDE the`);
108
- console.log(' ADR 0016 trust boundary — until that Transform Rule is actually in place.');
109
- if (scope.guidance) console.log(` FIX: ${scope.guidance}`);
110
- console.log(' Provisioning CONTINUES — this is defense-in-depth, never a provisioning gate.');
111
- console.log('');
112
- return scope;
113
- }
114
-
115
- // Lazily-built handles to the heavy dependencies (DB pool, DO/CF clients), so a
116
- // dry-run or a --help never opens a pool or requires a network client.
117
- //
118
- // task 1003507 — this was UNDECLARED. `_deps = {...}` further down is an
119
- // assignment, which in sloppy mode would have created a global, but
120
- // `if (_deps)` READS it first, and reading an undeclared identifier is a
121
- // ReferenceError. So loadDeps() threw on its first call, every time, and every
122
- // command that reaches makeDo()/makeCf() went with it — including
123
- // `sweep-idle --apply`, which resolves its DO client before the park loop.
124
- // The idle sweep therefore could not park ANY box, which is a second and
125
- // entirely separate reason the abandoned box in this task was never reaped.
126
- // No test caught it because the only apply:true test relied on the
127
- // claude_active veto emptying the idle set before makeDo() was reached.
128
- let _deps = null;
129
-
130
- function loadDeps() {
131
- if (_deps) return _deps;
132
- // eslint-disable-next-line global-require
133
- const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
134
- // eslint-disable-next-line global-require
135
- const boxes = require(path.join(REPO_ROOT, 'modules/dev-box/boxes'));
136
- // eslint-disable-next-line global-require
137
- const boxAccess = require(path.join(REPO_ROOT, 'modules/dev-box/box-access'));
138
- // eslint-disable-next-line global-require
139
- const boxOnboard = require(path.join(REPO_ROOT, 'modules/dev-box/box-onboard'));
140
- // eslint-disable-next-line global-require
141
- const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
142
- // eslint-disable-next-line global-require
143
- const cfTunnel = require(path.join(REPO_ROOT, 'scripts/gds/cf-tunnel'));
144
- _deps = { pool, boxes, boxAccess, boxOnboard, DoApi, CfTunnelApi: cfTunnel.CfTunnelApi, cfTunnel };
145
- return _deps;
146
- }
147
-
148
- function makeDo(apply) {
149
- const { DoApi } = loadDeps();
150
- return new DoApi({
151
- dryRun: !apply,
152
- token: process.env.DO_API_TOKEN || null,
153
- log: (m) => console.log(' ' + m),
154
- });
155
- }
156
-
157
- // A LIVE read-only DO client for enumerating droplets in the drift reconcile —
158
- // listing is non-mutating, so it runs for real even in dry-run planning (only the
159
- // DELETEs are gated by --apply, via the separate makeDo(apply) client). Returns
160
- // null when DO_API_TOKEN is unset (this is a control-plane command; the token
161
- // always lives on the prod box) so the caller can fail with a clear message
162
- // instead of the constructor's throw.
163
- function makeDoRead() {
164
- const token = process.env.DO_API_TOKEN || null;
165
- if (!token) return null;
166
- const { DoApi } = loadDeps();
167
- return new DoApi({ dryRun: false, token, log: (m) => console.log(' ' + m) });
168
- }
169
-
170
- // Best-effort outbound ping to the team Discord (mirrors ship.js's broadcast).
171
- // OUTBOUND messaging lives in scripts/discord/ as of ADR 0032/#607 — scripts/chat/
172
- // is inbound-only now, so this must NOT route through chat/post.js (it would fail
173
- // silently and the builder would never learn their box was reclaimed). Never throws.
174
- function postReclaimPing(text) {
175
- try {
176
- execFileSync('node', ['scripts/discord/post.js', text], { cwd: REPO_ROOT, stdio: 'pipe' });
177
- return { ok: true };
178
- } catch (e) {
179
- return { ok: false, error: e && e.message ? e.message : String(e) };
180
- }
181
- }
182
-
183
- // Resolve a builder by login or numeric id. Returns the full row or null.
184
- async function resolveBuilder(pool, ref) {
185
- const isId = /^\d+$/.test(String(ref));
186
- const { rows } = await pool.query(
187
- isId
188
- ? `SELECT id, github_login, display_name, rank, status FROM builders WHERE id = $1`
189
- : `SELECT id, github_login, display_name, rank, status FROM builders WHERE lower(github_login) = lower($1)`,
190
- [ref]
191
- );
192
- return rows[0] || null;
193
- }
194
-
195
- // A short, DNS-safe-ish suffix from the current time (CLI context — Date is fine).
196
- // ---- provision -------------------------------------------------------------
197
-
198
- // Pure: renders the /etc/otb/box.env content cloud-init bakes onto a box (task
199
- // 1002466). Split out of cmdProvision so GDS_BASE — and the named-tunnel block
200
- // baked in beside it — can be unit-tested without DB/DO/Cloudflare.
201
- //
202
- // GDS_BASE is the FULL api base (origin + /api/bongos), matching what
203
- // infra/box-report-*.sh already derive from the session file when the env var is
204
- // absent (`"$GDS_BASE/box/host-keys"` with no further path inserted) — every box
205
- // used to get this only as a fallback derived on-box; baking it explicitly closes
206
- // the one on-box script (box-source-fetch.sh, out-of-repo) that skips that
207
- // derivation entirely and hardcodes a different instance's origin instead.
208
- function buildBoxEnv({ apiOrigin, namedTunnel }) {
209
- let env = `# /etc/otb/box.env — baked by box.js provision\nGDS_BASE=${String(apiOrigin).replace(/\/+$/, '')}/api/bongos\n`;
210
- if (namedTunnel) {
211
- const { token, hostname, credential, previewHostname } = namedTunnel;
212
- env += `# named tunnel (#771/#774, ADR 0038)\nBOX_TUNNEL_TOKEN=${token}\nBOX_TERMINAL_HOSTNAME=${hostname}\nBOX_TERMINAL_CREDENTIAL=${credential}\nBOX_PREVIEW_HOSTNAME=${previewHostname}\n`;
213
- }
214
- return env;
215
- }
216
-
217
- async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
218
- const builder = await resolveBuilder(pool, ref);
219
- if (!builder) return fail(`no builder matches "${ref}"`);
220
- if (builder.status && builder.status !== 'active') {
221
- return fail(`builder @${builder.github_login} is ${builder.status}, not active — no box`);
222
- }
223
-
224
- // Rank gate (ADR 0016 trust boundary: checked against the live DB rank).
225
- try {
226
- boxes.assertProvisionAllowed({ rank: builder.rank, floor: CONFIG.floor });
227
- } catch (e) {
228
- return fail(`${e.message}`);
229
- }
230
-
231
- // Dry-run must touch nothing (#1034): READ the row instead of ensuring it, so a
232
- // bare `provision` (no --apply) cannot write a placeholder builder_boxes row. A
233
- // missing row is semantically 'none' — and 'none' is a valid provision source —
234
- // so map null→'none' for the gate (NOT the `!box || …` short-circuit the
235
- // park/wake paths use: those forbid 'none', but provision allows it). The row is
236
- // materialized under --apply below, mirroring wake/park/deprovision (all read via
237
- // getBoxByBuilderId).
238
- let box = await boxes.getBoxByBuilderId(pool, builder.id);
239
- const boxState = box ? box.state : 'none';
240
- if (!boxes.canTransition('provision', boxState)) {
241
- return fail(`box for @${builder.github_login} is '${boxState}' — provision only from none/destroyed/error (try wake?)`);
242
- }
243
-
244
- const scope = boxes.boxScopeForRank(builder.rank);
245
- const name = dropletName(builder.github_login);
246
- const hostname = hostnameFor(builder.github_login);
247
-
248
- // Bake the builder's registered SSH keys (#685) into the cloud-init so the
249
- // FIRST login works before the box has a GDS token. No keys yet → an empty
250
- // managed block (the box-authorized-keys-fetch cron fills it in once the
251
- // builder authenticates; onboard warns about this case upstream).
252
- const sshKeys = await boxOnboard.listSshKeys(pool, builder.id);
253
- const authorizedKeysBlock = boxOnboard.wrapManagedBlock(sshKeys);
254
-
255
- console.log(`provision box for @${builder.github_login} (${builder.rank} → scope=${scope})`);
256
- console.log(` region=${CONFIG.region} size=${CONFIG.size} image=${CONFIG.baseImage} host=${hostname}`);
257
- console.log(` baked SSH keys: ${sshKeys.length}${sshKeys.length === 0 ? ' (⚠ builder cannot SSH in until a key is registered — see `box.js onboard`)' : ''}`);
258
-
259
- // ADR 0150 — resolve the box cloud-init source up front (dry-run AND apply) and
260
- // FAIL LOUD if it can't be located. A missing infra/ means empty user-data, i.e.
261
- // a box that boots BARE. Aborting here (before any session/tunnel is minted)
262
- // surfaces the packaging bug in the PLAN, not as a silent bare box hours later.
263
- const infraDir = resolveBoxInfraDir();
264
- if (!infraDir) {
265
- return fail(
266
- `box cloud-init source not found — no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
267
- + `(<PREFIX>_BOX_INFRA_DIR, <PREFIX>_INSTANCE_ROOT / bongos.config.json, ${path.join(REPO_ROOT, 'infra')}, or a node_modules ancestor). `
268
- + `A box would boot BARE — refusing to provision. Ship infra/ or point the instance root at it (ADR 0150).`,
269
- );
270
- }
271
- console.log(` cloud-init source: ${infraDir}`);
272
-
273
- // Named-tunnel automation (#771, ADR 0038 Part B). OPT-IN: off → the box uses
274
- // the tokenless quick tunnel (#760 default) and box.env is left empty. On but
275
- // misconfigured → fail loud now (an operator who turned it on meant it).
276
- if (namedTunnelEnabled() && !namedTunnelConfigured()) {
277
- return fail('BOX_NAMED_TUNNEL is on but CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ACCOUNT_ID are not set — supply both (token needs Tunnel + DNS scope) or unset BOX_NAMED_TUNNEL for the quick-tunnel default');
278
- }
279
- const termHost = namedTunnelEnabled() ? `term-${builder.github_login}.${CF.terminalZone}` : null;
280
- if (namedTunnelEnabled()) {
281
- console.log(` named tunnel: ${termHost} (Cloudflare zone ${CF.zone})`);
282
- // Before ANY droplet spend, and on the dry-run path too, so `provision <login>`
283
- // with no --apply tells the operator the truth for free (task 1002511).
284
- await preflightCookieStripScope();
285
- } else {
286
- console.log(' browser terminal: quick tunnel (tokenless trycloudflare.com — set BOX_NAMED_TUNNEL=1 for a stable hostname)');
287
- }
288
-
289
- if (!apply) {
290
- console.log(' [dry-run] would create droplet + mark active. Re-run with --apply.');
291
- if (namedTunnelEnabled()) await makeCf(false).ensureNamedTunnel({ login: builder.github_login, zone: CF.zone, terminalZone: CF.terminalZone });
292
- return { ok: true, dryRun: true };
293
- }
294
-
295
- // --apply only: NOW materialize the box row (the dry-run path above left the DB
296
- // untouched, #1034). ensureBoxRow is idempotent — returns the existing row or
297
- // creates a fresh state='none' one — so box.id / box.provisioned_at below are
298
- // always real. Identical to the pre-#1034 --apply behavior; nothing between the
299
- // gate above and here reads or writes the box row.
300
- box = await boxes.ensureBoxRow(pool, builder.id);
301
-
302
- // Create (or reuse) the named tunnel BEFORE the droplet so its connector token
303
- // bakes into the cloud-init box.env. A failure here aborts provision — better
304
- // no box than a box that boots without its promised stable terminal.
305
- // #774: on the named-tunnel path the control plane knows everything the browser
306
- // on-ramp needs at provision time — the stable hostname (here) + a password we
307
- // generate. So bake the password into box.env (the box serves ttyd with it) and,
308
- // once the box is active, RECORD {url, password} into the GDS ourselves
309
- // (setTerminalAccess): the builder reads them from the hall, with no token placed
310
- // on the box and no box-side reporter (the quick-tunnel fallback that caused the
311
- // #771/#774 chicken-and-egg). ADR 0016 holds — no credential funneled into a box.
312
- // GDS_BASE (task 1002466 — see buildBoxEnv above for the shape/why): a box was
313
- // never actually bare here EXCEPT the one on-box script that skips the
314
- // session-file fallback (box-source-fetch.sh, out-of-repo on the control-plane
315
- // — see the cross-repo blocker filed alongside this fix). Baking it explicitly
316
- // closes that gap for every future box regardless of which script forgets it.
317
- const apiOrigin = instanceConfig.resolveEnv('PUBLIC_ORIGIN') || instanceConfig.defaultApiBase();
318
- let boxEnv = buildBoxEnv({ apiOrigin });
319
- let terminalHostname = null;
320
- let terminalCredential = null;
321
- if (namedTunnelEnabled()) {
322
- try {
323
- const { token, hostname: th, previewHostname: ph, edgeRules } = await makeCf(true).ensureNamedTunnel({
324
- login: builder.github_login, zone: CF.zone, terminalZone: CF.terminalZone,
325
- });
326
- terminalHostname = th;
327
- terminalCredential = crypto.randomBytes(24).toString('hex');
328
- // ADR 0044: ensureNamedTunnel also created the sandbox-<login> ingress route
329
- // + DNS for the live game preview. Bake its hostname so the box's
330
- // box-preview command can show the builder their URL (no DB row needed — the
331
- // host is deterministic; box-game-preview.service serves :3000 behind it).
332
- boxEnv = buildBoxEnv({
333
- apiOrigin,
334
- namedTunnel: { token, hostname: th, credential: terminalCredential, previewHostname: ph },
335
- });
336
- console.log(` ✓ named tunnel ready — ${th}`);
337
- console.log(` ✓ live game preview — https://${ph} (ADR 0044)`);
338
- // The rule PUT is the ONLY thing that proves edit scope. Report its real
339
- // outcome next to the ✓ lines so a silent miss lands in the summary rather
340
- // than mid-log (task 1002511).
341
- if (edgeRules && !edgeRules.cookieStrip.applied) {
342
- console.log(` ⚠⚠ SECURITY (ADR 0053 / SR#11): the edge cookie-strip Transform Rule was NOT applied${edgeRules.cookieStrip.error ? ` — ${edgeRules.cookieStrip.error}` : ''}`);
343
- console.log(` Platform cookies for .${CF.zone} can reach ${th} and ${ph} until it is.`);
344
- console.log(' Widen CLOUDFLARE_API_TOKEN with zone "Transform Rules → Edit", or apply the rule');
345
- console.log(' once by hand — see docs/recipes/builder-box-lifecycle.md.');
346
- }
347
- if (edgeRules && !edgeRules.cacheBypass.applied) {
348
- console.log(` ⚠ the sandbox cache-bypass Cache Rule was NOT applied${edgeRules.cacheBypass.error ? ` — ${edgeRules.cacheBypass.error}` : ''} (stale preview risk, #1065)`);
349
- }
350
- } catch (e) {
351
- await boxes.setBoxState(pool, builder.id, 'error', { error_note: `named tunnel: ${e.message}` });
352
- return fail(`named tunnel setup failed: ${e.message}`);
353
- }
354
- }
355
-
356
- // Bootstrap the builder's GDS session onto the box (#818). box-source-fetch (#600)
357
- // authenticates as the BUILDER to pull the rank-scoped repo; with no session file
358
- // it no-ops forever (repo never clones → no devcontainer, no /builder-* skills),
359
- // and the session normally comes from /builder-setup, which lives in the un-cloned
360
- // repo — the chicken-and-egg. So mint one here and bake it into cloud-init.
361
- //
362
- // SECURITY (#919, SR#2): this baked session is source='box' — SCOPED, 24h
363
- // refresh-on-use. The token sits ON the box (any on-box process + the metadata
364
- // endpoint can read it), OUTSIDE the ADR 0016 trust boundary, so it must not carry
365
- // the builder's full authority: requireBuilder denies a 'box' session everywhere
366
- // except the 4 on-box cron routes (source-access, authorized-keys, heartbeat,
367
- // terminal — via auth.allowBoxScope). Real work FROM the box needs /builder-reauth
368
- // (device flow); user_agent='box-provision' is the marker migration 090 keys on.
369
- const gdsDb = require(path.join(REPO_ROOT, 'src/bongos/db'));
370
- const sessionToken = crypto.randomBytes(32).toString('hex');
371
- await gdsDb.createSession({
372
- builderId: builder.id, token: sessionToken, source: 'box', userAgent: 'box-provision',
373
- });
374
- const gdsSession = JSON.stringify({
375
- token: sessionToken,
376
- builder: { id: builder.id, github_login: builder.github_login, rank: builder.rank },
377
- // The instance's OWN public origin (task 1002428) — a hardcoded founder host
378
- // here would point every newly provisioned box at the wrong instance.
379
- //
380
- // defaultApiBase(), deliberately NOT resolveApiBase() (task 1002466): a box
381
- // belongs to the SERVER it was provisioned on, not to whichever instance this
382
- // operator's own CLI session happens to be signed into right now — the two can
383
- // differ (an operator provisioning boxes across instances) and the box must
384
- // still call home to itself, not to the operator's other login.
385
- api_base: instanceConfig.resolveEnv('PUBLIC_ORIGIN') || instanceConfig.defaultApiBase(),
386
- }, null, 2);
387
-
388
- const doApi = makeDo(apply);
389
- // Render + size-check the cloud-init BEFORE touching DO (#903). DO rejects
390
- // user-data over 64 KiB with an opaque error; fail fast with an actionable one
391
- // so an over-budget cloud-init is caught here, not as a cryptic API 4xx.
392
- let userData;
393
- try {
394
- userData = readCloudInit({ authorizedKeysBlock, boxEnv, gdsSession, infraDir });
395
- } catch (e) {
396
- await boxes.setBoxState(pool, builder.id, 'error', { error_note: `cloud-init: ${e.message}`.slice(0, 480) });
397
- return fail(e.message);
398
- }
399
- const userDataBytes = userData ? Buffer.byteLength(userData, 'utf8') : 0;
400
- // Belt-and-braces (ADR 0150): readCloudInit throws above, but NEVER hand DO empty
401
- // user-data — that IS the bare-box failure this fix closes.
402
- if (!userDataBytes) {
403
- await boxes.setBoxState(pool, builder.id, 'error', { error_note: 'cloud-init render produced empty user-data' });
404
- return fail('cloud-init render produced EMPTY user-data — a box would boot bare (ADR 0150); refusing to provision.');
405
- }
406
- if (userDataBytes > MAX_USERDATA_BYTES) {
407
- await boxes.setBoxState(pool, builder.id, 'error', {
408
- error_note: `cloud-init ${userDataBytes}B > ${MAX_USERDATA_BYTES}B`,
409
- });
410
- return fail(`cloud-init user-data is ${userDataBytes}B, over DigitalOcean's ${MAX_USERDATA_BYTES}B limit — trim infra/box-source-fetch.sh or gzip more of the inlined files (infra/builder-box-cloud-init.yaml)`);
411
- }
412
- await boxes.setBoxState(pool, builder.id, 'provisioning', {
413
- scope, provisioned_rank: builder.rank, region: CONFIG.region, size_slug: CONFIG.size,
414
- droplet_name: name, hostname, error_note: null,
415
- // The droplet about to boot mints FRESH host keys, so anything the row still
416
- // carries describes a box that is gone. deprovisionOne clears these on teardown
417
- // (idea 310), but a provision over a row that never went through teardown — an
418
- // errored/parked box, a re-provision after a manual DO delete — kept serving the
419
- // predecessor's identity via GET /box/me, and the connect path pinned it: "Host
420
- // denied (verification failed)". Clear here too; the new box re-reports via
421
- // POST /box/host-keys, and until then the client falls back to a live keyscan.
422
- host_keys: null, host_keys_at: null,
423
- });
424
- try {
425
- const { droplet } = await doApi.createDroplet({
426
- name, region: CONFIG.region, size: CONFIG.size, image: CONFIG.baseImage,
427
- userData, tags: [CONFIG.tag, `builder:${builder.github_login}`],
428
- sshKeys: CONFIG.sshKeyIds.length ? CONFIG.sshKeyIds : undefined,
429
- });
430
- const { ip } = await doApi.settleDroplet(droplet.id);
431
- const now = new Date();
432
- await boxes.setBoxState(pool, builder.id, 'active', {
433
- droplet_id: String(droplet.id), ip, snapshot_id: null,
434
- provisioned_at: box.provisioned_at || now, active_since: now, last_activity_at: now,
435
- });
436
- await boxes.recordEvent(pool, {
437
- boxId: box.id, builderId: builder.id, event: 'provision',
438
- detail: `droplet ${droplet.id} ${ip || ''} ${CONFIG.size}`, actor: actorTag(),
439
- });
440
- // #602: log a spin-up event in the per-builder cost ledger.
441
- await boxes.recordSpinupCost(pool, { box, kind: 'provision', atMs: now.getTime(), actor: actorTag() });
442
- // #774: record the browser-terminal access NOW (named-tunnel path) so the hall
443
- // serves it immediately — no waiting on the box to report, no token on the box.
444
- // The box brings ttyd up at first boot with this same password (baked above).
445
- if (terminalHostname && terminalCredential) {
446
- try {
447
- await boxes.setTerminalAccess(pool, builder.id, {
448
- url: `https://${terminalHostname}`, credential: terminalCredential,
449
- });
450
- console.log(` ✓ terminal access recorded — https://${terminalHostname}`);
451
- } catch (e) {
452
- console.log(` ⚠ could not record terminal access (non-fatal): ${e.message}`);
453
- }
454
- }
455
- const dns = runDnsHook(hostname, ip);
456
- if (!dns.ok) {
457
- // task 1180: a stale DNS must NOT pass as a clean provision. Record it so the
458
- // box is queryable as DNS-stale (the box still provisions — failure-open).
459
- await boxes.recordEvent(pool, {
460
- boxId: box.id, builderId: builder.id, event: 'error',
461
- detail: `dns_repoint_failed: ${dns.error || 'DNS hook failed'} (${hostname} -> ${ip})`,
462
- actor: actorTag(),
463
- });
464
- }
465
- console.log(` ✓ active — droplet ${droplet.id}${ip ? ` @ ${ip}` : ''}${dns.ok ? '' : ' ⚠ DNS STALE — Desktop SSH will fail until repointed (browser terminal unaffected)'}`);
466
- return { ok: true };
467
- } catch (e) {
468
- await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
469
- await boxes.recordEvent(pool, { boxId: box.id, builderId: builder.id, event: 'error', detail: e.message, actor: actorTag() });
470
- return fail(`provision failed: ${e.message}`);
471
- }
472
- }
473
-
474
- // ---- onboard (the operator one-shot: provision + welcome packet) -----------
475
-
476
- // `box.js onboard <login>` is the operator's single command for #685: it
477
- // provisions the box (baking the builder's already-registered SSH keys) and then
478
- // prints a copy-paste "welcome packet" to forward to the builder. The canonical
479
- // order is: builder runs box-connect (registers key + installs managed-settings)
480
- // → operator runs onboard → builder restarts Desktop and connects. Because the
481
- // box can't fetch its own authorized_keys until the builder has a token ON the
482
- // box (chicken-and-egg), onboard REQUIRES at least one registered key by default;
483
- // --allow-no-keys overrides for an operator who knows they'll re-provision later.
484
- // The instance's product name, for the Claude Desktop environment label the
485
- // connect instructions name (task 1002428). Neutral fallback, never a foreign brand.
486
- function boxEnvLabel() {
487
- try {
488
- const id = require(path.join(REPO_ROOT, 'src/branding')).branding().identity || {};
489
- if (id.productName) return id.productName;
490
- } catch (_) { /* fall through */ }
491
- return 'Cloud Bongos';
492
- }
493
-
494
- function gdsBase() {
495
- // Session-aware, via the single source of truth (task 1002427/1002428) — the
496
- // operator's signed-in instance, not a hardcoded founder host. <PREFIX>_API_BASE
497
- // (incl. legacy GDS_API_BASE) still wins, so existing overrides are unchanged.
498
- return String(instanceConfig.resolveApiBase()).replace(/\/+$/, '');
499
- }
500
-
501
- function printWelcomePacket(login, scope) {
502
- const base = gdsBase();
503
- const host = hostnameFor(login);
504
- console.log('');
505
- console.log(`──────── welcome packet for @${login} — forward to the builder ────────`);
506
- console.log('');
507
- console.log('If you have not already connected this device, run ONE of these on your');
508
- console.log('own Mac/Windows (it signs you in, makes + registers your SSH key, and');
509
- console.log('installs the Claude Desktop connection file):');
510
- console.log('');
511
- console.log(` macOS/Linux: curl -fsSL ${base}/api/bongos/box/connect.sh | bash`);
512
- console.log(` Windows: irm ${base}/api/bongos/box/connect.ps1 | iex`);
513
- console.log(` (guide: ${base}/api/bongos/box/connect )`);
514
- console.log('');
515
- console.log(`Then: restart Claude Desktop → Code → Environment → "${boxEnvLabel()} Dev Box".`);
516
- console.log('');
517
- console.log('On a Chromebook / browser-only device (no Claude Desktop)? Skip the above —');
518
- console.log('your box serves its own terminal in a browser tab (ADR 0038). Once it is');
519
- console.log('active, get the URL + password and open it:');
520
- console.log(` GET ${base}/api/bongos/box/terminal (or run /builder-box)`);
521
- console.log('In that terminal, type `claude` to start working.');
522
- console.log('');
523
- console.log(`Your box: ${host} (scope=${scope})`);
524
- console.log('Your memory + credits are server-side and follow you to any box (ADR 0024).');
525
- console.log('───────────────────────────────────────────────────────────────────────');
526
- }
527
-
528
- async function cmdOnboard(pool, boxes, boxOnboard, ref, { apply, allowNoKeys }) {
529
- const builder = await resolveBuilder(pool, ref);
530
- if (!builder) return fail(`no builder matches "${ref}"`);
531
-
532
- const keys = await boxOnboard.listSshKeys(pool, builder.id);
533
- if (keys.length === 0 && !allowNoKeys) {
534
- console.error(`✖ @${builder.github_login} has no registered SSH key yet — the box would have no way in.`);
535
- console.error('');
536
- console.error(' Ask the builder to connect FIRST (this registers their key), then re-run onboard:');
537
- console.error(` macOS/Linux: curl -fsSL ${gdsBase()}/api/bongos/box/connect.sh | bash`);
538
- console.error(` Windows: irm ${gdsBase()}/api/bongos/box/connect.ps1 | iex`);
539
- console.error('');
540
- console.error(' (Override with --allow-no-keys to provision a keyless box anyway — you will');
541
- console.error(' then have to re-provision once they register a key.)');
542
- return fail('no SSH key registered for this builder');
543
- }
544
-
545
- console.log(`onboard @${builder.github_login} — provision + welcome packet (${keys.length} SSH key(s) registered)`);
546
- const result = await cmdProvision(pool, boxes, boxOnboard, ref, { apply });
547
- if (result && result.ok === false) return result;
548
-
549
- const scope = boxes.boxScopeForRank(builder.rank);
550
- printWelcomePacket(builder.github_login, scope);
551
- if (!apply) console.log('\n[dry-run] no box created — re-run with --apply to provision for real.');
552
- return { ok: true, dryRun: !apply };
553
- }
554
-
555
- // ---- park (snapshot + destroy) ---------------------------------------------
556
-
557
- async function parkOne(pool, boxes, doApi, box, { actor }) {
558
- const now = Date.now();
559
- await boxes.setBoxState(pool, box.builder_id, 'parking');
560
- // Power off for a clean snapshot, snapshot, wait, then destroy the droplet.
561
- await doApi.powerOffDroplet(box.droplet_id).then((r) => r.action && doApi.waitForAction(r.action.id));
562
- const snapName = `otb-snap-${box.droplet_name || box.builder_id}-${shortStamp()}`;
563
- const snapRes = await doApi.snapshotDroplet(box.droplet_id, snapName);
564
- if (snapRes.action) await doApi.waitForAction(snapRes.action.id);
565
- // Resolve the new snapshot id from the droplet's snapshot_ids (live mode).
566
- let snapshotId = null;
567
- if (!doApi.dryRun) {
568
- const { droplet } = await doApi.getDroplet(box.droplet_id);
569
- const ids = (droplet && droplet.snapshot_ids) || [];
570
- snapshotId = ids.length ? String(ids[ids.length - 1]) : null;
571
- } else {
572
- snapshotId = `dryrun-snap-${box.builder_id}`;
573
- }
574
- // Accrue the compute cost for this active interval BEFORE we lose active_since.
575
- await boxes.recordComputeCost(pool, { box, untilMs: now, actor });
576
- await doApi.deleteDroplet(box.droplet_id);
577
- await boxes.setBoxState(pool, box.builder_id, 'parked', {
578
- snapshot_id: snapshotId, parked_at: new Date(now),
579
- droplet_id: null, ip: null, active_since: null,
580
- // task 1003507 — the droplet is gone, so there is no `claude` process to
581
- // report. Clearing the flag AND its clock here means a wake starts a fresh
582
- // session rather than inheriting an expired latch and being reaped at once.
583
- claude_active: false, claude_active_since: null,
584
- });
585
- await boxes.recordEvent(pool, {
586
- boxId: box.id, builderId: box.builder_id, event: 'park',
587
- detail: `snapshot ${snapshotId}; droplet ${box.droplet_id} destroyed`, actor,
588
- });
589
- }
590
-
591
- async function cmdPark(pool, boxes, ref, { apply }) {
592
- const builder = await resolveBuilder(pool, ref);
593
- if (!builder) return fail(`no builder matches "${ref}"`);
594
- const box = await boxes.getBoxByBuilderId(pool, builder.id);
595
- if (!box || !boxes.canTransition('park', box.state)) {
596
- return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — park only from active`);
597
- }
598
- console.log(`park box for @${builder.github_login} (droplet ${box.droplet_id})`);
599
- if (!apply) { console.log(' [dry-run] would snapshot + destroy droplet. Re-run with --apply.'); return { ok: true, dryRun: true }; }
600
- try {
601
- await parkOne(pool, boxes, makeDo(apply), box, { actor: actorTag() });
602
- console.log(' ✓ parked (snapshot kept; droplet destroyed — compute billing stopped)');
603
- return { ok: true };
604
- } catch (e) {
605
- await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
606
- return fail(`park failed: ${e.message}`);
607
- }
608
- }
609
-
610
- // ---- wake (recreate from snapshot) -----------------------------------------
611
-
612
- async function cmdWake(pool, boxes, ref, { apply }) {
613
- const builder = await resolveBuilder(pool, ref);
614
- if (!builder) return fail(`no builder matches "${ref}"`);
615
- const box = await boxes.getBoxByBuilderId(pool, builder.id);
616
- if (!box || !boxes.canTransition('wake', box.state)) {
617
- return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — wake only from parked`);
618
- }
619
- if (!box.snapshot_id) return fail(`parked box for @${builder.github_login} has no snapshot_id — cannot wake (deprovision + re-provision)`);
620
- console.log(`wake box for @${builder.github_login} (from snapshot ${box.snapshot_id})`);
621
- if (!apply) { console.log(' [dry-run] would recreate droplet from snapshot. Re-run with --apply.'); return { ok: true, dryRun: true }; }
622
- const doApi = makeDo(apply);
623
- await boxes.setBoxState(pool, builder.id, 'waking');
624
- try {
625
- const { droplet } = await doApi.createDropletFromSnapshot({
626
- name: box.droplet_name || dropletName(builder.github_login),
627
- region: box.region, size: box.size_slug, snapshotId: box.snapshot_id,
628
- tags: [CONFIG.tag, `builder:${builder.github_login}`],
629
- sshKeys: CONFIG.sshKeyIds.length ? CONFIG.sshKeyIds : undefined,
630
- });
631
- const { ip } = await doApi.settleDroplet(droplet.id);
632
- const now = new Date();
633
- await boxes.setBoxState(pool, builder.id, 'active', {
634
- droplet_id: String(droplet.id), ip, active_since: now, last_activity_at: now, parked_at: null,
635
- // task 1003507 — a woken box has reported nothing yet. Start the latch and
636
- // its clock clean so the first real heartbeat sets both, rather than the
637
- // box re-entering the sweep carrying a stale claim from its last session.
638
- claude_active: false, claude_active_since: null,
639
- });
640
- await boxes.recordEvent(pool, { boxId: box.id, builderId: builder.id, event: 'wake', detail: `droplet ${droplet.id} ${ip || ''}`, actor: actorTag() });
641
- // #602: the box just ended a PARKED interval (snapshot retained) — accrue its
642
- // storage cost before active_since restarts compute, then log the wake spin-up.
643
- // `box` is the pre-wake row, so it still carries parked_at.
644
- await boxes.recordStorageCost(pool, { box, untilMs: now.getTime(), actor: actorTag() });
645
- await boxes.recordSpinupCost(pool, { box, kind: 'wake', atMs: now.getTime(), actor: actorTag() });
646
- const wakeHost = box.hostname || hostnameFor(builder.github_login);
647
- const dns = runDnsHook(wakeHost, ip);
648
- if (!dns.ok) {
649
- await boxes.recordEvent(pool, {
650
- boxId: box.id, builderId: builder.id, event: 'error',
651
- detail: `dns_repoint_failed: ${dns.error || 'DNS hook failed'} (${wakeHost} -> ${ip})`,
652
- actor: actorTag(),
653
- });
654
- }
655
- console.log(` ✓ active — droplet ${droplet.id}${ip ? ` @ ${ip}` : ''}${dns.ok ? '' : ' ⚠ DNS STALE — Desktop SSH will fail until repointed (browser terminal unaffected)'}`);
656
- return { ok: true };
657
- } catch (e) {
658
- await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
659
- return fail(`wake failed: ${e.message}`);
660
- }
661
- }
662
-
663
- // ---- deprovision (destroy droplet + snapshot) ------------------------------
664
-
665
- // Idempotent delete with a few retries for transient DigitalOcean blips. A 404
666
- // means the resource is ALREADY gone — that is success, not failure (the whole
667
- // point of an idempotent teardown). Returns { ok:true } once the resource is
668
- // confirmed absent, or { ok:false, error } after the retry budget is spent on a
669
- // real (non-404) failure. `sleepFn` is injectable so tests drive it instantly.
670
- async function deleteWithRetry(fn, { tries = 3, sleepMs = 2000, sleepFn } = {}) {
671
- const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
672
- let lastErr = null;
673
- for (let i = 0; i < tries; i++) {
674
- try {
675
- await fn();
676
- return { ok: true, alreadyGone: false };
677
- } catch (e) {
678
- if (e && e.status === 404) return { ok: true, alreadyGone: true };
679
- lastErr = e;
680
- if (i < tries - 1) await sleep(sleepMs);
681
- }
682
- }
683
- return { ok: false, error: lastErr || new Error('delete failed') };
684
- }
685
-
686
- // CONFIRM-BEFORE-DESTROYED (task 1289). The old teardown swallowed a failed
687
- // droplet delete (`.catch(log)`) and then set state='destroyed' UNCONDITIONALLY,
688
- // nulling droplet_id — so a transient DO failure left a droplet running + billing
689
- // + SSH-reachable while the DB claimed it was gone (the zombie). Now the droplet
690
- // delete is the GATE: it is retried + idempotent, and if it genuinely fails the box
691
- // is parked at state='error' with its droplet_id PRESERVED (so it's queryable and
692
- // the drift reconcile can find it) and the function THROWS — the row never reaches
693
- // 'destroyed' until DO confirms the droplet is gone. Snapshot/tunnel/DNS stay
694
- // best-effort (a leftover snapshot is cost-only; the box is already gone for the
695
- // tunnel/DNS), but the snapshot delete is now idempotent too.
696
- async function deprovisionOne(pool, boxes, doApi, box, { actor, login, sleepFn } = {}) {
697
- const now = Date.now();
698
- if (box.state === 'active' && box.droplet_id) {
699
- await boxes.recordComputeCost(pool, { box, untilMs: now, actor });
700
- }
701
- // #602: deprovisioning a PARKED box ends a snapshot-storage interval — accrue it
702
- // (no-op for an active/never-parked box, where parked_at is null).
703
- if (box.parked_at) {
704
- await boxes.recordStorageCost(pool, { box, untilMs: now, actor });
705
- }
706
- if (box.droplet_id) {
707
- const r = await deleteWithRetry(() => doApi.deleteDroplet(box.droplet_id), { sleepFn });
708
- if (!r.ok) {
709
- // Do NOT mark 'destroyed' — that is the bug. Park at 'error' with droplet_id
710
- // intact so the row stays truthful (a droplet is still out there) and throw.
711
- const msg = (r.error && r.error.message) || 'droplet delete failed';
712
- await boxes.setBoxState(pool, box.builder_id, 'error', {
713
- error_note: `droplet ${box.droplet_id} delete failed (not destroyed): ${msg}`,
714
- });
715
- await boxes.recordEvent(pool, {
716
- boxId: box.id, builderId: box.builder_id, event: 'error',
717
- detail: `deprovision aborted: droplet ${box.droplet_id} delete failed: ${msg}`, actor,
718
- });
719
- throw r.error || new Error(msg);
720
- }
721
- }
722
- if (box.snapshot_id) {
723
- const sr = await deleteWithRetry(() => doApi.deleteSnapshot(box.snapshot_id), { sleepFn });
724
- if (!sr.ok) console.log(` ⚠ snapshot delete (left behind, cost-only): ${(sr.error && sr.error.message) || 'failed'}`);
725
- }
726
- // Named-tunnel teardown (#771, ADR 0038 Part B). Best-effort — the box is gone
727
- // by now, so a leftover tunnel/DNS is only cosmetic, never a blocker. The
728
- // droplet was destroyed above, dropping the connector connections, so the
729
- // tunnel deletes cleanly. login is the deterministic key (tunnel name + host).
730
- const who = login || box.github_login;
731
- if (namedTunnelConfigured() && who) {
732
- try {
733
- const res = await makeCf(!doApi.dryRun).teardownNamedTunnel({ login: who, zone: CF.zone, terminalZone: CF.terminalZone });
734
- if (res.tunnelDeleted || res.dnsDeleted) console.log(` ✓ named tunnel torn down (tunnel=${res.tunnelDeleted} dns=${res.dnsDeleted})`);
735
- for (const err of res.errors) console.log(` ⚠ tunnel teardown ${err}`);
736
- } catch (e) { console.log(` ⚠ named tunnel teardown: ${e.message}`); }
737
- }
738
- // task 1180 + idea 309: park the per-builder SSH A record at the blackhole
739
- // sentinel (don't delete it) after teardown. This still stops the record
740
- // dangling at a recycled DO IP (the amplifier behind the stale-DNS outage),
741
- // AND it avoids the NXDOMAIN gap that a delete-then-recreate opened on a
742
- // reap→rebuild (idea 309): a deleted name negative-caches for the zone neg-TTL
743
- // (~30 min), so a client that queried mid-gap kept getting ENOTFOUND long after
744
- // the box was back. The name now always resolves (to a non-routable sentinel),
745
- // so there's nothing to negative-cache; the next provision/wake repoints it to
746
- // the live IP in place. Best-effort/non-fatal, like the tunnel teardown above;
747
- // box.hostname was stamped at provision, and the browser terminal rides the
748
- // tunnel, not this record.
749
- const dnsHost = box.hostname || (who ? hostnameFor(who) : null);
750
- if (CONFIG.dnsHook && dnsHost) runDnsHookBlackhole(dnsHost);
751
- await boxes.setBoxState(pool, box.builder_id, 'destroyed', {
752
- destroyed_at: new Date(now), droplet_id: null, ip: null, snapshot_id: null,
753
- active_since: null, parked_at: null, claude_active: false, claude_active_since: null,
754
- // idea 310: a rebuilt box mints fresh SSH host keys, but the GDS row kept the
755
- // OLD keys until the new box re-reported (~10 min). In that window GET /box/me
756
- // served a stale identity and the desktop app pinned the WRONG key — the
757
- // "Host denied (verification failed)" that locked builders out. Clear them on
758
- // teardown; the next box re-reports via POST /box/host-keys on provision. (A
759
- // park→wake keeps its keys via the snapshot, so this only fires on full destroy.)
760
- host_keys: null, host_keys_at: null,
761
- });
762
- // Post-'destroyed' audit-log write. The teardown is already COMMITTED above
763
- // (droplet confirmed gone, row at 'destroyed', ids nulled), so this event is
764
- // best-effort like the snapshot/tunnel/DNS steps — it MUST NOT throw. A failed
765
- // audit write here used to propagate to the caller's catch (the sweep /
766
- // deprovision / reconcile loops), which then flipped the just-committed
767
- // 'destroyed' back to 'error' with a null droplet_id — the false-error that
768
- // stranded builder 95 on 2026-07-04 (task 2034). Swallow + log instead; the
769
- // markErrorUnlessDestroyed guard below is the second line of defence for any
770
- // OTHER post-commit write (e.g. the reconcile path's recordSourceEvent).
771
- try {
772
- await boxes.recordEvent(pool, {
773
- boxId: box.id, builderId: box.builder_id, event: 'deprovision',
774
- detail: `droplet ${box.droplet_id || '-'} + snapshot ${box.snapshot_id || '-'} destroyed`, actor,
775
- });
776
- } catch (e) {
777
- console.log(` ⚠ teardown complete (state=destroyed) but the deprovision audit event failed to record: ${e.message}`);
778
- }
779
- }
780
-
781
- // A teardown that has already COMMITTED state='destroyed' must never be flipped
782
- // back to 'error' by a LATER failure. deprovisionOne commits 'destroyed' and then
783
- // runs best-effort follow-ups (its own recordEvent — now wrapped — and, on the
784
- // reconcile path, recordSourceEvent); if one of those throws it lands in the
785
- // caller's catch, which would otherwise overwrite the good 'destroyed' with
786
- // 'error' and a null droplet_id (the false-error that stranded builder 95,
787
- // 2026-07-04 — task 2034). This guard re-reads the row and only parks at 'error'
788
- // when the box did NOT reach 'destroyed'. Never throws (it runs inside a catch).
789
- async function markErrorUnlessDestroyed(pool, boxes, builderId, errNote) {
790
- try {
791
- const current = await boxes.getBoxByBuilderId(pool, builderId);
792
- if (current && current.state === 'destroyed') {
793
- console.log(` ⚠ teardown completed (state=destroyed); ignoring a post-teardown failure: ${errNote}`);
794
- return;
795
- }
796
- await boxes.setBoxState(pool, builderId, 'error', { error_note: errNote });
797
- } catch (e) {
798
- console.log(` ⚠ could not record error state for builder ${builderId}: ${e.message}`);
799
- }
800
- }
801
-
802
- async function cmdDeprovision(pool, boxes, ref, { apply }) {
803
- const builder = await resolveBuilder(pool, ref);
804
- if (!builder) return fail(`no builder matches "${ref}"`);
805
- const box = await boxes.getBoxByBuilderId(pool, builder.id);
806
- if (!box || !boxes.canTransition('deprovision', box.state)) {
807
- return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — nothing to deprovision`);
808
- }
809
- console.log(`deprovision box for @${builder.github_login} (droplet ${box.droplet_id || '-'}, snapshot ${box.snapshot_id || '-'})`);
810
- if (!apply) { console.log(' [dry-run] would destroy droplet AND snapshot. Re-run with --apply.'); return { ok: true, dryRun: true }; }
811
- try {
812
- await deprovisionOne(pool, boxes, makeDo(apply), box, { actor: actorTag(), login: builder.github_login });
813
- console.log(' ✓ destroyed (droplet + snapshot gone; all box cost stopped)');
814
- return { ok: true };
815
- } catch (e) {
816
- await markErrorUnlessDestroyed(pool, boxes, builder.id, e.message);
817
- return fail(`deprovision failed: ${e.message}`);
818
- }
819
- }
820
-
821
- // ---- sweep-idle ------------------------------------------------------------
822
- // Idle boxes are PARKED: snapshot taken, droplet destroyed (compute billing
823
- // stops), filesystem preserved so the builder wakes into their work. Snapshot
824
- // storage is ~$0.30/mo — the price of not silently discarding uncommitted work.
825
- //
826
- // This sweep deliberately never destroys a snapshot. Full reclaim (droplet AND
827
- // snapshot) belongs to sweep-dormant, which only fires after BOX_DORMANT_DAYS of
828
- // being parked and pings the builder first. That split is the whole safety
829
- // property: no automated path can take work off a builder without warning.
830
- //
831
- // History: this sweep used to call deprovisionOne directly, on the theory that
832
- // boxes are ephemeral and builders provision fresh each session. In practice it
833
- // meant a box idle past the threshold was annihilated — droplet and snapshot —
834
- // taking anything uncommitted with it, while the CLI help, the /builder-box
835
- // skill and the hall all still described park-and-wake. Task 1002726 restored
836
- // the documented behaviour rather than the documentation.
837
-
838
- async function cmdSweepIdle(pool, boxes, { apply }) {
839
- const rows = await boxes.listBoxes(pool, { state: 'active' });
840
- const sweepNow = Date.now();
841
- const sel = { idleMinutes: CONFIG.idleMinutes, nowMs: sweepNow, unattendedMaxHours: CONFIG.unattendedMaxHours };
842
- const idle = boxes.selectIdleBoxes(rows, sel);
843
- console.log(`idle sweep (threshold=${CONFIG.idleMinutes}m, unattended cap=${CONFIG.unattendedMaxHours}h${apply ? '' : ', dry-run'}): ${idle.length} of ${rows.length} active box(es) idle`);
844
- if (idle.length === 0) { console.log(' nothing idle — exit clean'); return { parked: 0 }; }
845
- const doApi = apply ? makeDo(apply) : null;
846
- let parked = 0, errors = 0;
847
- for (const box of idle) {
848
- const why = boxes.idleReason(box, sel);
849
- if (!apply) { console.log(` would park @${box.github_login}'s box (${why}, droplet ${box.droplet_id})`); continue; }
850
- try {
851
- await parkOne(pool, boxes, doApi, box, { actor: 'sweep:idle' });
852
- parked++;
853
- console.log(` ✓ parked @${box.github_login} (${why} — snapshot kept, compute stopped)`);
854
- } catch (e) {
855
- // A park that fails must NOT fall through to a destroy — leaving the box
856
- // at 'error' with its droplet intact is strictly safer than reclaiming it,
857
- // and reconcile-drift picks the row up from there.
858
- errors++;
859
- await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
860
- console.log(` ✖ @${box.github_login}: ${e.message}`);
861
- }
862
- }
863
- console.log(`done — parked=${parked} errors=${errors} dry-run=${!apply}`);
864
- return { parked, errors };
865
- }
866
-
867
- // ---- sweep-dormant ---------------------------------------------------------
868
- // Task 1002727 — since the idle sweep parks rather than destroys (task 1002726),
869
- // this is the ONLY automated path that deletes a snapshot, i.e. the only one that
870
- // can take a builder's uncommitted work. So it warns before it takes: a box
871
- // entering the warning window gets one heads-up, and a box past the threshold is
872
- // reclaimed ONLY if that warning was actually delivered during its current parked
873
- // interval. If it never was — runner downtime, a chat outage, a box that sailed
874
- // past the window while the sweep was off — the reclaim is DEFERRED and the
875
- // warning is sent instead. That way "no automated path silently discards work"
876
- // holds even when the machinery itself misbehaves.
877
-
878
- async function cmdSweepDormant(pool, boxes, { apply }) {
879
- const rows = await boxes.listBoxes(pool, { state: 'parked' });
880
- const now = Date.now();
881
- const dormant = boxes.selectDormantBoxes(rows, { dormantDays: CONFIG.dormantDays, nowMs: now });
882
- const warnAt = Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0);
883
- const warning = boxes.selectDormantWarnBoxes(rows, {
884
- dormantDays: CONFIG.dormantDays, warnLeadDays: CONFIG.dormantWarnLeadDays, nowMs: now,
885
- });
886
- console.log(`dormant sweep (reclaim >${CONFIG.dormantDays}d, warn >${warnAt}d${apply ? '' : ', dry-run'}): ${dormant.length} dormant, ${warning.length} entering the warning window, of ${rows.length} parked`);
887
- if (dormant.length === 0 && warning.length === 0) { console.log(' nothing dormant — exit clean'); return { reclaimed: 0, warned: 0, deferred: 0 }; }
888
- // Built lazily: a sweep that only WARNS must not require a DO token.
889
- let doApi = null;
890
- const needDo = () => (doApi || (doApi = makeDo(apply)));
891
- let reclaimed = 0, errors = 0, pingFailed = 0, warned = 0, deferred = 0;
892
-
893
- const sendWarning = async (box, days) => {
894
- const left = Math.max(CONFIG.dormantDays - days, 0);
895
- const ping = postReclaimPing(`@${box.github_login} — your dev box has been parked ${days} days. Its saved disk image will be reclaimed in ${left} day(s) to save cost, and anything on it you haven't pushed will be gone with it. Wake it any time with POST /api/bongos/box/ensure to keep it.`);
896
- if (!ping.ok) { pingFailed++; return false; }
897
- await boxes.recordEvent(pool, {
898
- boxId: box.id, builderId: box.builder_id, event: 'dormant_warning',
899
- detail: `parked ${days}d; reclaim in ${left}d`, actor: 'sweep:dormant',
900
- });
901
- warned++;
902
- return true;
903
- };
904
-
905
- for (const box of warning) {
906
- const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
907
- if (!apply) { console.log(` would warn @${box.github_login} (parked ${days}d)`); continue; }
908
- if (await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at })) continue;
909
- const ok = await sendWarning(box, days);
910
- console.log(` ${ok ? '✓ warned' : '✖ warning FAILED for'} @${box.github_login} (parked ${days}d)`);
911
- }
912
-
913
- for (const box of dormant) {
914
- const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
915
- if (!apply) { console.log(` would reclaim @${box.github_login}'s box (parked ${days}d, snapshot ${box.snapshot_id})`); continue; }
916
- // The gate: no receipt, no reclaim. Warn now, let a later sweep take it.
917
- if (!(await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at }))) {
918
- deferred++;
919
- const ok = await sendWarning(box, days);
920
- console.log(` ⏸ deferred @${box.github_login} (parked ${days}d) — never warned; ${ok ? 'warning sent, reclaim next sweep' : 'warning FAILED, will retry'}`);
921
- continue;
922
- }
923
- try {
924
- await deprovisionOne(pool, boxes, needDo(), box, { actor: 'sweep:dormant' });
925
- reclaimed++;
926
- const ping = postReclaimPing(`@${box.github_login} — your dev box was parked for ${days} days and has now been reclaimed to save cost, as warned. Queue a fresh one anytime with POST /api/bongos/box/ensure (no operator needed); your memory is server-side and unaffected.`);
927
- if (!ping.ok) pingFailed++;
928
- console.log(` ✓ reclaimed @${box.github_login} (parked ${days}d)${ping.ok ? '' : ' [chat ping failed]'}`);
929
- } catch (e) { errors++; await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message); console.log(` ✖ @${box.github_login}: ${e.message}`); }
930
- }
931
- console.log(`done — reclaimed=${reclaimed} warned=${warned} deferred=${deferred} ping-failed=${pingFailed} errors=${errors} dry-run=${!apply}`);
932
- return { reclaimed, warned, deferred, errors, pingFailed };
933
- }
934
-
935
- // ---- reconcile (source-access clawback) ------------------------------------
936
-
937
- // The control-plane half of rank-gated source access (ADR 0031 §6, #600). The
938
- // CREDENTIAL layer is already gated live on every GET /box/source-access fetch, so
939
- // revocation is instant there; this is the slower droplet-level reconcile that
940
- // brings each box into line with its builder's LIVE rank+status:
941
- // - below the floor / builder inactive → deprovision (no box, no source)
942
- // - 'full' box whose builder fell below Metic+ → deprovision (can't un-see the
943
- // crown jewels; a fresh starter box can be provisioned)
944
- // - 'starter' box whose builder graduated to Metic+ → rescope to 'full' (the box
945
- // re-clones the full surface on its next source-access refresh)
946
- // Run it on a schedule (alongside the sweeps) for hands-off offboarding. DRY-RUN
947
- // DEFAULT — pass --apply to actually deprovision / rescope.
948
- async function cmdReconcile(pool, boxes, boxAccess, { apply }) {
949
- const rows = await boxAccess.listBoxesForReconcile(pool);
950
- const actions = rows
951
- .map((box) => ({
952
- box,
953
- decision: boxAccess.reconcileSourceAccess({
954
- boxScope: box.scope, boxState: box.state,
955
- rank: box.rank, status: box.status, floor: CONFIG.floor,
956
- }),
957
- }))
958
- .filter((x) => x.decision.action !== 'none');
959
-
960
- console.log(`source-access reconcile (floor=${CONFIG.floor}${apply ? '' : ', dry-run'}): ${actions.length} of ${rows.length} box(es) need action`);
961
- if (actions.length === 0) { console.log(' all boxes in sync — exit clean'); return { deprovisioned: 0, rescoped: 0 }; }
962
-
963
- const doApi = apply ? makeDo(apply) : null;
964
- let deprovisioned = 0, rescoped = 0, errors = 0;
965
- for (const { box, decision } of actions) {
966
- if (!apply) {
967
- console.log(` would ${decision.action} @${box.github_login}'s box — ${decision.reason} (scope=${box.scope}, rank=${box.rank}, status=${box.status})`);
968
- continue;
969
- }
970
- try {
971
- if (decision.action === 'deprovision') {
972
- await deprovisionOne(pool, boxes, doApi, box, { actor: 'reconcile:clawback' });
973
- await boxAccess.recordSourceEvent(pool, {
974
- box, builderId: box.builder_id, event: 'source_revoke',
975
- detail: `deprovisioned: ${decision.reason} (rank=${box.rank}, status=${box.status})`, actor: 'reconcile',
976
- });
977
- deprovisioned++;
978
- console.log(` ✓ deprovisioned @${box.github_login} — ${decision.reason}`);
979
- } else if (decision.action === 'rescope') {
980
- await boxes.setBoxState(pool, box.builder_id, box.state, {
981
- scope: decision.newScope, provisioned_rank: box.rank,
982
- });
983
- await boxAccess.recordSourceEvent(pool, {
984
- box, builderId: box.builder_id, event: 'source_rescope',
985
- detail: `${box.scope}→${decision.newScope}: ${decision.reason}`, actor: 'reconcile',
986
- });
987
- rescoped++;
988
- console.log(` ✓ rescoped @${box.github_login} ${box.scope}→${decision.newScope} (box re-clones on next refresh)`);
989
- }
990
- } catch (e) {
991
- errors++;
992
- await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
993
- console.log(` ✖ @${box.github_login}: ${e.message}`);
994
- }
995
- }
996
- console.log(`done — deprovisioned=${deprovisioned} rescoped=${rescoped} errors=${errors} dry-run=${!apply}`);
997
- return { deprovisioned, rescoped, errors };
998
- }
999
-
1000
- // ---- reconcile-drift (DO droplet list ⇄ GDS box rows, task 1289) -----------
1001
-
1002
- // Best-effort teardown of a login's edge resources (Cloudflare named tunnel + the
1003
- // per-builder DNS A record), shared by the zombie + ghost reconcile paths so no
1004
- // orphan tunnel/DNS is left behind. Never throws — the droplet (the thing that
1005
- // bills + is SSH-reachable) is the hard part; a leftover tunnel/DNS is cosmetic.
1006
- async function teardownEdgeForLogin(login, { apply }) {
1007
- if (!login) return;
1008
- if (namedTunnelConfigured()) {
1009
- try {
1010
- const res = await makeCf(apply).teardownNamedTunnel({ login, zone: CF.zone, terminalZone: CF.terminalZone });
1011
- if (res.tunnelDeleted || res.dnsDeleted) console.log(` ✓ tunnel torn down (tunnel=${res.tunnelDeleted} dns=${res.dnsDeleted})`);
1012
- for (const err of res.errors) console.log(` ⚠ tunnel teardown ${err}`);
1013
- } catch (e) { console.log(` ⚠ tunnel teardown: ${e.message}`); }
1014
- }
1015
- if (CONFIG.dnsHook && apply) runDnsHookBlackhole(hostnameFor(login));
1016
- }
1017
-
1018
- // The bidirectional drift sweep. Lists the REAL fleet droplets (live read, even in
1019
- // dry-run) and reconciles against the GDS box rows:
1020
- // - ZOMBIE (droplet exists, no live row claims it) → force-delete droplet + edge.
1021
- // - GHOST (live row, its droplet vanished from DO) → mark 'destroyed' + edge.
1022
- // This is the backstop for any teardown that half-failed (now far less likely with
1023
- // confirm-before-destroyed in deprovisionOne) and for out-of-band DO changes.
1024
- // DRY-RUN DEFAULT — pass --apply to actually delete / mark.
1025
- async function cmdReconcileDrift(pool, boxes, { apply }) {
1026
- const readApi = makeDoRead();
1027
- if (!readApi) {
1028
- return fail('reconcile-drift needs DO_API_TOKEN to enumerate droplets — run on the control plane (prod droplet)');
1029
- }
1030
- let droplets;
1031
- try {
1032
- ({ droplets } = await readApi.listDroplets({ tag: CONFIG.tag }));
1033
- } catch (e) {
1034
- return fail(`could not list DigitalOcean droplets: ${e.message}`);
1035
- }
1036
- const boxRows = await boxes.listBoxes(pool, {});
1037
- const { zombies, ghosts, skippedYoung } = boxes.planDropletDrift({
1038
- droplets: droplets || [], boxRows, nowMs: Date.now(),
1039
- });
1040
-
1041
- console.log(`drift reconcile (fleet tag=${CONFIG.tag}${apply ? '' : ', dry-run'}): ${(droplets || []).length} droplet(s) live, ${boxRows.length} box row(s)`);
1042
- console.log(` zombies=${zombies.length} ghosts=${ghosts.length} skipped-young=${skippedYoung.length}`);
1043
- for (const z of skippedYoung) console.log(` · skip young droplet ${z.dropletId} (${z.name || '?'}) — likely a provision in flight`);
1044
- if (zombies.length === 0 && ghosts.length === 0) { console.log(' DO ⇄ DB consistent — exit clean'); return { zombies: 0, ghosts: 0 }; }
1045
-
1046
- const mutateDo = makeDo(apply); // dry-run logs the DELETEs; --apply executes them
1047
- let killed = 0, marked = 0, errors = 0;
1048
-
1049
- // ZOMBIES: a droplet no live row owns. Force-delete it (idempotent + retried),
1050
- // then tear down its edge. The box row (if any) is already 'destroyed'/'error' —
1051
- // we don't change DB state, we reclaim the leaked DO resource + record an audit row.
1052
- for (const z of zombies) {
1053
- const who = z.login ? `@${z.login}` : `droplet ${z.dropletId}`;
1054
- if (!apply) { console.log(` would FORCE-DELETE zombie droplet ${z.dropletId} (${z.name || '?'}, ${who})`); continue; }
1055
- try {
1056
- const r = await deleteWithRetry(() => mutateDo.deleteDroplet(z.dropletId));
1057
- if (!r.ok) throw r.error || new Error('droplet delete failed');
1058
- await teardownEdgeForLogin(z.login, { apply });
1059
- const row = boxRows.find((b) => b.builder_id === z.builderId);
1060
- if (row) {
1061
- await boxes.recordEvent(pool, {
1062
- boxId: row.id, builderId: z.builderId, event: 'deprovision',
1063
- detail: `drift: zombie droplet ${z.dropletId} force-deleted (row was '${row.state}')`,
1064
- actor: 'reconcile:drift',
1065
- });
1066
- }
1067
- killed++;
1068
- console.log(` ✓ killed zombie droplet ${z.dropletId} (${who})`);
1069
- } catch (e) {
1070
- errors++;
1071
- console.log(` ✖ zombie droplet ${z.dropletId} (${who}): ${e.message}`);
1072
- }
1073
- }
1074
-
1075
- // GHOSTS: a live row whose droplet is gone from DO. Accrue final compute, tear
1076
- // down any leftover edge, then mark 'destroyed' (clearing the dead droplet_id +
1077
- // SSH host keys, idea 310) so the DB stops claiming a running box.
1078
- for (const g of ghosts) {
1079
- if (!apply) { console.log(` would MARK DESTROYED @${g.login || g.builderId}'s box (state '${g.state}', droplet ${g.dropletId} gone from DO)`); continue; }
1080
- try {
1081
- const row = boxRows.find((b) => b.builder_id === g.builderId);
1082
- const nowMs = Date.now();
1083
- if (row && row.state === 'active' && row.droplet_id) {
1084
- await boxes.recordComputeCost(pool, { box: row, untilMs: nowMs, actor: 'reconcile:drift' });
1085
- }
1086
- await teardownEdgeForLogin(g.login, { apply });
1087
- await boxes.setBoxState(pool, g.builderId, 'destroyed', {
1088
- destroyed_at: new Date(nowMs), droplet_id: null, ip: null, snapshot_id: null,
1089
- active_since: null, parked_at: null, claude_active: false, claude_active_since: null,
1090
- host_keys: null, host_keys_at: null,
1091
- });
1092
- if (row) {
1093
- await boxes.recordEvent(pool, {
1094
- boxId: row.id, builderId: g.builderId, event: 'deprovision',
1095
- detail: `drift: droplet ${g.dropletId} vanished from DO — row '${g.state}'→'destroyed'`,
1096
- actor: 'reconcile:drift',
1097
- });
1098
- }
1099
- marked++;
1100
- console.log(` ✓ marked @${g.login || g.builderId}'s box destroyed (ghost droplet ${g.dropletId})`);
1101
- } catch (e) {
1102
- errors++;
1103
- console.log(` ✖ ghost @${g.login || g.builderId}: ${e.message}`);
1104
- }
1105
- }
1106
-
1107
- console.log(`done — zombies-killed=${killed} ghosts-marked=${marked} errors=${errors} dry-run=${!apply}`);
1108
- return { zombies: killed, ghosts: marked, errors };
1109
- }
1110
-
1111
- // ---- run-intents (#701: drain the auto-provision / wake-on-connect queue) --
1112
-
1113
- // The control-plane half of the #701 onboarding middleware. The web process can
1114
- // only ENQUEUE a provision/wake intent (POST /box/ensure) — it never holds the DO
1115
- // token. This subcommand, run on a short systemd timer (box-intent-runner.timer,
1116
- // every minute), drains box_intents by calling the SAME cmdProvision/cmdWake the
1117
- // operator uses. DRY-RUN DEFAULT: without --apply it only lists the pending queue
1118
- // (and does NOT claim rows). With --apply it claims + executes each pending intent.
1119
- //
1120
- // A small attempt cap (BOX_INTENT_MAX_ATTEMPTS, default 3) retires a poison intent
1121
- // to 'error' so a doomed provision can't loop forever burning the DO API.
1122
- const MAX_INTENT_ATTEMPTS = numEnv('BOX_INTENT_MAX_ATTEMPTS', 3);
1123
-
1124
- async function cmdRunIntents(pool, boxes, boxOnboard, { apply }) {
1125
- const instanceRoot = process.env.BONGOS_INSTANCE_ROOT || process.cwd();
1126
- console.log(runnerBanner({ runnerVersion: coreVersionSafe(), runnerRoot: REPO_ROOT, instanceVersion: installedCoreVersion(instanceRoot), instanceRoot }));
1127
- if (!apply) {
1128
- const { rows } = await pool.query(
1129
- `SELECT bi.*, b.github_login FROM box_intents bi
1130
- JOIN builders b ON b.id = bi.builder_id
1131
- WHERE bi.state = 'pending' ORDER BY bi.created_at`
1132
- );
1133
- console.log(`intent runner (dry-run): ${rows.length} pending intent(s)`);
1134
- for (const r of rows) console.log(` would ${r.action} @${r.github_login} (intent #${r.id}, attempts=${r.attempts})`);
1135
- if (rows.length === 0) console.log(' queue empty — exit clean');
1136
- return { ran: 0 };
1137
- }
1138
-
1139
- let ran = 0, ok = 0, errors = 0;
1140
- // Drain the queue one claimed intent at a time (FOR UPDATE SKIP LOCKED), so two
1141
- // overlapping runner ticks never execute the same intent.
1142
- for (;;) {
1143
- const intent = await boxes.claimNextIntent(pool);
1144
- if (!intent) break;
1145
- ran++;
1146
- const builder = await resolveBuilder(pool, String(intent.builder_id));
1147
- const who = builder ? `@${builder.github_login}` : `builder#${intent.builder_id}`;
1148
- // Poison guard: a claimed intent already past the cap is retired, not retried.
1149
- if (intent.attempts > MAX_INTENT_ATTEMPTS) {
1150
- await boxes.resolveIntent(pool, intent.id, 'error', `exceeded ${MAX_INTENT_ATTEMPTS} attempts`);
1151
- errors++;
1152
- console.log(` ✖ ${who}: intent #${intent.id} retired (too many attempts)`);
1153
- continue;
1154
- }
1155
- console.log(` ${intent.action} ${who} (intent #${intent.id}, attempt ${intent.attempts})`);
1156
- try {
1157
- const ref = String(intent.builder_id);
1158
- const result = intent.action === 'provision'
1159
- ? await cmdProvision(pool, boxes, boxOnboard, ref, { apply: true })
1160
- : intent.action === 'deprovision'
1161
- ? await cmdDeprovision(pool, boxes, ref, { apply: true })
1162
- : await cmdWake(pool, boxes, ref, { apply: true });
1163
- if (result && result.ok === false) {
1164
- // Leave it 'pending' for a retry UNLESS we've hit the cap (next claim
1165
- // bumps attempts; the guard above retires it once over the cap).
1166
- if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
1167
- await boxes.resolveIntent(pool, intent.id, 'error', result.error || 'lifecycle op failed');
1168
- } else {
1169
- await pool.query(`UPDATE box_intents SET state = 'pending', last_error = $2, updated_at = now() WHERE id = $1`,
1170
- [intent.id, result.error || 'lifecycle op failed']);
1171
- }
1172
- errors++;
1173
- console.log(` ✖ ${who}: ${result.error || 'failed'}`);
1174
- } else {
1175
- await boxes.resolveIntent(pool, intent.id, 'done', null);
1176
- ok++;
1177
- console.log(` ✓ ${who}: ${intent.action} done`);
1178
- }
1179
- } catch (e) {
1180
- await boxes.resolveIntent(pool, intent.id, 'error', e.message).catch(() => {});
1181
- errors++;
1182
- console.log(` ✖ ${who}: ${e.message}`);
1183
- }
1184
- }
1185
- console.log(`done — ran=${ran} ok=${ok} errors=${errors}`);
1186
- return { ran, ok, errors };
1187
- }
1188
-
1189
- // ---- status ----------------------------------------------------------------
1190
-
1191
- async function cmdStatus(pool, boxes) {
1192
- const rows = await boxes.listBoxes(pool, {});
1193
- if (rows.length === 0) { console.log('no builder boxes yet.'); return; }
1194
- let monthly = 0, spent = 0;
1195
- console.log('builder boxes:');
1196
- for (const b of rows) {
1197
- const sz = boxes.SIZE_CATALOG[b.size_slug];
1198
- const liveMo = b.state === 'active' && sz ? sz.monthlyUsd : 0;
1199
- monthly += liveMo;
1200
- spent += Number(b.compute_cost_usd || 0);
1201
- const where = b.state === 'active' ? `${b.ip || '?'} (${b.size_slug})` : b.state === 'parked' ? `snapshot ${b.snapshot_id || '?'}` : '';
1202
- console.log(` @${b.github_login.padEnd(16)} ${b.state.padEnd(12)} scope=${b.scope.padEnd(8)} $${Number(b.compute_cost_usd || 0).toFixed(2)} spent ${where}`);
1203
- }
1204
- console.log(`\n active monthly run-rate: ~$${monthly.toFixed(2)}/mo total compute spent: $${spent.toFixed(2)}`);
1205
- }
1206
-
1207
- // ---- shared ----------------------------------------------------------------
1208
-
1209
- let APPLY = false;
1210
- function actorTag() {
1211
- const who = instanceConfig.resolveEnv('ACTOR') || process.env.USER || 'cli';
1212
- return `cli:${who}`;
1213
- }
1214
- function fail(msg) { console.error(`✖ ${msg}`); return { ok: false, error: msg }; }
1215
-
1216
- function usage() {
1217
- console.log(`box.js — per-builder dev box lifecycle (ADR 0031 §2/§5, #598)
1218
-
1219
- Usage:
1220
- node scripts/gds/box.js <command> [args] [--apply]
1221
-
1222
- Commands:
1223
- provision <builder> create + boot a box (rank-gated, floor=${CONFIG.floor})
1224
- onboard <builder> provision + print the builder welcome packet (#685);
1225
- requires a registered SSH key unless --allow-no-keys
1226
- park <builder> snapshot + destroy droplet (stop compute billing)
1227
- wake <builder> recreate droplet from its snapshot
1228
- deprovision <builder> destroy droplet AND snapshot (full reclaim)
1229
- run-intents drain the auto-provision/wake-on-connect queue (#701):
1230
- execute each pending box_intents row (provision/wake)
1231
- sweep-idle park active boxes idle > ${CONFIG.idleMinutes}m, or unattended > ${CONFIG.unattendedMaxHours}h
1232
- sweep-dormant warn at ${Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0)}d, then deprovision boxes parked > ${CONFIG.dormantDays}d
1233
- (never reclaims a box that was not warned first)
1234
- reconcile source-access clawback: deprovision below-floor/inactive
1235
- boxes, rescope graduated ones (ADR 0031 §6, #600)
1236
- reconcile-drift repair DO⇄DB drift BOTH ways (task 1289): force-delete
1237
- zombie droplets (running but no live box row) + mark
1238
- ghost rows destroyed (droplet gone from DO)
1239
- status print the box roster + cost
1240
-
1241
- DRY-RUN IS DEFAULT. Pass --apply to actually touch DigitalOcean + the DB.
1242
- <builder> = GitHub login or numeric builder id.
1243
-
1244
- Named browser-terminal tunnel (#771, ADR 0038 — OPT-IN, default OFF):
1245
- BOX_NAMED_TUNNEL=1 turn on per-builder named Cloudflare tunnels (provision
1246
- creates term-<login>.${CF.terminalZone} + bakes the
1247
- connector token; deprovision tears it down). Off → the
1248
- zero-secret quick tunnel (trycloudflare.com).
1249
- CLOUDFLARE_API_TOKEN token with Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit
1250
- + Zone:Transform Rules:Edit (the ADR 0053 cookie-strip —
1251
- without it platform cookies reach the box subdomains)
1252
- + Zone:Cache Rules:Edit (the sandbox cache bypass, #1065).
1253
- provision read-probes the ruleset scope before spending.
1254
- CLOUDFLARE_ACCOUNT_ID the Cloudflare account the tunnels live in
1255
- CLOUDFLARE_ZONE DNS zone (default ${CF.zone})
1256
- BOX_TERMINAL_ZONE hostname suffix (default ${CF.terminalZone})`);
1257
- }
1258
-
1259
- // box.js is a CLI: run directly it dispatches; require()d (only by tests — prod
1260
- // never imports it, it is always spawned) it exposes the orchestration fns so the
1261
- // dry-run/live contract can be exercised with injected fakes (#1034). The guard
1262
- // keeps a require() from running the CLI + calling process.exit.
1263
- // provenance() — WHICH core is making this fleet decision, and from which root.
1264
- //
1265
- // Task 1002738: the four cloudbongos-box-* units ran with WorkingDirectory pointed at
1266
- // a root whose vendored @bongos/core was 1.19.2 while the web app had long since moved
1267
- // to 1.19.146 — so every park/destroy/provision decision was made by a core ~145
1268
- // patches behind, including a cmdSweepIdle that still DESTROYED an idle box. Nothing
1269
- // in the journal said so: the sweep banner reports the THRESHOLD but never the code.
1270
- // Diagnosing it took a filesystem dig across two instance roots.
1271
- //
1272
- // One line, printed by every invocation, makes that failure self-announcing: a stale
1273
- // plane now says its own version in `journalctl -u cloudbongos-box-idle-suspend`, next
1274
- // to the instance root that supplied it. Cheap, unconditional, and it can only be
1275
- // wrong if the package it reads is the package it ran.
1276
- // Names the package REPO_ROOT resolved to, not just its version: installed, that is
1277
- // `@bongos/core@1.19.148`; in a monolith checkout it is the instance's own package, and
1278
- // saying so is the point — the line must never let a monolith masquerade as a core pin.
1279
- function provenance() {
1280
- let pkg = 'unknown';
1281
- try {
1282
- const p = require(path.join(REPO_ROOT, 'package.json'));
1283
- pkg = `${p.name || 'unknown'}@${p.version || 'unknown'}`;
1284
- } catch { /* a core with no readable package.json still runs — just say unknown */ }
1285
- const inst = instanceConfig.resolveInstanceRootExplicit() || process.cwd();
1286
- return `box.js · running from ${pkg} · instance ${inst}`;
1287
- }
1288
-
1289
- async function main() {
1290
- const args = process.argv.slice(2);
1291
- const cmd = args[0];
1292
- APPLY = hasFlag(args, '--apply');
1293
- if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
1294
- console.log(provenance());
1295
-
1296
- const known = ['provision', 'onboard', 'park', 'wake', 'deprovision', 'run-intents', 'sweep-idle', 'sweep-dormant', 'reconcile', 'reconcile-drift', 'status'];
1297
- if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
1298
-
1299
- const ref = args[1] && !args[1].startsWith('--') ? args[1] : null;
1300
- const needsRef = ['provision', 'onboard', 'park', 'wake', 'deprovision'].includes(cmd);
1301
- if (needsRef && !ref) { console.error(`${cmd} needs a <builder> (login or id)`); process.exit(2); }
1302
-
1303
- let pool, boxes, boxAccess, boxOnboard;
1304
- try { ({ pool, boxes, boxAccess, boxOnboard } = loadDeps()); }
1305
- catch (e) { console.error(`cannot load DB layer (run this on the control plane / droplet): ${e.message}`); process.exit(1); }
1306
-
1307
- let result;
1308
- try {
1309
- const opts = { apply: APPLY, allowNoKeys: hasFlag(args, '--allow-no-keys') };
1310
- if (cmd === 'provision') result = await cmdProvision(pool, boxes, boxOnboard, ref, opts);
1311
- else if (cmd === 'onboard') result = await cmdOnboard(pool, boxes, boxOnboard, ref, opts);
1312
- else if (cmd === 'park') result = await cmdPark(pool, boxes, ref, opts);
1313
- else if (cmd === 'wake') result = await cmdWake(pool, boxes, ref, opts);
1314
- else if (cmd === 'deprovision') result = await cmdDeprovision(pool, boxes, ref, opts);
1315
- else if (cmd === 'run-intents') result = await cmdRunIntents(pool, boxes, boxOnboard, opts);
1316
- else if (cmd === 'sweep-idle') result = await cmdSweepIdle(pool, boxes, opts);
1317
- else if (cmd === 'sweep-dormant') result = await cmdSweepDormant(pool, boxes, opts);
1318
- else if (cmd === 'reconcile') result = await cmdReconcile(pool, boxes, boxAccess, opts);
1319
- else if (cmd === 'reconcile-drift') result = await cmdReconcileDrift(pool, boxes, opts);
1320
- else if (cmd === 'status') result = await cmdStatus(pool, boxes);
1321
- } catch (e) {
1322
- console.error('fatal:', e && e.message ? e.message : e);
1323
- await pool.end().catch(() => {});
1324
- process.exit(1);
1325
- }
1326
- await pool.end().catch(() => {});
1327
- process.exit(result && result.ok === false ? 1 : 0);
1328
- }
1329
-
1330
- if (require.main === module) main();
1331
-
1332
- module.exports = { cmdProvision, cmdRunIntents, deprovisionOne, parkOne, cmdSweepIdle, cmdSweepDormant, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, buildBoxEnv, BOX_INFRA_TEMPLATE };