@bongos/core 1.19.1072 → 1.19.1074

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/.bongos-core.json +320 -465
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -60
  5. package/clients/bongos-client/index.cjs +4 -60
  6. package/clients/bongos-client/index.d.ts +5 -90
  7. package/clients/bongos-client/index.mjs +4 -60
  8. package/config/branding.neutral.json +1 -5
  9. package/config/modules.neutral.json +2 -3
  10. package/config/scheduled-routines.json +0 -2
  11. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  12. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  13. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  14. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  15. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +6 -2
  16. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  17. package/docs/adr/README.md +2 -1
  18. package/docs/api/openapi.json +171 -1661
  19. package/docs/api-reference.md +9 -41
  20. package/docs/architecture.md +16 -1
  21. package/docs/branding-contract.md +0 -3
  22. package/docs/copy-inventory.md +454 -479
  23. package/docs/copy-registry.json +746 -978
  24. package/docs/file-map.md +5 -34
  25. package/docs/module-api-changelog.md +4 -0
  26. package/docs/modules-contract.md +13 -1
  27. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  28. package/docs/onboarding/diagrams/README.md +3 -3
  29. package/docs/onboarding/diagrams/assertions.json +2 -22
  30. package/docs/onboarding/primer.md +9 -28
  31. package/docs/page-inventory.json +4 -1
  32. package/docs/page-readings.json +1032 -1077
  33. package/docs/recipes/render-pilot.md +29 -10
  34. package/migrations/core_257_module_store_registry.sql +87 -0
  35. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  36. package/modules/agents/module.json +1 -0
  37. package/modules/autonomy/module.json +1 -0
  38. package/modules/builder-settings/module.json +1 -0
  39. package/modules/builder-settings/render-prefs.js +4 -5
  40. package/modules/copy-desk/docx.js +78 -5
  41. package/modules/copy-desk/module.json +1 -0
  42. package/modules/copy-desk/routes/copy-desk.js +9 -9
  43. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  44. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  45. package/modules/discord/module.json +1 -0
  46. package/modules/economy/module.json +1 -0
  47. package/modules/economy/routes/credits.js +1 -2
  48. package/modules/government/catalog.js +13 -17
  49. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  50. package/modules/government/module.json +1 -0
  51. package/modules/government/protected-surfaces.json +3 -11
  52. package/modules/government/resolver.js +3 -46
  53. package/modules/government/routes/government.js +0 -2
  54. package/modules/government/session-scopes.js +43 -83
  55. package/modules/government/session-scopes.json +8 -18
  56. package/modules/grading/module.json +1 -0
  57. package/modules/hall-ui/module.json +1 -0
  58. package/modules/hall-ui/public/board-lib.js +2 -2
  59. package/modules/hall-ui/public/builders.js +29 -40
  60. package/modules/hall-ui/public/collab.html +1 -1
  61. package/modules/hall-ui/public/collab.js +1 -1
  62. package/modules/hall-ui/public/diagrams.html +3 -3
  63. package/modules/hall-ui/public/dom-utils.js +1 -1
  64. package/modules/hall-ui/public/gate.js +2 -2
  65. package/modules/hall-ui/public/gate.states.json +1 -1
  66. package/modules/hall-ui/public/hall-render.js +43 -79
  67. package/modules/hall-ui/public/index.html +5 -1
  68. package/modules/hall-ui/public/modules.js +1 -0
  69. package/modules/hall-ui/public/oversight.css +5 -5
  70. package/modules/hall-ui/public/palette.js +0 -11
  71. package/modules/hall-ui/public/primer.js +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings-sessions.js +1 -1
  74. package/modules/hall-ui/public/settings.css +0 -1
  75. package/modules/hall-ui/public/settings.html +4 -33
  76. package/modules/hall-ui/public/settings.js +3 -33
  77. package/modules/hall-ui/public/settings.states.json +2 -2
  78. package/modules/hall-ui/public/studio.html +1 -1
  79. package/modules/hall-ui/public/style.css +10 -13
  80. package/modules/hall-ui/public/task.html +4 -1
  81. package/modules/hall-ui/public/task.js +36 -1
  82. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  83. package/modules/hall-ui/public/tweak-editor.css +16 -0
  84. package/modules/hall-ui/public/tweak-editor.html +26 -3
  85. package/modules/hall-ui/public/tweak-editor.js +32 -1
  86. package/modules/hall-ui/public/work.js +1 -1
  87. package/modules/hall-ui/records/tweak-editor.md +1 -0
  88. package/modules/ideas/module.json +1 -0
  89. package/modules/lifecycle/db-claim-reads.js +1 -58
  90. package/modules/lifecycle/db-ship.js +0 -5
  91. package/modules/lifecycle/db.js +0 -4
  92. package/modules/lifecycle/kickoff-checklist.js +0 -4
  93. package/modules/lifecycle/lifecycle.js +0 -8
  94. package/modules/lifecycle/module.json +1 -0
  95. package/modules/lifecycle/routes/tasks.js +5 -7
  96. package/modules/memory/module.json +1 -0
  97. package/modules/npm-release/module.json +1 -0
  98. package/modules/onboarding/module.json +1 -0
  99. package/modules/onboarding/onboarding-state.js +17 -36
  100. package/modules/onboarding/routes/access-requests.js +3 -3
  101. package/modules/platform-identity/module.json +1 -0
  102. package/modules/platform-identity/platform-identity.js +13 -5
  103. package/modules/platform-identity/routes/sso.js +2 -2
  104. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  105. package/modules/provisioning/module.json +1 -0
  106. package/modules/provisioning/provisioning.js +2 -2
  107. package/modules/provisioning/starter-bundles.js +5 -25
  108. package/modules/public-landing/module.json +1 -0
  109. package/modules/public-landing/public/assets/cosmos.css +1 -1
  110. package/modules/public-landing/public/contact.html +1 -1
  111. package/modules/public-landing/public/privacy.html +1 -1
  112. package/modules/public-landing/public/projects.html +101 -32
  113. package/modules/public-landing/public/projects.probes.json +2 -2
  114. package/modules/public-landing/public/projects.states.json +4 -3
  115. package/modules/public-landing/public/terms.html +1 -1
  116. package/modules/security/module.json +1 -0
  117. package/modules/sessions/module.json +1 -0
  118. package/modules/specialities/module.json +1 -0
  119. package/modules/status-ui/module.json +1 -0
  120. package/modules/ui-design/module.json +1 -0
  121. package/package-lock.json +2 -2
  122. package/package.json +1 -1
  123. package/release-notes.json +84 -0
  124. package/scripts/gds/artifact-format.js +203 -0
  125. package/scripts/gds/audit-authorship.sh +1 -13
  126. package/scripts/gds/claim.js +0 -15
  127. package/scripts/gds/cli-lib.js +8 -8
  128. package/scripts/gds/diagram-facts.js +12 -23
  129. package/scripts/gds/do-api.js +13 -80
  130. package/scripts/gds/doc-cli-guard.js +2 -0
  131. package/scripts/gds/doctor.js +1 -1
  132. package/scripts/gds/feedback-latest.js +139 -9
  133. package/scripts/gds/fitness-checks-identity.js +2 -7
  134. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  135. package/scripts/gds/fitness-ratchets.js +4 -4
  136. package/scripts/gds/fitness.js +10 -10
  137. package/scripts/gds/gen-diagrams.js +49 -71
  138. package/scripts/gds/http-api-client.js +12 -19
  139. package/scripts/gds/init.js +1 -1
  140. package/scripts/gds/lint-infra-exec.js +1 -1
  141. package/scripts/gds/local-preview-lib.js +8 -39
  142. package/scripts/gds/local-preview.js +2 -15
  143. package/scripts/gds/migration-namespace.js +3 -3
  144. package/scripts/gds/module-artifact.js +212 -0
  145. package/scripts/gds/module.js +95 -6
  146. package/scripts/gds/package-core.js +5 -116
  147. package/scripts/gds/provision-config.js +2 -2
  148. package/scripts/gds/provision-net.js +2 -3
  149. package/scripts/gds/provision.js +9 -9
  150. package/scripts/gds/publish-manifest.js +1 -2
  151. package/scripts/gds/regen-instance-docs.js +1 -1
  152. package/scripts/gds/run-unit-tests.js +2 -16
  153. package/scripts/gds/sandbox-stage.js +39 -341
  154. package/scripts/gds/ship-flow.js +6 -6
  155. package/scripts/gds/ship-land.js +3 -4
  156. package/scripts/gds/ship-regen.js +10 -41
  157. package/scripts/gds/ship.js +4 -24
  158. package/scripts/gds/start.js +0 -15
  159. package/scripts/hall-preview/README.md +5 -5
  160. package/scripts/hall-preview/server.js +6 -6
  161. package/scripts/render-diagrams.sh +1 -1
  162. package/src/bongos/api-errors.js +1 -1
  163. package/src/bongos/auth-github.js +2 -3
  164. package/src/bongos/auth.js +51 -126
  165. package/src/bongos/db-kernel.js +2 -3
  166. package/src/bongos/db.js +5 -28
  167. package/src/bongos/module-scope-map.js +14 -29
  168. package/src/bongos/module-store.js +141 -0
  169. package/src/bongos/route-rank-check.js +3 -16
  170. package/src/bongos/routes/auth.js +0 -116
  171. package/src/bongos/routes/builders.js +5 -15
  172. package/src/bongos/routes/instance.js +8 -14
  173. package/src/bongos/routes/me.js +6 -22
  174. package/src/bongos/routes/modules.js +83 -0
  175. package/src/bongos/routes.js +5 -0
  176. package/src/module-api.js +13 -6
  177. package/src/module-loader/catalog.js +3 -0
  178. package/src/module-loader/loader.js +4 -4
  179. package/src/module-loader/manifest-schema.js +15 -7
  180. package/src/module-seams.js +2 -3
  181. package/src/modules.js +42 -14
  182. package/tests/agents_spend_guard.mjs +1 -1
  183. package/tests/api_client.mjs +0 -15
  184. package/tests/auth_page_gate.mjs +10 -10
  185. package/tests/canonical_profile_url.mjs +1 -3
  186. package/tests/claim_action.mjs +4 -1
  187. package/tests/claim_from_task_and_home.mjs +127 -0
  188. package/tests/consumer_layout_boot.mjs +2 -0
  189. package/tests/copy_desk_page_docx.mjs +33 -2
  190. package/tests/core_upgrade_runner.mjs +6 -6
  191. package/tests/deploy_divergence_line.mjs +11 -11
  192. package/tests/diagram_facts_offset.mjs +1 -1
  193. package/tests/do_api.mjs +106 -0
  194. package/tests/feedback_latest.mjs +122 -0
  195. package/tests/fitness.mjs +44 -42
  196. package/tests/gate_approvals.mjs +0 -1
  197. package/tests/go_live.mjs +3 -3
  198. package/tests/government_abuse_matrix.mjs +20 -19
  199. package/tests/government_ownership_scope.mjs +33 -35
  200. package/tests/government_parity.mjs +2 -2
  201. package/tests/government_protected_surfaces.mjs +56 -37
  202. package/tests/government_require_permission.mjs +3 -3
  203. package/tests/government_seed.mjs +37 -4
  204. package/tests/government_session_scope.mjs +92 -366
  205. package/tests/hall_error_envelope.mjs +4 -0
  206. package/tests/hall_landing_boot.mjs +4 -1
  207. package/tests/hall_palette.mjs +1 -16
  208. package/tests/hall_record_world.mjs +6 -0
  209. package/tests/hall_tweak_editor.mjs +52 -1
  210. package/tests/host_topology_skips.mjs +1 -2
  211. package/tests/html_comment_nesting.mjs +137 -0
  212. package/tests/infra_exec_paths.mjs +9 -10
  213. package/tests/init.mjs +4 -5
  214. package/tests/instance_manifest.mjs +18 -25
  215. package/tests/kickoff_checklist.mjs +1 -1
  216. package/tests/lib_sh_resolution.mjs +0 -221
  217. package/tests/local_preview.mjs +0 -12
  218. package/tests/module-scope-map.mjs +9 -9
  219. package/tests/module_catalog.mjs +1 -1
  220. package/tests/module_cli.mjs +15 -15
  221. package/tests/module_contributions.mjs +6 -6
  222. package/tests/module_loader.mjs +6 -6
  223. package/tests/module_manifest.mjs +13 -4
  224. package/tests/module_store_publish.mjs +307 -0
  225. package/tests/module_store_publish_route.mjs +121 -0
  226. package/tests/module_store_registry_migration.mjs +70 -0
  227. package/tests/modules.mjs +56 -26
  228. package/tests/onboarding_route_signals.mjs +21 -31
  229. package/tests/onboarding_state.mjs +35 -49
  230. package/tests/permission_path.mjs +0 -3
  231. package/tests/platform_boot.mjs +1 -2
  232. package/tests/profile_route.mjs +1 -6
  233. package/tests/projects_hub.mjs +5 -5
  234. package/tests/projects_hub_app_step.mjs +138 -0
  235. package/tests/projects_hub_module_picker.mjs +2 -2
  236. package/tests/projects_hub_pre_uat.mjs +4 -5
  237. package/tests/provision.mjs +7 -7
  238. package/tests/provisioning_starter_bundles.mjs +0 -22
  239. package/tests/publish_branch_route.mjs +10 -18
  240. package/tests/publish_manifest.mjs +1 -2
  241. package/tests/rank_tier_single_source.mjs +1 -13
  242. package/tests/repo_map.mjs +3 -4
  243. package/tests/runner_drift.mjs +3 -21
  244. package/tests/sandbox_stage.mjs +57 -390
  245. package/tests/session_rename_fallback.mjs +0 -21
  246. package/tests/ship_error_shape.mjs +1 -1
  247. package/tests/task_visual_slots.mjs +31 -0
  248. package/tests/terms_acceptance.mjs +1 -1
  249. package/tests/upgrade.mjs +4 -4
  250. package/tests/watch_sealed_floor.mjs +0 -1
  251. package/tests/wizard_draft_resume.mjs +2 -2
  252. package/tests/wizard_intent_resume.mjs +21 -10
  253. package/tests/wizard_preselect_why.mjs +18 -18
  254. package/docs/onboarding/browser-terminal-guide.md +0 -73
  255. package/docs/recipes/managed-settings-remote-control.md +0 -416
  256. package/modules/dev-box/CLAUDE.md +0 -15
  257. package/modules/dev-box/box-access.js +0 -623
  258. package/modules/dev-box/box-credential.js +0 -94
  259. package/modules/dev-box/box-onboard.js +0 -486
  260. package/modules/dev-box/boxes.js +0 -1038
  261. package/modules/dev-box/db.js +0 -39
  262. package/modules/dev-box/module.json +0 -19
  263. package/modules/dev-box/routes/box.js +0 -1223
  264. package/scripts/gds/box-auth-check.js +0 -160
  265. package/scripts/gds/box-infra.js +0 -321
  266. package/scripts/gds/box-sync.js +0 -216
  267. package/scripts/gds/box.js +0 -1332
  268. package/scripts/gds/cf-tunnel.js +0 -558
  269. package/scripts/gds/smoke-box.sh +0 -150
  270. package/scripts/gds/tree-preflight.js +0 -241
  271. package/src/bongos/app-pair.js +0 -172
  272. package/tests/app_pair.mjs +0 -168
  273. package/tests/box_access.mjs +0 -578
  274. package/tests/box_auth_check.mjs +0 -114
  275. package/tests/box_code_staleness.mjs +0 -129
  276. package/tests/box_connect_e2e.mjs +0 -196
  277. package/tests/box_cost.mjs +0 -70
  278. package/tests/box_credential.mjs +0 -125
  279. package/tests/box_dns_repoint.mjs +0 -153
  280. package/tests/box_env.mjs +0 -64
  281. package/tests/box_host_keys.mjs +0 -126
  282. package/tests/box_infra_resolve.mjs +0 -246
  283. package/tests/box_onboard.mjs +0 -317
  284. package/tests/box_scope_session.mjs +0 -119
  285. package/tests/box_sweep_docs.mjs +0 -119
  286. package/tests/box_sync.mjs +0 -55
  287. package/tests/box_sync_scope_report.mjs +0 -126
  288. package/tests/box_task_scope.mjs +0 -169
  289. package/tests/box_task_scope_pause.mjs +0 -66
  290. package/tests/box_terminal_ssrf.mjs +0 -96
  291. package/tests/boxes.mjs +0 -1585
  292. package/tests/cf_ruleset_preflight.mjs +0 -142
  293. package/tests/cf_tunnel.mjs +0 -496
  294. package/tests/docs_scrubber_damage.mjs +0 -111
  295. package/tests/module_scope_active_claims.mjs +0 -94
  296. package/tests/ship_api_client_pathspec.mjs +0 -100
  297. package/tests/tree_preflight.mjs +0 -243
@@ -1,1223 +0,0 @@
1
- // src/bongos/routes/box.js — read + heartbeat surface for the per-builder dev box
2
- // lifecycle (ADR 0031 §2/§5, #598).
3
- //
4
- // The web Node process NEVER shells out to the DigitalOcean API — a stolen
5
- // builder token can never directly spin up or destroy infrastructure. The
6
- // real-money lifecycle is split: web routes only ENQUEUE an intent (provision /
7
- // wake / deprovision) into box_intents, and the control-plane runner
8
- // (scripts/gds/box.js) is the sole holder of DO_API_TOKEN that executes them
9
- // (ADR 0031 §9.4). This router carries the read surface + the intent-enqueueing
10
- // mutations:
11
- //
12
- // GET /box/me — the caller's own box state/IP/cost (any builder; own)
13
- // POST /box/heartbeat — bump the caller's own activity clock (any builder; own)
14
- // POST /box/close — enqueue deprovision of the caller's own box (own)
15
- // GET /boxes — the full box roster (Archon)
16
- // POST /boxes/:id/close — enqueue deprovision of ANY box (Archon; idea 257)
17
- // PATCH /boxes/:id/block — bar/un-bar a builder from opening boxes (Archon; idea 257)
18
- //
19
- // The heartbeat is what feeds the idle sweep: the box pings while a builder is
20
- // working, resetting its idle clock; silence past BOX_IDLE_MINUTES lets the
21
- // sweep park it (snapshot + destroy → compute billing stops).
22
-
23
- const fs = require('node:fs');
24
- const path = require('node:path');
25
- const express = require('express');
26
- // The kernel doorway (ADR 0083 / BV1.R37): this module reaches core ONLY through
27
- // module-api — never a core internal directly. `auth`/`gdsDb` are aliased to the
28
- // doorway so the existing auth.*/gdsDb.* call sites read unchanged.
29
- const api = require('../../../src/module-api');
30
- const auth = api;
31
- const gdsDb = api;
32
- const { pool, buildInfo, validateOrRespond, LIMITS } = api;
33
- // Box-internal modules (same module → same-dir relative requires).
34
- const boxes = require('../boxes');
35
- const boxAccess = require('../box-access');
36
- const boxCredential = require('../box-credential');
37
- const boxOnboard = require('../box-onboard');
38
- // This module's own data slice (BV1.R68 / ADR 0091) — owns the box-domain
39
- // queries lifted out of core db.js. Kernel reads (getBuilderById) still come
40
- // from the doorway via `gdsDb`.
41
- const db = require('../db');
42
- // task 1003208: structured logging (pino via the doorway) — was console.*.
43
- const log = api.logger('dev-box');
44
-
45
- // Repo root, for serving the (non-secret) bootstrap scripts that live in infra/.
46
- const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
47
-
48
- // The FIXED public origin baked into the served connect scripts + guide. It is
49
- // DELIBERATELY NOT derived from the request (publicOrigin / X-Forwarded-Host):
50
- // these scripts are run as `curl … | bash` and then carry a GitHub/GDS bearer
51
- // token, so a spoofable Host header in the served GDS_API_BASE would let an
52
- // attacker redirect a victim's bootstrap at their own server (token theft + RCE
53
- // on the laptop). Same posture + env var the OAuth redirect_uri uses (auth.js
54
- // DISCORD_REDIRECT_ORIGIN). Staging sets <PREFIX>_PUBLIC_ORIGIN in its env —
55
- // resolved through the doorway's resolveEnv so the deployed GDS_PUBLIC_ORIGIN
56
- // still wins over the branding default until the sunset (task 1003703).
57
- const PUBLIC_ORIGIN = api.resolveEnv('PUBLIC_ORIGIN')
58
- || (() => { try { return api.branding().domains.publicOrigin; } catch { return 'http://localhost:3000'; } })();
59
-
60
- // Task-scoping PAUSE (task 1003285 / ADR 0193 — owner decision, 2026-08-25).
61
- // While paused, the claim-derived slice is never resolved: scopeKeys stays
62
- // undefined and decideSourceAccess takes its documented rank fallback — full
63
- // repo for Metic+, the starter surface below. Rank floor, ADR 0016 live-rank
64
- // checks, and the ADR 0055 read-only credential are all unchanged; only the
65
- // module BREADTH stops narrowing. Paused by default; set BOX_TASK_SCOPE=on
66
- // to re-enable the ADR 0148 claim-driven slice.
67
- function taskScopingPaused() {
68
- return String(process.env.BOX_TASK_SCOPE || '').trim().toLowerCase() !== 'on';
69
- }
70
-
71
- // The instance's world name + dev-box apex + SSH zone come from the branding pack
72
- // (ADR 0062 §3), never a hardcoded host brand — a non-OTB instance uses its own
73
- // configured identity/domains. Passed into the PURE box-onboard renderers.
74
- function devBoxApex() { try { return api.branding().domains.devBoxBase || ''; } catch { return ''; } }
75
- function devBoxWorld() { try { return api.branding().identity.worldName || 'the platform'; } catch { return 'the platform'; } }
76
-
77
- // The per-builder box DNS zone (mirrors scripts/gds/box.js CONFIG.dnsZone). The
78
- // hostname is deterministic from the login, so managed-settings can be issued
79
- // BEFORE the box is provisioned (the builder places it, then connects once the
80
- // operator provisions). Kept here so the route and the CLI derive the same name.
81
- function boxDnsZone() {
82
- if (process.env.BOX_DNS_ZONE) return process.env.BOX_DNS_ZONE;
83
- try { return api.branding().domains.sshBoxBase || ''; } catch { return ''; }
84
- }
85
- function hostnameForLogin(login) { return `box-${login}.${boxDnsZone()}`; }
86
-
87
- // The provisioning rank floor (#701 /box/ensure), mirroring scripts/gds/box.js
88
- // CONFIG.floor and boxes.DEFAULT_PROVISION_FLOOR. Kept here so the route's
89
- // auto-provision gate uses the SAME floor the control-plane CLI does.
90
- function provisionFloor() { return process.env.BOX_PROVISION_MIN_RANK || 'xenos'; }
91
-
92
- // SECURITY (#871): SSRF allow-list for the box web-terminal URL. POST /box/terminal
93
- // stores a builder-supplied URL and GET /box/terminal makes the WEB TIER fetch it
94
- // (a reachability probe). The old guard (/^https:\/\/[^\s]+$/) accepted ANY https
95
- // host — including 127.0.0.1, 10.x/192.168/172.16, 169.254.169.254 (cloud
96
- // metadata), [::1], *.local/*.internal — so a builder could make the prod server
97
- // probe arbitrary internal endpoints. A legitimate terminal URL is ALWAYS one of
98
- // exactly two shapes (see cf-tunnel.js terminalHostname + the quick-tunnel
99
- // default): the named tunnel `term-<login>.<zone>` under example.com, or a
100
- // Cloudflare quick tunnel `*.trycloudflare.com`. We bind the named form to the
101
- // CALLER's own login so one builder can't even name another's terminal host.
102
- // PURE + exported for unit tests (no network, no DB).
103
- function isSafeTerminalUrl(rawUrl, login, apex = devBoxApex()) {
104
- let u;
105
- try { u = new URL(String(rawUrl || '')); } catch { return false; }
106
- if (u.protocol !== 'https:') return false;
107
- // Reject anything with embedded credentials (user:pass@host).
108
- if (u.username || u.password) return false;
109
- const host = u.hostname.toLowerCase();
110
- if (!host) return false;
111
- // Reject IP literals (the only way to hit metadata/loopback/private ranges by
112
- // address) — IPv4 dotted-quad and IPv6 (URL hostname strips the [] brackets,
113
- // leaving colons, so a ':' in the host means an IPv6 literal).
114
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false;
115
- if (host.includes(':')) return false;
116
- // Reject loopback / internal-zone names.
117
- if (host === 'localhost' || host.endsWith('.localhost') ||
118
- host.endsWith('.local') || host.endsWith('.internal')) return false;
119
- // Allow ONLY the two legitimate tunnel shapes.
120
- if (host.endsWith('.trycloudflare.com')) return true;
121
- const safeLogin = String(login || '').toLowerCase();
122
- if (safeLogin && apex && host.startsWith(`term-${safeLogin}.`) && host.endsWith('.' + apex)) {
123
- return true;
124
- }
125
- return false;
126
- }
127
-
128
- // (#889) Is an HTTP status from probing the terminal URL a sign the tunnel is
129
- // actually serving? The URL is recorded at provision time, BEFORE cloudflared
130
- // connects, so a probe is the only way to tell "live" from "still booting".
131
- // Cloudflare's edge answers even when the tunnel is DOWN — error 1033 is served
132
- // as HTTP 530, and origin-unreachable as 502/503/504/52x — so "any HTTP
133
- // response = ready" handed out a clickable link that 1033s (builder 25). The
134
- // tunnel is ready ONLY when the ORIGIN answered: ttyd behind basic-auth returns
135
- // 401 (or 2xx/3xx once authed). Every 5xx (incl. 530) means not-yet-ready.
136
- // PURE + exported for unit tests.
137
- function isTunnelReadyStatus(status) {
138
- return status === 401 || (status >= 200 && status < 400);
139
- }
140
-
141
- // Read one of the infra bootstrap scripts and bake the server's PINNED public
142
- // origin (PUBLIC_ORIGIN / GDS_PUBLIC_ORIGIN) in as GDS_API_BASE. apiBase is that
143
- // constant, NEVER a request header — do NOT change this to a request-derived
144
- // origin: it would reintroduce the spoofable-Host token-theft/RCE blocker (the
145
- // served script is run as `curl … | bash` and then carries a bearer token).
146
- // Staging targets staging by setting GDS_PUBLIC_ORIGIN in its env. Returns null
147
- // if the file is missing (the route then 404s rather than serving junk).
148
- function readBootstrapScript(name, apiBase) {
149
- try {
150
- const raw = fs.readFileSync(path.join(REPO_ROOT, 'infra', name), 'utf8');
151
- return raw.split('__GDS_API_BASE__').join(apiBase);
152
- } catch {
153
- return null;
154
- }
155
- }
156
-
157
- // What we surface for a box — never the raw DO token or anything secret; the
158
- // row holds none, but we still project an explicit allow-list.
159
- function publicBox(row) {
160
- if (!row) return { state: 'none' };
161
- return {
162
- state: row.state,
163
- scope: row.scope,
164
- region: row.region,
165
- size_slug: row.size_slug,
166
- ip: row.ip,
167
- hostname: row.hostname,
168
- last_activity_at: row.last_activity_at,
169
- provisioned_at: row.provisioned_at,
170
- // When the CURRENT droplet came up. provisioned_at is preserved across rebuilds,
171
- // so this is the only timestamp a client can compare host_keys_at against to
172
- // tell a fresh identity report from a dead box's (boxes.hostKeysAreStale).
173
- active_since: row.active_since || null,
174
- parked_at: row.parked_at,
175
- compute_cost_usd: Number(row.compute_cost_usd || 0),
176
- claude_active: row.claude_active ?? false,
177
- // task 1187 / idea 235: the box's PUBLIC SSH host keys (newline-joined
178
- // `<keytype> <base64>`), reported by the box. The connect path writes them to
179
- // known_hosts authoritatively. Public, own-scoped (GET /box/me) — not a secret.
180
- host_keys: row.host_keys || null,
181
- host_keys_at: row.host_keys_at || null,
182
- // idea 332 / task 1316 (ADR 0072): code-staleness, so the hall/app can flag
183
- // "this box is running old code — rebuild." The box reports its /workspace
184
- // HEAD (POST /box/version); the server compares it to its OWN deployed commit
185
- // date (buildInfo().commitDate). Computed here so /box/me + /boxes share it.
186
- code: {
187
- sha: row.code_sha || null,
188
- committed_at: row.code_committed_at || null,
189
- reported_at: row.code_reported_at || null,
190
- ...boxes.computeCodeStaleness({
191
- state: row.state,
192
- provisionedAt: row.provisioned_at,
193
- codeReportedAt: row.code_reported_at,
194
- codeCommittedAt: row.code_committed_at,
195
- serverCommittedAt: buildInfo().commitDate,
196
- }),
197
- },
198
- };
199
- }
200
-
201
- // Register this module's kernel seams (ADR 0083 / BV1.R43). Idempotent — the loader
202
- // calls this factory once at boot, but tests may instantiate the router repeatedly;
203
- // gating on hasProvider keeps the port single-registered and the reconcile listener
204
- // single-wired (both re-register together after a seam _reset()).
205
- // • PROVIDES port `box.hasEverConnected` — the box-connection signal core
206
- // onboarding (GET /me, GET /builders/:id/onboarding) reads via resolveOptional.
207
- // • LISTENS event `builder.box-reconcile-needed` — emitted by core db.js after a
208
- // rank/status change; flags the droplet reconcile (replaces the old
209
- // core db.js → box-access direct import).
210
- function registerBoxSeams() {
211
- if (api.hasProvider('box.hasEverConnected')) return;
212
- api.registerProvider('box.hasEverConnected', (builderId) => boxes.hasEverConnected(pool, builderId));
213
- api.on('builder.box-reconcile-needed', async ({ builderId, rank, status, actor }) => {
214
- await boxAccess.flagBoxReconcileForBuilder(pool, builderId, { rank, status, actor });
215
- });
216
- }
217
-
218
- module.exports = function buildBoxRouter() {
219
- registerBoxSeams();
220
- const router = express.Router();
221
-
222
- // Route ORDER is deliberate: the Archon-gated route is declared FIRST, then
223
- // the any-builder routes. The route-rank auditor (src/bongos/route-rank-check.js)
224
- // classifies a route from a 20-line lookahead window, checking requireRank
225
- // before requireBuilder. With compact handlers like these, putting an
226
- // archon route BELOW an any-builder one would bleed its `requireRank('archon')`
227
- // up into the any-builder route's window and mislabel it. Archon-first keeps
228
- // every route classifying to its true gate (verified in the ship pre-pass).
229
-
230
- // GET /boxes — full roster across all builders. rank: archon (exposes every
231
- // builder's box + IP + cost; same gate as the rest of roster management).
232
- router.get('/boxes', auth.requireBuilder, auth.requirePermission('box.fleet.manage'), async (req, res) => {
233
- try {
234
- const state = req.query.state || undefined;
235
- const rows = await boxes.listBoxes(pool, { state });
236
- res.json({
237
- boxes: rows.map((r) => ({
238
- builder: {
239
- id: r.builder_id,
240
- github_login: r.github_login,
241
- display_name: r.display_name,
242
- rank: r.rank,
243
- },
244
- ...publicBox(r),
245
- // idea 257 / task 1163: surface the provisioning block so the Harbor
246
- // shows who is barred (incl. blocked builders with no live box — they
247
- // carry a state='none' row created at block time).
248
- blocked: {
249
- is: !!r.box_blocked,
250
- reason: r.box_blocked_reason || null,
251
- at: r.box_blocked_at || null,
252
- },
253
- droplet_id: r.droplet_id,
254
- snapshot_id: r.snapshot_id,
255
- })),
256
- });
257
- } catch (err) {
258
- log.error('[gds] GET /boxes', err);
259
- res.fail('roster_failed', { status: 500, message: 'internal error' });
260
- }
261
- });
262
-
263
- // GET /boxes/cost-ledger — the full per-builder container-cost ledger + totals
264
- // (#602). rank: archon — cost oversight across every builder without a DB query.
265
- // Declared among the archon routes (archon-first) so the route-rank auditor's
266
- // 20-line lookahead classifies it correctly.
267
- router.get('/boxes/cost-ledger', auth.requireBuilder, auth.requirePermission('box.fleet.manage'), async (req, res) => {
268
- try {
269
- // R14 (#2001 / ADR 0119): shared helper; per-endpoint cap preserved (default 500, max 2000).
270
- const { limit } = api.parsePagination(req, { defaultLimit: 500, maxLimit: 2000 });
271
- const out = await boxes.boxCostLedger(pool, { limit });
272
- res.json(out);
273
- } catch (err) {
274
- log.error('[gds] GET /boxes/cost-ledger', err);
275
- res.fail('cost_ledger_failed', { status: 500, message: 'internal error' });
276
- }
277
- });
278
-
279
- // POST /boxes/:builderId/close — Archon force-closes ANY builder's box (idea
280
- // 257 / task 1163). Trust-safe by the same mechanism as the builder's own
281
- // POST /box/close: it ENQUEUES a 'deprovision' intent and the control-plane
282
- // runner executes it, so the web tier never holds DO_API_TOKEN (ADR 0031
283
- // §9.4). rank: archon (acts on another builder's paid infrastructure).
284
- // Declared among the archon routes (archon-first) so the route-rank auditor's
285
- // 20-line lookahead classifies it correctly.
286
- router.post('/boxes/:builderId/close', auth.requireBuilder, auth.requirePermission('box.fleet.manage'), async (req, res) => {
287
- const builderId = Number(req.params.builderId);
288
- if (!Number.isInteger(builderId) || builderId < 1) {
289
- return res.fail('bad_builder_id', 400);
290
- }
291
- try {
292
- const box = await boxes.getBoxByBuilderId(pool, builderId);
293
- const state = box ? box.state : 'none';
294
- // canTransition('deprovision', …) is the single source of truth for which
295
- // states hold a droplet/snapshot worth reclaiming (active/parking/parked/
296
- // waking/error). none/destroyed/provisioning → nothing to close.
297
- if (!box || !boxes.canTransition('deprovision', state)) {
298
- return res.fail('box_not_closeable', { status: 409, message: state === 'none' || state === 'destroyed'
299
- ? 'That builder has no box to close.'
300
- : `Box is '${state}' — nothing to deprovision right now.`, details: { state } });
301
- }
302
- const { created } = await boxes.enqueueIntent(
303
- pool, builderId, 'deprovision', `api:archon:${req.builder.id}`
304
- );
305
- if (created) {
306
- await boxes.recordEvent(pool, {
307
- boxId: box.id, builderId, event: 'intent_enqueue',
308
- detail: `deprovision (archon close by ${req.builder.github_login || req.builder.id})`,
309
- actor: `archon:${req.builder.id}`,
310
- }).catch(() => {});
311
- }
312
- res.json({
313
- ok: true, queued: created, action: 'deprovision', state,
314
- message: created
315
- ? 'The box will be closed shortly.'
316
- : 'A close request is already queued for that box.',
317
- });
318
- } catch (err) {
319
- log.error('[gds] POST /boxes/:builderId/close', err);
320
- res.fail('close_failed', { status: 500, message: 'internal error' });
321
- }
322
- });
323
-
324
- // PATCH /boxes/:builderId/block — Archon bars (or un-bars) a builder from
325
- // OPENING dev boxes (idea 257 / task 1163; migration 104). Body:
326
- // { blocked: bool (required),
327
- // reason?: string, // recorded on block; shown back to the builder
328
- // close_current?: bool } // on block, ALSO deprovision a running box
329
- // Block alone is "no new opens" (decideEnsureAction → BOX_PROVISION_BLOCKED);
330
- // it does NOT touch a box that is already active. close_current adds the
331
- // explicit teardown the owner asked be optional ("options for both"). rank:
332
- // archon. Archon-first ordering (see above).
333
- router.patch('/boxes/:builderId/block', auth.requireBuilder, auth.requirePermission('box.fleet.manage'), async (req, res) => {
334
- const builderId = Number(req.params.builderId);
335
- if (!Number.isInteger(builderId) || builderId < 1) {
336
- return res.fail('bad_builder_id', 400);
337
- }
338
- if (validateOrRespond(req, res, {
339
- blocked: { required: true, type: 'boolean' },
340
- reason: { type: 'string', maxLength: 500 },
341
- close_current: { type: 'boolean' },
342
- })) return;
343
- const { blocked, reason, close_current: closeCurrent } = req.body || {};
344
- try {
345
- const updated = await db.setBuilderBoxBlocked(builderId, {
346
- blocked,
347
- reason: blocked ? (reason || null) : null,
348
- actorBuilderId: req.builder.id,
349
- });
350
- if (!updated) return res.fail('builder_not_found', 404);
351
-
352
- let closeQueued = false;
353
- if (blocked) {
354
- // Ensure a builder_boxes row exists so a blocked builder appears on the
355
- // Harbor roster (state='none') even if they never opened a box — an
356
- // Archon can bar a newcomer pre-emptively.
357
- const box = await boxes.ensureBoxRow(pool, builderId);
358
- await boxes.recordEvent(pool, {
359
- boxId: box.id, builderId, event: 'box_blocked',
360
- detail: `blocked by ${req.builder.github_login || req.builder.id}${reason ? ` — ${reason}` : ''}`,
361
- actor: `archon:${req.builder.id}`,
362
- }).catch(() => {});
363
- if (closeCurrent && boxes.canTransition('deprovision', box.state)) {
364
- const { created } = await boxes.enqueueIntent(
365
- pool, builderId, 'deprovision', `api:archon:${req.builder.id}`
366
- );
367
- closeQueued = created;
368
- if (created) {
369
- await boxes.recordEvent(pool, {
370
- boxId: box.id, builderId, event: 'intent_enqueue',
371
- detail: 'deprovision (block + close current)',
372
- actor: `archon:${req.builder.id}`,
373
- }).catch(() => {});
374
- }
375
- }
376
- } else {
377
- const box = await boxes.getBoxByBuilderId(pool, builderId);
378
- if (box) {
379
- await boxes.recordEvent(pool, {
380
- boxId: box.id, builderId, event: 'box_unblocked',
381
- detail: `unblocked by ${req.builder.github_login || req.builder.id}`,
382
- actor: `archon:${req.builder.id}`,
383
- }).catch(() => {});
384
- }
385
- }
386
-
387
- res.json({
388
- ok: true,
389
- blocked: {
390
- is: !!updated.box_blocked,
391
- reason: updated.box_blocked_reason || null,
392
- at: updated.box_blocked_at || null,
393
- },
394
- close_queued: closeQueued,
395
- });
396
- } catch (err) {
397
- log.error('[gds] PATCH /boxes/:builderId/block', err);
398
- res.fail('block_failed', { status: 500, message: 'internal error' });
399
- }
400
- });
401
-
402
- // GET /box/me — the caller's own box. rank: any authenticated builder (own
403
- // resource read). Returns { box: { state:'none' } } when they have no box yet.
404
- router.get('/box/me', auth.requireBuilder, async (req, res) => {
405
- try {
406
- const row = await boxes.getBoxByBuilderId(pool, req.builder.id);
407
- // Surface any open intent so a returning builder sees "queued" without
408
- // having to re-POST /box/ensure.
409
- const open = await boxes.getOpenIntent(pool, req.builder.id);
410
- // #602: the caller's own running container-cost total (spin-up + compute +
411
- // storage). Own-scoped — keyed on req.builder.id, never another builder.
412
- const cost = await boxes.boxCostSummaryForBuilder(pool, req.builder.id);
413
- res.json({
414
- box: publicBox(row),
415
- open_intent: open
416
- ? { action: open.action, state: open.state }
417
- : null,
418
- cost,
419
- });
420
- } catch (err) {
421
- log.error('[gds] GET /box/me', err);
422
- res.fail('box_read_failed', { status: 500, message: 'internal error' });
423
- }
424
- });
425
-
426
- // GET /box/source-access — the box fetches its rank-scoped clone spec + the
427
- // credential to pull it (ADR 0031 §6, #600). rank: any authenticated builder
428
- // (own resource) — the ALLOW/DENY and the SCOPE are decided INSIDE from the
429
- // caller's LIVE rank+status, NOT by a static requireRank: a Xenos must still get
430
- // their 'starter' credential, a Metic+ gets 'full', and a below-floor / inactive
431
- // builder is denied (403). This is ADR 0016 server-enforcement extended from
432
- // "writes are gated" to "source is gated": the box presents the BUILDER's
433
- // session, the GDS reads the live DB rank (unforgeable from the box), so a
434
- // demoted/deactivated builder is cut off on their VERY NEXT fetch — the instant,
435
- // credential-layer half of the offboarding clawback (the droplet teardown is the
436
- // slower control-plane half via `box.js reconcile`). The credential is returned
437
- // ONLY here, from the prod env (never the repo — ADR 0022), and is NEVER logged
438
- // or exposed by publicBox()/the roster.
439
- // #919: allowBoxScope — one of the 4 endpoints the on-box crons call, so a
440
- // box-scoped session (source='box') is permitted here. box-source-fetch.sh (cron */10).
441
- router.get('/box/source-access', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
442
- try {
443
- // ONE consistent read of rank + status from the same row (ADR 0016: fresh
444
- // per request). Deliberately NOT (req.builder.rank from the session) +
445
- // (status from a second query): on a credential-issuing path those two reads
446
- // could straddle a concurrent deactivation (TOCTOU). getBuilderById is the
447
- // single snapshot the decision is made on; null → fail closed.
448
- const builder = await gdsDb.getBuilderById(req.builder.id);
449
- const rank = builder ? builder.rank : null;
450
- const status = builder ? builder.status : 'inactive';
451
-
452
- // R94 (ADR 0148 §4 / goal 1000051): scope the box to the CODE THE BUILDER'S
453
- // CLAIMED TASK NEEDS. Resolve the lifecycle port (OPTIONAL — resolveOptional
454
- // returns null on a vanilla instance without it) and read the set-union of the
455
- // builder's active claims' module scope. scopeKeys drives the sparse BREADTH:
456
- // - array present → TASK scope (BASE ∪ the claims' module dirs; empty → BASE-only)
457
- // - undefined → decideSourceAccess falls back to the RANK scope (the
458
- // pre-ADR-0148 behavior, so a port-less instance is unchanged).
459
- // NEVER fail the source fetch on a scope-read hiccup — degrade to rank scope.
460
- // While task-scoping is paused (taskScopingPaused above), skip the claim
461
- // resolution entirely: undefined scopeKeys IS the rank-fallback signal.
462
- const lifecycle = api.resolveOptional('lifecycle');
463
- let scopeKeys;
464
- let hasActiveClaims;
465
- if (!taskScopingPaused() && lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
466
- try {
467
- scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
468
- } catch (scopeErr) {
469
- log.error('[gds] source-access scope read (non-fatal, → rank scope)', scopeErr && scopeErr.message ? scopeErr.message : scopeErr);
470
- scopeKeys = undefined;
471
- }
472
- }
473
- // task 1002729 — only needed to disambiguate an EMPTY scope: no claim (BASE)
474
- // vs claims whose goals declare no wall (rank scope). Skipped otherwise so
475
- // the common path keeps its single query.
476
- if (Array.isArray(scopeKeys) && scopeKeys.length === 0
477
- && lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
478
- try {
479
- hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
480
- } catch (claimErr) {
481
- log.error('[gds] source-access claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
482
- hasActiveClaims = undefined;
483
- }
484
- }
485
- // Read the box BEFORE deciding: its widen_paths are an INPUT to the sparse
486
- // set now (task 1003087). A builder-requested directory is unioned in here,
487
- // which is what makes it survive the */10 cron — the cron re-asks THIS
488
- // endpoint every tick, so the union comes back with it instead of being
489
- // deleted by the next `git sparse-checkout set`.
490
- const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
491
- const decision = boxAccess.decideSourceAccess({
492
- rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
493
- widenPaths: box ? box.widen_paths : null,
494
- });
495
- const auditDeny = (reason, extra) =>
496
- box && boxAccess.recordSourceEvent(pool, {
497
- box, builderId: req.builder.id, event: 'source_deny',
498
- detail: `${reason}${extra ? ` ${extra}` : ''}`, actor: 'api',
499
- }).catch(() => {});
500
-
501
- if (!decision.allowed) {
502
- await auditDeny(decision.reason, decision.required ? `(required ${decision.required}, had ${decision.actual ?? 'none'})` : '');
503
- return res.fail('SOURCE_ACCESS_REVOKED', 403, { reason: decision.reason, required: decision.required ?? null, actual: decision.actual ?? null });
504
- }
505
-
506
- // The credential is issued ONLY to a LIVE box (defense-in-depth: "the box
507
- // holds the credential" — a stolen token used off-box, or a builder with no /
508
- // parked box, gets nothing). The box calls this while active; on first
509
- // provision / wake it is active by the time its cron fetches.
510
- if (!box || !boxAccess.boxCanHoldSource(box.state)) {
511
- await auditDeny('NO_ACTIVE_BOX', `(box_state=${box ? box.state : 'none'})`);
512
- return res.fail('SOURCE_ACCESS_REVOKED', 403, { reason: 'NO_ACTIVE_BOX', box_state: box ? box.state : 'none' });
513
- }
514
-
515
- // Mint the scope-appropriate credential (operator-supplied; may be unconfigured).
516
- // ADR 0148 C4 leaves the TOKEN MODEL untouched: the credential (which repo +
517
- // which token) stays RANK-based, exactly as before task-scoping. decision.scope
518
- // may now be 'task'/'base' (the sparse BREADTH the box ASKS for), but the
519
- // credential must still be the rank one — issue() only special-cases 'full', so
520
- // passing 'task'/'base' would hand even a Metic+ the STARTER repo/token, which on
521
- // a separate-starter-repo instance can't reach the task's module. Task-scope is
522
- // the CLIENT-SIDE sparse ask; a hard per-claim credential ceiling is the deferred
523
- // hardening (ADR 0148 Consequences).
524
- const credScope = boxes.boxScopeForRank(rank); // 'starter' | 'full' — unchanged posture
525
- const cred = boxCredential.defaultProvider.issue(credScope, { nowMs: Date.now() });
526
-
527
- // Audit the grant — scope + configured flag only, NEVER the token. Only a
528
- // REAL (configured) grant is logged: an unconfigured fetch issued no
529
- // credential, so it is not an access event. Retention: box_events is
530
- // append-only audit; a source_grant lands ~6×/hour per active box (the
531
- // refresh cadence), so the box-lifecycle sweeps' retention should prune
532
- // source_grant rows beyond a short window — the security-critical
533
- // source_deny / source_revoke / source_reconcile events are far rarer and
534
- // are what an audit actually reads.
535
- if (box && cred.configured) {
536
- await boxAccess.recordSourceEvent(pool, {
537
- box, builderId: req.builder.id, event: 'source_grant',
538
- detail: `scope=${decision.scope} cred=${credScope} mode=${decision.spec.mode}`,
539
- actor: 'api',
540
- }).catch(() => {});
541
- }
542
-
543
- return res.json({
544
- scope: decision.scope,
545
- mode: decision.spec.mode,
546
- sparse_paths: decision.spec.sparsePaths,
547
- repo_url: cred.repoUrl,
548
- credential: cred.token, // null when unconfigured
549
- configured: cred.configured,
550
- separate_starter_repo: cred.separateStarterRepo,
551
- expires_at: cred.expiresAt,
552
- ttl_seconds: cred.ttlSeconds ?? null,
553
- guidance: cred.guidance,
554
- });
555
- } catch (err) {
556
- // Log the detail server-side; do NOT leak it to the caller — this is a
557
- // credential-issuing endpoint, and err.message can carry DB/query internals.
558
- log.error('[gds] GET /box/source-access', err);
559
- res.fail('source_access_failed', 500);
560
- }
561
- });
562
-
563
- // GET /box/scope — the credential-FREE companion to /box/source-access
564
- // (BV1.R100 / goal 1000051). Answers "what code does my box hold RIGHT NOW?" —
565
- // the resolved sparse set + the module keys of the builder's active claims —
566
- // WITHOUT issuing the pull credential. The laptop app reads it to confirm
567
- // "your box now has <module> for task N" after a pick+claim; R104's proof reads
568
- // it to VERIFY the scope is minimal (excludes other modules). Same live rank+
569
- // status + lifecycle-port scope logic as source-access, so the answer can't
570
- // drift from the actual checkout — it just omits the secret.
571
- //
572
- // Deliberately NOT gated on a live box (boxCanHoldSource): the SCOPE is metadata,
573
- // so a builder can ask "what WOULD my box hold for my current claims" the instant
574
- // they claim, before the box has even synced. The ALLOW/DENY still comes from the
575
- // live rank+status (a demoted/inactive builder gets the same 403 as source-access).
576
- // allowBoxScope so it works both from the laptop app (full session) and on the box
577
- // (box-scoped session) — safe here precisely because no credential is returned.
578
- router.get('/box/scope', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
579
- try {
580
- // ONE consistent rank+status snapshot (the /box/source-access TOCTOU note).
581
- const builder = await gdsDb.getBuilderById(req.builder.id);
582
- const rank = builder ? builder.rank : null;
583
- const status = builder ? builder.status : 'inactive';
584
-
585
- // Resolve the claim-driven module scope exactly as source-access does
586
- // (optional lifecycle port; degrade to rank scope on any hiccup; skipped
587
- // entirely while task-scoping is paused, so this read-only companion can
588
- // never report a slice the real fetch would no longer grant).
589
- const lifecycle = api.resolveOptional('lifecycle');
590
- let scopeKeys;
591
- let hasActiveClaims;
592
- if (!taskScopingPaused() && lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
593
- try {
594
- scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
595
- } catch (scopeErr) {
596
- log.error('[gds] /box/scope scope read (non-fatal, → rank scope)', scopeErr && scopeErr.message ? scopeErr.message : scopeErr);
597
- scopeKeys = undefined;
598
- }
599
- }
600
- // task 1002729 — same disambiguation as source-access, so this read-only
601
- // companion can never report a scope the real fetch wouldn't grant.
602
- if (Array.isArray(scopeKeys) && scopeKeys.length === 0
603
- && lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
604
- try {
605
- hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
606
- } catch (claimErr) {
607
- log.error('[gds] /box/scope claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
608
- hasActiveClaims = undefined;
609
- }
610
- }
611
- // Same widen input as the real fetch — this readout exists precisely so it
612
- // cannot drift from what the box will actually hold.
613
- const scopeBox = await boxes.getBoxByBuilderId(pool, req.builder.id);
614
- const decision = boxAccess.decideSourceAccess({
615
- rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
616
- widenPaths: scopeBox ? scopeBox.widen_paths : null,
617
- });
618
- if (!decision.allowed) {
619
- return res.fail('SOURCE_ACCESS_REVOKED', 403, {
620
- reason: decision.reason,
621
- required: decision.required ?? null,
622
- actual: decision.actual ?? null,
623
- });
624
- }
625
- const meta = boxAccess.scopeMetadata({ decision, scopeKeys });
626
- // task_scope true only when a claim actually drove the breadth (scopeKeys is
627
- // an array). false → the rank fallback (no active claim / no lifecycle port /
628
- // task-scoping paused — the paused flag says which, so the app can explain).
629
- return res.json({ ...meta, task_scope: Array.isArray(scopeKeys), task_scoping_paused: taskScopingPaused() });
630
- } catch (err) {
631
- log.error('[gds] GET /box/scope', err);
632
- res.fail('box_scope_failed', { status: 500, message: 'internal error' });
633
- }
634
- });
635
-
636
- // POST /box/heartbeat — the box reports it is alive, resetting the idle clock.
637
- // rank: any authenticated builder (own resource write). Optional body:
638
- // { claude_active: bool, attached: bool }.
639
- //
640
- // `claude_active` records whether a `claude` process is running; the idle sweep
641
- // honours it as a veto, but since task 1003507 only for BOX_UNATTENDED_MAX_HOURS
642
- // (a running process is not a person, and an unbounded veto pinned boxes active
643
- // forever). `attached` is the human signal — a login session, an inbound SSH
644
- // connection, an open browser terminal, or an attached tmux client — and stamps
645
- // last_attached_at, the clock that cap is measured against. A heartbeat that
646
- // omits `attached` (any box still running the pre-1003507 script) leaves the
647
- // column NULL, which the sweep reads as "no data" and not as "unattended".
648
- // Heartbeats without a body behave as before (last_activity_at bumped only).
649
- // #919: allowBoxScope — box-heartbeat.sh (cron */5) runs with the box-scoped session.
650
- router.post('/box/heartbeat', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
651
- // strict:false — heartbeat is a machine ping from SHIPPED box clients that
652
- // can't update in lockstep with the server (the ADR-0117 un-updatable-consumer
653
- // concern). Validate the known field's type, but tolerate a future/extra field
654
- // rather than reject a resilience-critical ping (the idle sweep keys off it).
655
- if (validateOrRespond(req, res, { claude_active: { type: 'boolean' }, attached: { type: 'boolean' } }, { strict: false })) return;
656
- try {
657
- const body = req.body || {};
658
- const claudeActive = typeof body.claude_active === 'boolean' ? body.claude_active : undefined;
659
- const attached = typeof body.attached === 'boolean' ? body.attached : undefined;
660
- const state = await boxes.bumpActivity(pool, req.builder.id, { claudeActive, attached });
661
- res.json({ ok: true, state: state || 'none' });
662
- } catch (err) {
663
- log.error('[gds] POST /box/heartbeat', err);
664
- res.fail('heartbeat_failed', { status: 500, message: 'internal error' });
665
- }
666
- });
667
-
668
- // POST /box/close — the builder manually requests their box be deprovisioned.
669
- // rank: any authenticated builder (own resource). Enqueues a 'deprovision'
670
- // intent; the control-plane runner executes it so the web tier never holds
671
- // DO_API_TOKEN (trust boundary holds, same as /box/ensure). Only valid for an
672
- // active box — a box that isn't running has nothing to close.
673
- router.post('/box/close', auth.requireBuilder, async (req, res) => {
674
- try {
675
- const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
676
- const state = box ? box.state : 'none';
677
- if (state !== 'active') {
678
- return res.fail('box_not_active', { status: 409, message: state === 'none' || state === 'destroyed'
679
- ? 'No active box to close.'
680
- : `Box is currently '${state}' — wait for it to finish or ask a Metic+ operator.`, details: { state } });
681
- }
682
- const { intent, created } = await boxes.enqueueIntent(
683
- pool, req.builder.id, 'deprovision', 'api:self'
684
- );
685
- if (created) {
686
- await boxes.recordEvent(pool, {
687
- boxId: box.id, builderId: req.builder.id, event: 'intent_enqueue',
688
- detail: 'deprovision (manual close from hall)', actor: 'api',
689
- }).catch(() => {});
690
- }
691
- res.json({
692
- ok: true, queued: created, action: 'deprovision',
693
- message: created
694
- ? 'Your box will be closed shortly.'
695
- : 'A close request is already queued for your box.',
696
- });
697
- } catch (err) {
698
- log.error('[gds] POST /box/close', err);
699
- res.fail('close_failed', { status: 500, message: 'internal error' });
700
- }
701
- });
702
-
703
- // ─── #685: one-command onboarding — SSH-key registry + box pull ──────────
704
- // The block below removes the operator SSH round-trip: the builder registers
705
- // their OWN public key, and the box pulls authorized_keys from here (gated on
706
- // live rank, like source). Every route is requireBuilder (own resource); none
707
- // is archon, so the any-builder routes can follow heartbeat without tripping
708
- // the route-rank auditor's archon-first ordering. The PUBLIC connect routes
709
- // come dead last (see their `rank: public` annotations).
710
-
711
- // POST /box/ssh-key — register the caller's own public SSH key. rank: any
712
- // authenticated builder (own resource write). The key is validated +
713
- // normalized (box-onboard.validateSshPublicKey) so nothing attacker-shaped can
714
- // reach the box's authorized_keys; the audit_log middleware records the write.
715
- router.post('/box/ssh-key', auth.requireBuilder, async (req, res) => {
716
- if (validateOrRespond(req, res, {
717
- public_key: { required: true, type: 'string', maxLength: 20000 },
718
- label: { type: 'string', maxLength: LIMITS.TAG },
719
- })) return;
720
- const v = boxOnboard.validateSshPublicKey((req.body || {}).public_key);
721
- if (!v.ok) {
722
- return res.fail('bad_ssh_key', 400, { reason: v.error });
723
- }
724
- try {
725
- // Per-builder cap (bloat-prevention; see MAX_SSH_KEYS_PER_BUILDER). A
726
- // re-register of an EXISTING fingerprint is always allowed (it's an
727
- // upsert, not growth) — only a NEW key past the cap is refused, so a
728
- // builder at the cap can still rotate/relabel their existing keys.
729
- const existing = await boxOnboard.listSshKeys(pool, req.builder.id);
730
- const isNew = !existing.some((k) => k.fingerprint === v.fingerprint);
731
- if (isNew && existing.length >= boxOnboard.MAX_SSH_KEYS_PER_BUILDER) {
732
- return res.fail('too_many_keys', 400, { limit: boxOnboard.MAX_SSH_KEYS_PER_BUILDER });
733
- }
734
- const label = boxOnboard.sanitizeLabel((req.body || {}).label) || v.label;
735
- const row = await boxOnboard.registerSshKey(pool, req.builder.id, {
736
- publicKey: v.normalized, keyType: v.keyType, fingerprint: v.fingerprint, label,
737
- });
738
- res.status(201).json({
739
- ok: true,
740
- key: { id: row.id, key_type: row.key_type, fingerprint: row.fingerprint, label: row.label },
741
- });
742
- } catch (err) {
743
- log.error('[gds] POST /box/ssh-key', err);
744
- res.fail('register_failed', { status: 500, message: 'internal error' });
745
- }
746
- });
747
-
748
- // GET /box/ssh-key — list the caller's registered keys. rank: any builder (own).
749
- router.get('/box/ssh-key', auth.requireBuilder, async (req, res) => {
750
- try {
751
- const rows = await boxOnboard.listSshKeys(pool, req.builder.id);
752
- res.json({
753
- keys: rows.map((r) => ({
754
- id: r.id, key_type: r.key_type, fingerprint: r.fingerprint,
755
- label: r.label, created_at: r.created_at, last_used_at: r.last_used_at,
756
- })),
757
- });
758
- } catch (err) {
759
- log.error('[gds] GET /box/ssh-key', err);
760
- res.fail('list_failed', { status: 500, message: 'internal error' });
761
- }
762
- });
763
-
764
- // DELETE /box/ssh-key/:id — remove one of the caller's keys. rank: any builder
765
- // (own resource; the delete is builder_id-scoped in SQL so an id from another
766
- // builder cannot be removed). 404 when the id isn't the caller's.
767
- router.delete('/box/ssh-key/:id', auth.requireBuilder, async (req, res) => {
768
- const id = Number.parseInt(req.params.id, 10);
769
- if (!Number.isInteger(id) || id <= 0) return res.fail('bad_id', 400);
770
- try {
771
- const removed = await boxOnboard.deleteSshKey(pool, req.builder.id, id);
772
- if (!removed) return res.fail('not_found', 404);
773
- res.json({ ok: true, deleted: removed });
774
- } catch (err) {
775
- log.error('[gds] DELETE /box/ssh-key/:id', err);
776
- res.fail('delete_failed', { status: 500, message: 'internal error' });
777
- }
778
- });
779
-
780
- // GET /box/widen — the caller's own recorded widen set (task 1003087).
781
- // rank: any authenticated builder, own resource. Reports what is STORED plus
782
- // what is currently ADMITTED, because those differ whenever a request sits
783
- // outside the builder's rank scope — showing only the stored list would let a
784
- // Xenos believe a widen took effect that the fetch quietly drops.
785
- router.get('/box/widen', auth.requireBuilder, async (req, res) => {
786
- try {
787
- const builder = await gdsDb.getBuilderById(req.builder.id);
788
- const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
789
- if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
790
- const stored = Array.isArray(box.widen_paths) ? box.widen_paths : [];
791
- const admitted = boxAccess.admissibleWidenPaths(stored, builder ? builder.rank : null);
792
- res.json({
793
- widen_paths: stored,
794
- admitted,
795
- ignored: stored.filter((p) => !admitted.includes(boxAccess.normalizeWidenPath(p))),
796
- max: boxAccess.MAX_WIDEN_PATHS,
797
- });
798
- } catch (err) {
799
- log.error('[gds] GET /box/widen', err);
800
- res.fail('widen_read_failed', { status: 500, message: 'internal error' });
801
- }
802
- });
803
-
804
- // POST /box/widen — record extra sparse-checkout directories that must SURVIVE
805
- // the */10 source-fetch (task 1003087, idea 1000793). rank: any authenticated
806
- // builder, OWN box (builder_id-scoped in SQL, so no id from the body can point
807
- // this at someone else's box).
808
- //
809
- // Deliberately NOT allowBoxScope. The on-box crons read source-access with a
810
- // box-scoped session (ADR 0053); this is a WRITE that changes what the box
811
- // pulls forever after, so it takes the builder's own session — the same posture
812
- // that keeps a stolen box token from re-scoping the checkout it was stolen from.
813
- //
814
- // add/remove are applied to the stored set and the result is re-normalized, so
815
- // the endpoint is idempotent: widening twice is not an error, and neither is
816
- // removing something that was never there.
817
- //
818
- // A path outside the caller's rank scope is ACCEPTED into storage but reported
819
- // in `ignored` rather than refused. That is the honest shape: rank can change,
820
- // and a builder promoted to Metic should find the widen they asked for as a
821
- // Xenos simply start working, instead of having been silently discarded months
822
- // earlier. What it can never do is take effect early — admissibleWidenPaths is
823
- // re-evaluated against the LIVE rank on every single fetch.
824
- router.post('/box/widen', auth.requireBuilder, async (req, res) => {
825
- if (validateOrRespond(req, res, {
826
- add: { type: 'array' },
827
- remove: { type: 'array' },
828
- clear: { type: 'boolean' },
829
- })) return;
830
- try {
831
- const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
832
- if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
833
- const builder = await gdsDb.getBuilderById(req.builder.id);
834
-
835
- const current = Array.isArray(box.widen_paths) ? box.widen_paths : [];
836
- const next = new Set(req.body && req.body.clear === true ? [] : current);
837
-
838
- const rejected = [];
839
- for (const raw of Array.isArray(req.body && req.body.add) ? req.body.add : []) {
840
- const p = boxAccess.normalizeWidenPath(raw);
841
- if (!p) { rejected.push(String(raw).slice(0, 120)); continue; }
842
- next.add(p);
843
- }
844
- for (const raw of Array.isArray(req.body && req.body.remove) ? req.body.remove : []) {
845
- const p = boxAccess.normalizeWidenPath(raw);
846
- if (p) next.delete(p);
847
- }
848
- if (rejected.length) {
849
- return res.fail('bad_widen_path', 400, {
850
- rejected,
851
- message: 'a widen path must be a repo-relative directory: no absolute paths, no "..", no drive letters',
852
- });
853
- }
854
-
855
- const stored = [...next].sort().slice(0, boxAccess.MAX_WIDEN_PATHS);
856
- const saved = await boxes.setBoxWidenPaths(pool, req.builder.id, stored);
857
- if (saved === null) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
858
- const admitted = boxAccess.admissibleWidenPaths(saved, builder ? builder.rank : null);
859
- res.json({
860
- ok: true,
861
- widen_paths: saved,
862
- admitted,
863
- ignored: saved.filter((p) => !admitted.includes(p)),
864
- max: boxAccess.MAX_WIDEN_PATHS,
865
- });
866
- } catch (err) {
867
- log.error('[gds] POST /box/widen', err);
868
- res.fail('widen_failed', { status: 500, message: 'internal error' });
869
- }
870
- });
871
-
872
- // GET /box/authorized-keys — the box pulls its builder's authorized_keys.
873
- // rank: any authenticated builder (own resource) — but the ALLOW/DENY is decided
874
- // INSIDE from the caller's LIVE rank+status (boxAccess.decideSourceAccess), NOT a
875
- // static requireRank, mirroring /box/source-access: a demoted/deactivated builder
876
- // is 403'd and the box-side fetch empties the managed authorized_keys block on the
877
- // very next cron — instant SSH-login revocation (ADR 0016 extended to login). The
878
- // body is text/plain: the managed key LINES (no markers — the box wraps them). An
879
- // allowed builder with no registered keys gets an empty 200 (a valid empty block).
880
- // #919: allowBoxScope — box-authorized-keys-fetch.sh (cron */10) runs box-scoped.
881
- router.get('/box/authorized-keys', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
882
- try {
883
- // One consistent rank+status snapshot (the /box/source-access TOCTOU note).
884
- const builder = await gdsDb.getBuilderById(req.builder.id);
885
- const rank = builder ? builder.rank : null;
886
- const status = builder ? builder.status : 'inactive';
887
- const decision = boxAccess.decideSourceAccess({ rank, status });
888
- if (!decision.allowed) {
889
- const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
890
- if (box) {
891
- await boxAccess.recordSourceEvent(pool, {
892
- box, builderId: req.builder.id, event: 'authkeys_deny',
893
- detail: `${decision.reason}${decision.required ? ` (required ${decision.required}, had ${decision.actual ?? 'none'})` : ''}`,
894
- actor: 'api',
895
- }).catch(() => {});
896
- }
897
- return res.fail('BOX_ACCESS_REVOKED', 403, { reason: decision.reason, required: decision.required ?? null, actual: decision.actual ?? null });
898
- }
899
- const keys = await boxOnboard.listSshKeys(pool, req.builder.id);
900
- await boxOnboard.touchSshKeysUsed(pool, req.builder.id).catch(() => {});
901
- res.type('text/plain').send(boxOnboard.buildAuthorizedKeysFile(keys));
902
- } catch (err) {
903
- log.error('[gds] GET /box/authorized-keys', err);
904
- res.fail('authorized_keys_failed', 500);
905
- }
906
- });
907
-
908
- // GET /box/managed-settings — the caller's Claude Desktop managed-settings.json.
909
- // rank: any authenticated builder (own resource; non-secret — just their box
910
- // hostname + the SSH identity path). Issued regardless of box state: the builder
911
- // needs it on disk BEFORE the operator provisions, then connects once the box is up.
912
- router.get('/box/managed-settings', auth.requireBuilder, async (req, res) => {
913
- try {
914
- const login = req.builder.github_login;
915
- const settings = boxOnboard.buildManagedSettings({ login, hostname: hostnameForLogin(login), world: devBoxWorld(), sshZone: boxDnsZone() });
916
- res.json(settings);
917
- } catch (err) {
918
- // BAD_LOGIN/BAD_HOSTNAME are 400-class (a malformed login should never reach
919
- // here for a real builder, but fail clean rather than 500).
920
- if (err && (err.code === 'BAD_LOGIN' || err.code === 'BAD_HOSTNAME')) {
921
- return res.fail('bad_identity', { status: 400, message: 'internal error' });
922
- }
923
- log.error('[gds] GET /box/managed-settings', err);
924
- res.fail('managed_settings_failed', 500);
925
- }
926
- });
927
-
928
- // ─── #701/#760: box onboarding middleware — auto-provision + wake-on-connect +
929
- // web/Chromebook terminal access (ADR 0038) ──────────────────────────────
930
- // These three are the TRUST-BOUNDARY-SAFE on-ramp: the web process records a
931
- // REQUEST (an intent) or reads published terminal access — it NEVER touches the
932
- // DigitalOcean API (the header's invariant holds). The control-plane runner
933
- // (box.js run-intents, the box-intent-runner timer) drains the queue and does
934
- // the real provision/wake. All three are requireBuilder (own resource); none is
935
- // archon, so they sit between heartbeat/ssh-key and the public connect routes
936
- // without tripping the route-rank auditor's archon-first ordering.
937
-
938
- // POST /box/ensure — "make my box ready". rank: any authenticated builder (own
939
- // resource) — the ALLOW/DENY + the chosen ACTION are decided INSIDE from the
940
- // caller's LIVE rank+status (boxes.decideEnsureAction), NOT a static requireRank,
941
- // mirroring /box/source-access. This single call covers BOTH halves of #701's
942
- // gap: a builder with no box gets a provision queued (auto-provision), a builder
943
- // whose box is parked gets a wake queued (wake-on-connect). It enqueues at most
944
- // one open intent per builder; the actual DO work happens on the control plane.
945
- router.post('/box/ensure', auth.requireBuilder, async (req, res) => {
946
- try {
947
- // One consistent rank+status snapshot from the live DB (the source-access
948
- // TOCTOU note): never trust the session copy on a provisioning path.
949
- const builder = await gdsDb.getBuilderById(req.builder.id);
950
- const rank = builder ? builder.rank : null;
951
- const status = builder ? builder.status : 'inactive';
952
- const box = await boxes.ensureBoxRow(pool, req.builder.id);
953
- const decision = boxes.decideEnsureAction({
954
- boxState: box.state, rank, status,
955
- blocked: !!(builder && builder.box_blocked),
956
- floor: provisionFloor(),
957
- });
958
-
959
- if (decision.action === 'denied') {
960
- const payload = {
961
- error: 'BOX_PROVISION_DENIED', reason: decision.reason,
962
- required: decision.required ?? null, actual: decision.actual ?? null,
963
- };
964
- // idea 257 / task 1163: when an operator block is the cause, tell the
965
- // builder WHY (the recorded reason) rather than a bare denial — this is
966
- // the `message` the box-panel surfaces to the builder verbatim. The block
967
- // is written by a Metic+ operator (box.fleet.manage, ADR 0157), so don't
968
- // attribute it to an Archon specifically.
969
- if (decision.reason === 'BOX_PROVISION_BLOCKED') {
970
- payload.message = builder && builder.box_blocked_reason
971
- ? `An operator has paused thy dev-box access: ${builder.box_blocked_reason}`
972
- : 'An operator has paused thy dev-box access. Ask in Discord #support.';
973
- }
974
- return res.status(403).json(payload);
975
- }
976
- if (decision.action === 'ready') {
977
- // Already up — treat the connect as a heartbeat so it doesn't get parked
978
- // out from under the builder who just asked for it.
979
- await boxes.bumpActivity(pool, req.builder.id);
980
- return res.json({ state: 'active', action: 'ready', queued: false });
981
- }
982
- if (decision.action === 'in_progress') {
983
- const open = await boxes.getOpenIntent(pool, req.builder.id);
984
- return res.json({ state: box.state, action: 'in_progress', queued: false, intent: open ? open.action : null });
985
- }
986
-
987
- // provision | wake → enqueue an intent (idempotent: an already-open intent
988
- // is returned rather than duplicated).
989
- const { intent, created } = await boxes.enqueueIntent(
990
- pool, req.builder.id, decision.action, 'api:self'
991
- );
992
- if (created) {
993
- await boxes.recordEvent(pool, {
994
- boxId: box.id, builderId: req.builder.id, event: 'intent_enqueue',
995
- detail: `${decision.action} (from state=${box.state})`,
996
- actor: 'api',
997
- }).catch(() => {});
998
- }
999
- return res.status(created ? 202 : 200).json({
1000
- state: box.state, action: intent.action, queued: created,
1001
- message: created
1002
- ? `Your box will be ${intent.action === 'wake' ? 'woken' : 'created'} shortly — check back in a minute.`
1003
- : `A ${intent.action} is already queued for your box.`,
1004
- });
1005
- } catch (err) {
1006
- log.error('[gds] POST /box/ensure', err);
1007
- res.fail('ensure_failed', 500);
1008
- }
1009
- });
1010
-
1011
- // GET /box/terminal — the caller reads their box's web-terminal URL + credential
1012
- // (the Chromebook on-ramp, ADR 0038: ttyd behind a Cloudflare Tunnel). rank: any
1013
- // authenticated builder (own resource). Read-only by design: it does NOT wake a
1014
- // parked box (that mutation belongs to POST /box/ensure). The Chromebook flow is:
1015
- // POST /box/ensure → poll this until state='active' and a url appears → open the
1016
- // url, enter the basic-auth credential, run `claude` in the terminal. The url AND
1017
- // credential are returned ONLY to the box's own builder, and are null until the
1018
- // box's terminal reporter (box-report-terminal.sh) has published them.
1019
- router.get('/box/terminal', auth.requireBuilder, async (req, res) => {
1020
- try {
1021
- const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
1022
- const state = box ? box.state : 'none';
1023
- if (state !== 'active') {
1024
- return res.json({
1025
- state, url: null, credential: null,
1026
- hint: state === 'parked' || state === 'none' || state === 'destroyed'
1027
- ? 'No active box — POST /box/ensure first, then poll this endpoint.'
1028
- : 'Box is starting up — poll again shortly.',
1029
- });
1030
- }
1031
- // If the DB has a URL, probe it before handing out credentials — the URL
1032
- // is recorded at provision time (#774), BEFORE cloudflared connects, so the
1033
- // probe (via isTunnelReadyStatus, #889) is what distinguishes a live tunnel
1034
- // from a box that is still booting / a CF 1033 (530) error page.
1035
- // SECURITY (#871): never fetch (SSRF) or serve a stored URL that isn't a
1036
- // valid tunnel host — e.g. a row written before this allow-list landed.
1037
- if (box.terminal_url && !isSafeTerminalUrl(box.terminal_url, req.builder.github_login)) {
1038
- return res.json({
1039
- state: 'active', url: null, credential: null,
1040
- hint: 'Terminal URL pending a valid tunnel host — reconnect your box to republish it.',
1041
- });
1042
- }
1043
- if (box.terminal_url) {
1044
- let tunnelReady = false;
1045
- try {
1046
- // L3 (red-team 2026-06-12): probe with redirect:'manual' so a 30x can't
1047
- // bounce this fetch to an arbitrary host (SSRF, defense-in-depth atop
1048
- // the isSafeTerminalUrl check on the stored URL above). On a redirect,
1049
- // re-run the SAME host allowlist on the Location: a redirect to a valid
1050
- // tunnel host is still "ready" (isTunnelReadyStatus already treats 3xx
1051
- // as ready); a redirect anywhere else is treated as not-ready.
1052
- const probe = await fetch(box.terminal_url, { redirect: 'manual', signal: AbortSignal.timeout(4000) });
1053
- if (probe.status >= 300 && probe.status < 400) {
1054
- const location = probe.headers?.get?.('location');
1055
- let resolved = null;
1056
- try { resolved = location ? new URL(location, box.terminal_url).toString() : null; } catch { resolved = null; }
1057
- tunnelReady = !!resolved && isSafeTerminalUrl(resolved, req.builder.github_login);
1058
- } else {
1059
- tunnelReady = isTunnelReadyStatus(probe.status);
1060
- }
1061
- } catch (_) {
1062
- tunnelReady = false;
1063
- }
1064
- if (!tunnelReady) {
1065
- return res.json({
1066
- state: 'active',
1067
- url: null,
1068
- credential: null,
1069
- warming_up: true,
1070
- hint: 'Box is up; tunnel is connecting — poll again in a few seconds.',
1071
- });
1072
- }
1073
- }
1074
- return res.json({
1075
- state: 'active',
1076
- url: box.terminal_url || null,
1077
- credential: box.terminal_credential || null,
1078
- terminal_at: box.terminal_at || null,
1079
- hint: box.terminal_url
1080
- ? null
1081
- : 'Box is up; waiting for the terminal service + tunnel to publish their URL (a few seconds after boot).',
1082
- });
1083
- } catch (err) {
1084
- log.error('[gds] GET /box/terminal', err);
1085
- res.fail('terminal_failed', 500);
1086
- }
1087
- });
1088
-
1089
- // POST /box/terminal — the box publishes its current web-terminal URL + the
1090
- // per-box basic-auth credential. rank: any authenticated builder (own resource)
1091
- // — the BOX calls this as its builder, using the GDS token already on the box.
1092
- // Both are stored ONLY when the box is active (boxes.setTerminalAccess enforces
1093
- // this), so a stale URL/credential can never be served after a park. The URL is
1094
- // validated to a bounded https URL; the credential is a bounded opaque string
1095
- // (a secret), only ever returned to its own builder (GET /box/terminal).
1096
- // #919: allowBoxScope — box-report-terminal.sh (credsync) runs box-scoped. The GET
1097
- // above is the builder reading from the hall, so it stays full-auth (not box-scoped).
1098
- router.post('/box/terminal', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
1099
- if (validateOrRespond(req, res, {
1100
- url: { required: true, type: 'string', maxLength: 2048 },
1101
- credential: { required: false, type: 'string', maxLength: 512 },
1102
- })) return;
1103
- const url = String((req.body || {}).url || '').trim();
1104
- const credential = String((req.body || {}).credential || '').trim() || null;
1105
- // SECURITY (#871): allow-list to a real tunnel host bound to the caller's own
1106
- // login, not just "any https URL" — otherwise the GET probe below is an SSRF
1107
- // into internal/metadata endpoints.
1108
- if (!isSafeTerminalUrl(url, req.builder.github_login)) {
1109
- return res.fail('bad_terminal_url', 400, { reason: `must be an https tunnel URL (term-<login>.${devBoxApex()} or *.trycloudflare.com)` });
1110
- }
1111
- try {
1112
- const state = await boxes.setTerminalAccess(pool, req.builder.id, { url, credential });
1113
- if (state !== 'active') {
1114
- // The box isn't active in our records — refuse rather than store access
1115
- // we'd never serve (and that would be stale the moment it parked).
1116
- return res.fail('box_not_active', 409, { state: state || 'none' });
1117
- }
1118
- res.json({ ok: true, state });
1119
- } catch (err) {
1120
- log.error('[gds] POST /box/terminal', err);
1121
- res.fail('terminal_failed', 500);
1122
- }
1123
- });
1124
-
1125
- // POST /box/host-keys — the box publishes its own PUBLIC SSH host keys (task 1187 /
1126
- // idea 235). rank: any authenticated builder (own resource) — the BOX calls this as
1127
- // its builder via the GDS token on the box; allowBoxScope so the box-scoped baked
1128
- // session (ADR 0053) can reach it, exactly like box-report-terminal.sh / the heartbeat
1129
- // reporter. Stored ONLY for an active box (setBoxHostKeys gates on it). Served back to
1130
- // the owner via GET /box/me; the connect path writes them to known_hosts authoritatively
1131
- // (no TOFU, no keyscan race), killing the post-re-provision "Host denied". host_keys are
1132
- // PUBLIC — never a secret — but the write is validated hard (anti-injection, >=1 ed25519).
1133
- router.post('/box/host-keys', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
1134
- if (validateOrRespond(req, res, {
1135
- host_keys: { required: true, type: 'string', maxLength: 4096 },
1136
- })) return;
1137
- const parsed = boxOnboard.validateHostKeyLines(String((req.body || {}).host_keys || ''));
1138
- if (!parsed.ok) {
1139
- return res.fail('bad_host_keys', 400, { reason: parsed.error });
1140
- }
1141
- try {
1142
- const state = await boxes.setBoxHostKeys(pool, req.builder.id, { hostKeys: parsed.lines.join('\n') });
1143
- if (state !== 'active') {
1144
- // Don't store host keys we'd never serve (and that would be stale once parked).
1145
- return res.fail('box_not_active', 409, { state: state || 'none' });
1146
- }
1147
- res.json({ ok: true, state, count: parsed.lines.length });
1148
- } catch (err) {
1149
- log.error('[gds] POST /box/host-keys', err);
1150
- res.fail('host_keys_failed', 500);
1151
- }
1152
- });
1153
-
1154
- // POST /box/version — the box reports its /workspace HEAD commit so the server
1155
- // can flag a stale box (idea 332 / task 1316, ADR 0072). Body:
1156
- // { sha: "<40-hex>", committed_at: "<ISO date>" }
1157
- // rank: any-builder — own resource (the box reports its OWN code). #919 pattern:
1158
- // allowBoxScope so the on-box cron (box-report-version.sh, box-scoped session)
1159
- // can call it, exactly like /box/host-keys + /box/heartbeat. Non-secret.
1160
- router.post('/box/version', auth.allowBoxScope, auth.requireBuilder, async (req, res) => {
1161
- if (validateOrRespond(req, res, {
1162
- sha: { required: true, type: 'string', maxLength: 64 },
1163
- committed_at: { type: 'string', maxLength: 64 },
1164
- })) return;
1165
- const body = req.body || {};
1166
- const sha = String(body.sha || '').trim();
1167
- if (!/^[0-9a-f]{7,40}$/i.test(sha)) {
1168
- return res.fail('bad_sha', 400);
1169
- }
1170
- // committed_at is optional but, when present, must parse — a bad date would
1171
- // poison the staleness comparison. Store as null if unparseable.
1172
- let committedAt = null;
1173
- if (body.committed_at) {
1174
- const d = new Date(body.committed_at);
1175
- if (Number.isNaN(d.getTime())) return res.fail('bad_committed_at', 400);
1176
- committedAt = d.toISOString();
1177
- }
1178
- try {
1179
- const state = await boxes.setBoxCodeVersion(pool, req.builder.id, { sha, committedAt });
1180
- if (state !== 'active') {
1181
- // Don't record a version for a box we aren't serving (mirrors host-keys).
1182
- return res.fail('box_not_active', 409, { state: state || 'none' });
1183
- }
1184
- res.json({ ok: true, state });
1185
- } catch (err) {
1186
- log.error('[gds] POST /box/version', err);
1187
- res.fail('version_report_failed', 500);
1188
- }
1189
- });
1190
-
1191
- // GET /box/connect — the human/Claude-readable connect walkthrough.
1192
- // rank: public — no auth on purpose: a brand-new builder fetches this from a
1193
- // bare laptop (no token yet) to learn the one command that onboards them. It
1194
- // contains no secrets, only instructions + the public bootstrap URLs.
1195
- router.get('/box/connect', async (_req, res) => {
1196
- res.type('text/plain').send(boxOnboard.renderConnectGuide({ apiBase: PUBLIC_ORIGIN, world: devBoxWorld(), apex: devBoxApex(), sshZone: boxDnsZone() }));
1197
- });
1198
-
1199
- // GET /box/connect.sh — the macOS/Linux bootstrap script.
1200
- // rank: public — non-secret installer (the rustup/nvm pattern). It does its OWN
1201
- // GitHub device-flow auth before it touches anything; serving it needs no auth.
1202
- router.get('/box/connect.sh', async (_req, res) => {
1203
- const script = readBootstrapScript('box-connect.sh', PUBLIC_ORIGIN);
1204
- if (script == null) return res.status(404).type('text/plain').send('# box-connect.sh not found on the server');
1205
- res.type('text/plain').send(script);
1206
- });
1207
-
1208
- // GET /box/connect.ps1 — the Windows (PowerShell) bootstrap script.
1209
- // rank: public — same posture as connect.sh (non-secret installer; self-auths).
1210
- router.get('/box/connect.ps1', async (_req, res) => {
1211
- const script = readBootstrapScript('box-connect.ps1', PUBLIC_ORIGIN);
1212
- if (script == null) return res.status(404).type('text/plain').send('# box-connect.ps1 not found on the server');
1213
- res.type('text/plain').send(script);
1214
- });
1215
-
1216
- return router;
1217
- };
1218
-
1219
- // SECURITY (#871): exported PURE so the SSRF allow-list is unit-tested directly
1220
- // (tests/box_terminal_ssrf.mjs) without a server, DB, or network.
1221
- module.exports.isSafeTerminalUrl = isSafeTerminalUrl;
1222
- module.exports.isTunnelReadyStatus = isTunnelReadyStatus;
1223
- module.exports.taskScopingPaused = taskScopingPaused;