@bongos/core 1.18.3 → 1.18.19

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 (471) hide show
  1. package/.bongos-core.json +1576 -286
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +4 -2
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +6 -1
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  27. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  28. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  29. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  30. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  31. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  32. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  33. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  34. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  35. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  36. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  37. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  38. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  39. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  40. package/docs/adr/README.md +10 -0
  41. package/docs/api/openapi.json +843 -17
  42. package/docs/api-reference.md +23 -10
  43. package/docs/architecture.md +4 -2
  44. package/docs/branding-contract.md +3 -1
  45. package/docs/canonical-permissions.md +21 -8
  46. package/docs/file-map.md +8 -0
  47. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  48. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  49. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  50. package/docs/onboarding/diagrams/assertions.json +4 -7
  51. package/docs/onboarding/drachmae-and-karma.md +3 -3
  52. package/docs/onboarding/primer.md +17 -16
  53. package/docs/onboarding/slash-commands.md +124 -0
  54. package/docs/recipes/builder-box-lifecycle.md +23 -0
  55. package/docs/recipes/devbox-app-release.md +14 -4
  56. package/docs/recipes/instance-first-run.md +16 -0
  57. package/docs/recipes/local-dev.md +81 -0
  58. package/docs/recipes/ops-gotchas.md +19 -1
  59. package/docs/recipes/overnight-builder.md +1 -1
  60. package/docs/recipes/parallel-goal-waves.md +174 -0
  61. package/docs/recipes/ship-and-regrade.md +2 -2
  62. package/docs/recipes/standalone-live-docs.md +48 -0
  63. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  64. package/migrations/003_pms.sql +6 -7
  65. package/migrations/006_pms_v2.sql +3 -31
  66. package/migrations/core_193_task_needs_rebase.sql +28 -0
  67. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  68. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  69. package/migrations/core_196_session_reward_topup.sql +69 -0
  70. package/modules/autonomy/pollers/routine-timers.js +9 -8
  71. package/modules/builder-settings/wandering-prefs.js +61 -18
  72. package/modules/dev-box/app/README.md +73 -0
  73. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  74. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  75. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  76. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  77. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  78. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  79. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  80. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  81. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  82. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  83. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  84. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  85. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  86. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  87. package/modules/dev-box/app/build-resources/icon.png +0 -0
  88. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  89. package/modules/dev-box/app/electron-builder.yml +95 -0
  90. package/modules/dev-box/app/package-lock.json +5291 -0
  91. package/modules/dev-box/app/package.json +30 -0
  92. package/modules/dev-box/app/renderer/app.js +319 -0
  93. package/modules/dev-box/app/renderer/index.html +45 -0
  94. package/modules/dev-box/app/renderer/styles.css +246 -0
  95. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  96. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  97. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  98. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  99. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  100. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  101. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  102. package/modules/dev-box/app/src/box-controller.js +287 -0
  103. package/modules/dev-box/app/src/brand.generated.json +13 -0
  104. package/modules/dev-box/app/src/brand.js +16 -0
  105. package/modules/dev-box/app/src/config.js +19 -0
  106. package/modules/dev-box/app/src/gds-client.js +90 -0
  107. package/modules/dev-box/app/src/main.js +610 -0
  108. package/modules/dev-box/app/src/pairing.js +57 -0
  109. package/modules/dev-box/app/src/preload.js +29 -0
  110. package/modules/dev-box/app/src/session-store.js +58 -0
  111. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  112. package/modules/dev-box/app/src/task-picker.js +70 -0
  113. package/modules/dev-box/app/src/tray.js +99 -0
  114. package/modules/dev-box/app/src/updater.js +55 -0
  115. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  116. package/modules/dev-box/app/test/unit.mjs +526 -0
  117. package/modules/dev-box/box-access.js +121 -4
  118. package/modules/dev-box/boxes.js +20 -0
  119. package/modules/dev-box/module.json +1 -1
  120. package/modules/dev-box/routes/box.js +87 -3
  121. package/modules/economy/credits.js +116 -25
  122. package/modules/economy/reward.js +8 -0
  123. package/modules/economy/streak.js +2 -1
  124. package/modules/governance/CLAUDE.md +15 -0
  125. package/modules/governance/catalog.js +419 -0
  126. package/modules/governance/config.js +174 -0
  127. package/modules/governance/db.js +125 -0
  128. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  129. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  130. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  131. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  132. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  133. package/modules/governance/module.json +10 -0
  134. package/modules/governance/protected-surfaces.js +88 -0
  135. package/modules/governance/protected-surfaces.json +80 -0
  136. package/modules/governance/rank-roles.js +83 -0
  137. package/modules/governance/resolver.js +221 -0
  138. package/modules/governance/routes/governance.js +89 -0
  139. package/modules/governance/routes/role-sync.js +49 -0
  140. package/modules/governance/session-scopes.js +127 -0
  141. package/modules/governance/session-scopes.json +38 -0
  142. package/modules/hall-ui/public/atlas.css +117 -0
  143. package/modules/hall-ui/public/atlas.html +3 -114
  144. package/modules/hall-ui/public/brand-apply.js +34 -6
  145. package/modules/hall-ui/public/builders.js +187 -219
  146. package/modules/hall-ui/public/diagrams.html +2 -0
  147. package/modules/hall-ui/public/dom-utils.js +27 -0
  148. package/modules/hall-ui/public/drachmae.html +2 -0
  149. package/modules/hall-ui/public/drachmae.js +54 -1
  150. package/modules/hall-ui/public/gate.css +98 -0
  151. package/modules/hall-ui/public/gate.html +3 -95
  152. package/modules/hall-ui/public/goal-inbox.js +4 -1
  153. package/modules/hall-ui/public/goals-page.js +626 -114
  154. package/modules/hall-ui/public/goals.css +79 -1
  155. package/modules/hall-ui/public/goals.html +38 -11
  156. package/modules/hall-ui/public/hall-widgets.js +62 -52
  157. package/modules/hall-ui/public/harbor.css +123 -0
  158. package/modules/hall-ui/public/harbor.html +3 -120
  159. package/modules/hall-ui/public/index.html +11 -4
  160. package/modules/hall-ui/public/instances-panel.js +4 -1
  161. package/modules/hall-ui/public/modules.css +63 -0
  162. package/modules/hall-ui/public/modules.html +3 -60
  163. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  164. package/modules/hall-ui/public/not-ready.html +2 -0
  165. package/modules/hall-ui/public/pair.css +73 -0
  166. package/modules/hall-ui/public/pair.html +3 -70
  167. package/modules/hall-ui/public/palette.js +302 -0
  168. package/modules/hall-ui/public/primer.css +13 -0
  169. package/modules/hall-ui/public/primer.html +2 -0
  170. package/modules/hall-ui/public/profile.html +2 -0
  171. package/modules/hall-ui/public/ranks.html +2 -0
  172. package/modules/hall-ui/public/roadmap.css +179 -0
  173. package/modules/hall-ui/public/roadmap.html +9 -172
  174. package/modules/hall-ui/public/roadmap.js +329 -316
  175. package/modules/hall-ui/public/sessions.css +16 -0
  176. package/modules/hall-ui/public/sessions.html +3 -13
  177. package/modules/hall-ui/public/settings.html +2 -0
  178. package/modules/hall-ui/public/settings.js +4 -4
  179. package/modules/hall-ui/public/shell.js +143 -18
  180. package/modules/hall-ui/public/style.css +231 -39
  181. package/modules/hall-ui/public/task-detail.js +347 -26
  182. package/modules/hall-ui/public/task.html +6 -1
  183. package/modules/hall-ui/public/watch.html +2 -0
  184. package/modules/hall-ui/public/work.html +8 -6
  185. package/modules/hall-ui/public/work.js +1 -1
  186. package/modules/ideas/inbox.js +61 -11
  187. package/modules/ideas/routes/inbox.js +23 -0
  188. package/modules/lifecycle/claim-eligibility.js +72 -0
  189. package/modules/lifecycle/db.js +519 -139
  190. package/modules/lifecycle/done-when.js +18 -3
  191. package/modules/lifecycle/github-push.js +19 -2
  192. package/modules/lifecycle/goal-advisory.js +114 -0
  193. package/modules/lifecycle/goal-authz.js +62 -0
  194. package/modules/lifecycle/lifecycle.js +9 -0
  195. package/modules/lifecycle/publish-reconciler.js +109 -6
  196. package/modules/lifecycle/routes/claims.js +68 -23
  197. package/modules/lifecycle/routes/done-when.js +110 -15
  198. package/modules/lifecycle/routes/goals.js +8 -38
  199. package/modules/lifecycle/routes/tasks.js +38 -6
  200. package/modules/lifecycle/ship-card.js +6 -0
  201. package/modules/onboarding/onboarding-state.js +1 -1
  202. package/modules/platform-identity/CLAUDE.md +15 -0
  203. package/modules/platform-identity/hub-device.js +147 -0
  204. package/modules/platform-identity/hub-keys.js +151 -0
  205. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  206. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  207. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  208. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  209. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  210. package/modules/platform-identity/module.json +18 -0
  211. package/modules/platform-identity/platform-identity.js +531 -0
  212. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  213. package/modules/platform-identity/routes/my-projects.js +140 -0
  214. package/modules/platform-identity/routes/projects.js +107 -0
  215. package/modules/platform-identity/routes/scouting.js +35 -0
  216. package/modules/platform-identity/routes/sso.js +402 -0
  217. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  218. package/modules/platform-identity/ui/.gitkeep +0 -0
  219. package/modules/security/routes/reports.js +10 -0
  220. package/modules/sessions/db.js +166 -21
  221. package/modules/sessions/module.json +1 -1
  222. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  223. package/modules/sessions/routes/sessions.js +33 -0
  224. package/package-lock.json +2 -2
  225. package/package.json +1 -1
  226. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  227. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  228. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  229. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  230. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  231. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  232. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  233. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  234. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  235. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  236. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  237. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  238. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  239. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  270. package/public-landing/create.html +51 -58
  271. package/public-landing/gate/README.md +137 -0
  272. package/public-landing/gate/dispmaps/bar.png +0 -0
  273. package/public-landing/gate/dispmaps/flat.png +0 -0
  274. package/public-landing/gate/dispmaps/key.png +0 -0
  275. package/public-landing/gate/index.html +587 -0
  276. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  277. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  278. package/public-landing/gate/space.css +799 -0
  279. package/public-landing/gate/space.js +1826 -0
  280. package/public-landing/gate/sprites/enter.png +0 -0
  281. package/public-landing/gate/sprites/search.png +0 -0
  282. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  283. package/public-landing/gate/sprites/tile.png +0 -0
  284. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  285. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  286. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  287. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  288. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  289. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  290. package/scripts/gds/abandon.js +6 -4
  291. package/scripts/gds/api.js +10 -5
  292. package/scripts/gds/api.ps1 +26 -0
  293. package/scripts/gds/api.sh +6 -0
  294. package/scripts/gds/audit-ships.js +7 -1
  295. package/scripts/gds/backfill-sessions.js +78 -18
  296. package/scripts/gds/backup.js +23 -1
  297. package/scripts/gds/box-connect-lib.js +124 -31
  298. package/scripts/gds/box-sync.js +66 -0
  299. package/scripts/gds/box.js +225 -53
  300. package/scripts/gds/cf-tunnel.js +145 -6
  301. package/scripts/gds/claim.js +34 -3
  302. package/scripts/gds/cli-lib.js +161 -27
  303. package/scripts/gds/code.js +6 -2
  304. package/scripts/gds/deploy-divergence.js +209 -0
  305. package/scripts/gds/dev-lib.js +309 -0
  306. package/scripts/gds/dev.js +322 -0
  307. package/scripts/gds/doctor.js +90 -61
  308. package/scripts/gds/fetch-art-key.js +1 -1
  309. package/scripts/gds/fetch-sound-prefs.js +2 -2
  310. package/scripts/gds/fitness.js +228 -12
  311. package/scripts/gds/gds-db-backup.service +16 -1
  312. package/scripts/gds/gds-db-offsite.service +9 -1
  313. package/scripts/gds/gen-api-client.js +1 -1
  314. package/scripts/gds/gen-api-docs.js +36 -14
  315. package/scripts/gds/gen-diagrams.js +45 -28
  316. package/scripts/gds/gen-file-map.js +2 -1
  317. package/scripts/gds/gen-freshness.js +32 -0
  318. package/scripts/gds/gen-repo-map.js +3 -2
  319. package/scripts/gds/gen-session-index.js +14 -5
  320. package/scripts/gds/hooks/pre-push.js +2 -2
  321. package/scripts/gds/init.js +60 -3
  322. package/scripts/gds/install-git-hooks.js +1 -1
  323. package/scripts/gds/key-card.js +15 -6
  324. package/scripts/gds/key-status.js +1 -1
  325. package/scripts/gds/linkify-refs.js +35 -9
  326. package/scripts/gds/login.js +16 -1
  327. package/scripts/gds/paste-token.js +7 -1
  328. package/scripts/gds/preflight.js +126 -0
  329. package/scripts/gds/provision.js +123 -12
  330. package/scripts/gds/publish-manifest.js +7 -1
  331. package/scripts/gds/reauth.js +7 -3
  332. package/scripts/gds/regen-instance-docs.js +47 -0
  333. package/scripts/gds/run-unit-tests.js +4 -2
  334. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  335. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  336. package/scripts/gds/sequence.js +425 -0
  337. package/scripts/gds/session-cost.js +10 -1
  338. package/scripts/gds/setup.js +31 -5
  339. package/scripts/gds/shell.js +5 -3
  340. package/scripts/gds/ship.js +319 -29
  341. package/scripts/gds/smoke-gds.js +139 -8
  342. package/scripts/gds/start.js +171 -22
  343. package/scripts/gds/status.js +1 -1
  344. package/scripts/gds/triage.js +10 -9
  345. package/scripts/gds/upgrade.js +124 -2
  346. package/scripts/migrate.sh +99 -14
  347. package/scripts/status-mirror-sync.js +114 -84
  348. package/src/bongos/api-errors.js +1 -0
  349. package/src/bongos/auth.js +447 -102
  350. package/src/bongos/db-kernel.js +27 -0
  351. package/src/bongos/db.js +39 -0
  352. package/src/bongos/module-scope-map.js +27 -1
  353. package/src/bongos/permission-path-check.js +136 -102
  354. package/src/bongos/repo-info.js +47 -11
  355. package/src/bongos/route-rank-check.js +429 -46
  356. package/src/bongos/routes/_helpers.js +12 -1
  357. package/src/bongos/routes/auth.js +219 -2
  358. package/src/bongos/routes/backup.js +58 -2
  359. package/src/bongos/routes/me.js +35 -1
  360. package/src/bongos/serve-internal.js +84 -15
  361. package/src/branding.js +32 -0
  362. package/src/instance-config.js +30 -0
  363. package/src/module-api.js +124 -1
  364. package/src/platform-server.js +86 -9
  365. package/tests/abandon_task.mjs +59 -3
  366. package/tests/api_cli_flags.mjs +53 -0
  367. package/tests/auth_page_gate.mjs +13 -0
  368. package/tests/backfill_sessions.mjs +127 -1
  369. package/tests/backup_cli.mjs +54 -0
  370. package/tests/bongos_box_connect.mjs +77 -0
  371. package/tests/box_access.mjs +130 -1
  372. package/tests/box_credential.mjs +125 -0
  373. package/tests/box_host_keys.mjs +24 -0
  374. package/tests/box_infra_resolve.mjs +149 -0
  375. package/tests/box_scope_session.mjs +6 -2
  376. package/tests/box_sync.mjs +55 -0
  377. package/tests/box_task_scope.mjs +99 -0
  378. package/tests/branding.mjs +12 -0
  379. package/tests/cf_ruleset_preflight.mjs +142 -0
  380. package/tests/cf_tunnel.mjs +37 -0
  381. package/tests/claim_gate_rebase.mjs +98 -0
  382. package/tests/clawback_confirmed_backlog.mjs +103 -0
  383. package/tests/cli_lib_formatters.mjs +20 -0
  384. package/tests/confirm_attribution.mjs +30 -46
  385. package/tests/credit_grant.mjs +198 -10
  386. package/tests/criterion_authoring_gate.mjs +272 -0
  387. package/tests/criterion_progress.mjs +33 -1
  388. package/tests/dev_lib.mjs +253 -0
  389. package/tests/devbox_artifact_gate.mjs +75 -0
  390. package/tests/devbox_dmg_backdrop.mjs +137 -0
  391. package/tests/devbox_icons.mjs +304 -0
  392. package/tests/devbox_installer_brand.mjs +232 -0
  393. package/tests/devbox_key_register.mjs +234 -0
  394. package/tests/docs_openapi_route.mjs +110 -0
  395. package/tests/doctor_hooks.mjs +141 -0
  396. package/tests/extract_json.mjs +140 -0
  397. package/tests/fitness.mjs +89 -0
  398. package/tests/gen_freshness.mjs +96 -0
  399. package/tests/goal_advisory.mjs +251 -0
  400. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  401. package/tests/goal_routes.mjs +59 -4
  402. package/tests/goal_tier.mjs +12 -3
  403. package/tests/governance_catalog.mjs +129 -0
  404. package/tests/governance_claim_eligibility.mjs +262 -0
  405. package/tests/governance_config.mjs +187 -0
  406. package/tests/governance_ownership_scope.mjs +410 -0
  407. package/tests/governance_page_gates.mjs +324 -0
  408. package/tests/governance_parity.mjs +336 -0
  409. package/tests/governance_principals.mjs +232 -0
  410. package/tests/governance_protected_surfaces.mjs +208 -0
  411. package/tests/governance_require_permission.mjs +223 -0
  412. package/tests/governance_resolver.mjs +84 -0
  413. package/tests/governance_role_sync.mjs +231 -0
  414. package/tests/governance_seed.mjs +127 -0
  415. package/tests/governance_session_scope.mjs +434 -0
  416. package/tests/governance_vanilla_boot.mjs +203 -0
  417. package/tests/grade_attribution.mjs +7 -7
  418. package/tests/hall_audit.mjs +223 -0
  419. package/tests/hall_nav.mjs +191 -0
  420. package/tests/hall_palette.mjs +206 -0
  421. package/tests/hall_signout.mjs +124 -0
  422. package/tests/hook_user_agents.mjs +10 -0
  423. package/tests/idea_triage.mjs +162 -0
  424. package/tests/idp_device_exchange.mjs +93 -0
  425. package/tests/init.mjs +51 -0
  426. package/tests/instance_config.mjs +65 -0
  427. package/tests/instance_mismatch_guard.mjs +23 -6
  428. package/tests/instance_release_config.mjs +104 -0
  429. package/tests/linkify_refs.mjs +15 -2
  430. package/tests/migrate_root_resolution.mjs +124 -1
  431. package/tests/module_contributions.mjs +55 -0
  432. package/tests/module_loader.mjs +10 -5
  433. package/tests/module_scope_active_claims.mjs +94 -0
  434. package/tests/needs_rebase.mjs +92 -0
  435. package/tests/one_active_claim_per_session.mjs +76 -0
  436. package/tests/override_attribution.mjs +10 -8
  437. package/tests/override_request_ownership.mjs +8 -4
  438. package/tests/parse_cookie_decode.mjs +42 -0
  439. package/tests/pay_on_land.mjs +129 -0
  440. package/tests/platform_boot.mjs +100 -1
  441. package/tests/post_login_landing.mjs +42 -0
  442. package/tests/preflight_checks.mjs +72 -0
  443. package/tests/provision.mjs +132 -7
  444. package/tests/publish_branch_route.mjs +53 -0
  445. package/tests/publish_reconciler.mjs +187 -2
  446. package/tests/repo_info_binding.mjs +248 -0
  447. package/tests/reward_gate.mjs +11 -2
  448. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  449. package/tests/reward_mode_surfacing.mjs +168 -0
  450. package/tests/route_permission_check.mjs +301 -0
  451. package/tests/route_rank_check.mjs +254 -12
  452. package/tests/route_validation.mjs +30 -0
  453. package/tests/sequence.mjs +267 -0
  454. package/tests/session_digest.mjs +44 -0
  455. package/tests/session_index.mjs +18 -6
  456. package/tests/session_records.mjs +39 -1
  457. package/tests/session_reward_on_land.mjs +381 -0
  458. package/tests/session_reward_reconcile.mjs +229 -0
  459. package/tests/ship_api_client_pathspec.mjs +100 -0
  460. package/tests/ship_base_flag.mjs +93 -0
  461. package/tests/ship_ci_deploy.mjs +80 -3
  462. package/tests/ship_upstream_deletion.mjs +57 -0
  463. package/tests/smoke_target.mjs +233 -0
  464. package/tests/start_env_guard.mjs +87 -0
  465. package/tests/start_rebase_warning.mjs +103 -0
  466. package/tests/status_mirror_sync.mjs +122 -0
  467. package/tests/stop_refcheck.mjs +19 -1
  468. package/tests/task_goal_link.mjs +15 -6
  469. package/tests/upgrade.mjs +139 -0
  470. package/tests/web_admission_status.mjs +162 -0
  471. package/tests/write_validation_gaps.mjs +110 -0
@@ -0,0 +1,610 @@
1
+ // devbox-app/src/main.js — Electron main process for OTB Dev Box (#904,
2
+ // ADR 0045). A tray-resident switch for the builder's hosted dev box:
3
+ // pairing sign-in (browser clicks only), box on/off over the existing
4
+ // own-scoped GDS endpoints, native notifications, and the local SSH /
5
+ // Claude Desktop plumbing the old connect.sh could never finish.
6
+
7
+ const path = require('node:path');
8
+ const fs = require('node:fs');
9
+ const {
10
+ app, BrowserWindow, ipcMain, shell, Notification, clipboard, net,
11
+ } = require('electron');
12
+
13
+ const brand = require('./brand');
14
+ const { apiBase, appVersion } = require('./config');
15
+ const { isUpdateAvailable, resolveTarget, updateBannerText } = require('./updater');
16
+ const sessionStore = require('./session-store');
17
+ const { createClient } = require('./gds-client');
18
+ const { createPairing } = require('./pairing');
19
+ const { createBoxController, terminalAuthUrl, TERMINAL_USER } = require('./box-controller');
20
+ const { shapeClaimableTasks, boxScopeMessage, claimErrorMessage } = require('./task-picker');
21
+ const { createTray } = require('./tray');
22
+ const sshSetup = require('./ssh-setup');
23
+
24
+ // Windows: required for Notification to display, must match build.appId.
25
+ app.setAppUserModelId(brand.APP_ID);
26
+
27
+ // One instance only — a second launch just surfaces the panel.
28
+ if (!app.requestSingleInstanceLock()) {
29
+ app.quit();
30
+ } else {
31
+ app.on('second-instance', () => showWindow());
32
+ }
33
+
34
+ let session = sessionStore.read();
35
+ let win = null;
36
+ let tray = null;
37
+ let controller = null;
38
+ let pairing = null;
39
+ let pairingState = null; // { phase, pairCode, verifyUrl } while a pairing runs
40
+ let updateInfo = null; // { version, url } when a newer release exists
41
+ let lastSetupHostname = null;
42
+ let setupInFlight = false; // re-entrancy guard: setHostKeys → emit → onChange → onActiveSetup
43
+
44
+ const client = createClient({
45
+ getToken: () => (session ? session.token : null),
46
+ getApiBase: () => (session ? session.api_base : null),
47
+ });
48
+
49
+ // Log to console AND a small persistent file — the Parallels hardware test
50
+ // (#909) showed GUI-launched failures vanish without a trace. The log lives
51
+ // in userData (Help-able path: app.getPath('userData')/devbox.log), truncated
52
+ // when it grows past ~256KB.
53
+ const LOG_FILE = () => path.join(app.getPath('userData'), 'devbox.log');
54
+ function log(...args) {
55
+ const line = `${new Date().toISOString()} ${args.map(String).join(' ')}`;
56
+ console.log('[devbox]', ...args);
57
+ try {
58
+ const f = LOG_FILE();
59
+ try { if (require('node:fs').statSync(f).size > 262_144) require('node:fs').truncateSync(f, 0); } catch { /* fresh file */ }
60
+ require('node:fs').appendFileSync(f, `${line}\n`);
61
+ } catch { /* logging must never break the app */ }
62
+ }
63
+
64
+ function notify({ title, body }) {
65
+ try { new Notification({ title, body }).show(); }
66
+ catch (e) { log('notification failed:', e.message); }
67
+ }
68
+
69
+ function meta() {
70
+ return {
71
+ civName: brand.CIV_NAME,
72
+ productName: brand.PRODUCT_NAME,
73
+ envName: brand.DESKTOP_ENV_NAME,
74
+ tagline: brand.TAGLINE,
75
+ version: appVersion(),
76
+ builder: session && session.builder
77
+ ? { login: session.builder.github_login, rank: session.builder.rank }
78
+ : null,
79
+ pairing: pairingState,
80
+ update: updateInfo,
81
+ settingsPath: sshSetup.managedSettingsPath(),
82
+ };
83
+ }
84
+
85
+ function pushState() {
86
+ if (win && !win.isDestroyed()) {
87
+ win.webContents.send('state', { snapshot: controller.snapshot(), meta: meta() });
88
+ }
89
+ if (tray) tray.update(controller.snapshot());
90
+ captureForVerification();
91
+ }
92
+
93
+ // Dev/verification hook (README): with OTB_DEVBOX_SCREENSHOT_DIR set, write a
94
+ // PNG of the panel after each state settles — how stub runs and CI capture
95
+ // visual proof without a human watching the tray.
96
+ let captureTimer = null;
97
+ function captureForVerification() {
98
+ const dir = process.env.OTB_DEVBOX_SCREENSHOT_DIR;
99
+ if (!dir || !win || win.isDestroyed()) return;
100
+ clearTimeout(captureTimer);
101
+ captureTimer = setTimeout(async () => {
102
+ try {
103
+ const img = await win.webContents.capturePage();
104
+ const status = controller ? controller.snapshot().status : 'init';
105
+ const file = path.join(dir, `panel-${Date.now()}-${status}.png`);
106
+ require('node:fs').writeFileSync(file, img.toPNG());
107
+ log('screenshot', file);
108
+ } catch (e) { log('screenshot failed:', e.message); }
109
+ }, 600);
110
+ }
111
+
112
+ // --- window ----------------------------------------------------------------
113
+
114
+ function createWindow() {
115
+ win = new BrowserWindow({
116
+ width: 380,
117
+ height: 600,
118
+ show: false,
119
+ resizable: false,
120
+ maximizable: false,
121
+ fullscreenable: false,
122
+ skipTaskbar: process.platform === 'darwin',
123
+ title: brand.PRODUCT_NAME,
124
+ backgroundColor: brand.BG_COLOR,
125
+ webPreferences: {
126
+ preload: path.join(__dirname, 'preload.js'),
127
+ contextIsolation: true,
128
+ nodeIntegration: false,
129
+ sandbox: true,
130
+ },
131
+ });
132
+ win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
133
+ // Closing hides — the app lives in the tray.
134
+ win.on('close', (e) => {
135
+ if (!app.isQuittingForReal) {
136
+ e.preventDefault();
137
+ win.hide();
138
+ }
139
+ });
140
+ // leg 3: a visible panel means the builder is actively engaging the box, so keep
141
+ // it alive (heartbeat) while shown and let it reap normally once hidden.
142
+ win.on('show', () => { if (controller) controller.setPanelVisible(true); });
143
+ win.on('hide', () => { if (controller) controller.setPanelVisible(false); });
144
+ win.webContents.on('did-finish-load', pushState);
145
+ // The renderer never navigates and opens nothing — belt and braces.
146
+ win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
147
+ win.webContents.on('will-navigate', (e) => e.preventDefault());
148
+ }
149
+
150
+ function showWindow() {
151
+ if (!win || win.isDestroyed()) createWindow();
152
+ if (process.platform === 'darwin' && tray) {
153
+ // Anchor under the menu-bar icon.
154
+ const b = tray.bounds();
155
+ if (b && b.x) {
156
+ win.setPosition(Math.round(b.x + b.width / 2 - 190), Math.round(b.y + b.height + 6), false);
157
+ }
158
+ }
159
+ win.show();
160
+ win.focus();
161
+ controller.refresh().catch(() => {});
162
+ }
163
+
164
+ // --- sign-in / sign-out ----------------------------------------------------
165
+
166
+ async function signIn() {
167
+ if (pairing) return; // one at a time
168
+ pairing = createPairing({ client, openExternal: (u) => shell.openExternal(u) });
169
+ try {
170
+ const grant = await pairing.run((update) => {
171
+ pairingState = update;
172
+ pushState();
173
+ });
174
+ session = sessionStore.write({
175
+ token: grant.token,
176
+ builder: grant.builder,
177
+ apiBase: apiBase(session && session.api_base),
178
+ });
179
+ notify({ title: 'Signed in', body: `Connected as @${grant.builder.github_login}.` });
180
+ afterSignIn().catch((e) => log('post-sign-in setup:', e.message));
181
+ } catch (err) {
182
+ if (err.message !== 'cancelled') {
183
+ pairingState = { phase: 'failed', reason: err.message };
184
+ }
185
+ } finally {
186
+ pairing = null;
187
+ if (pairingState && pairingState.phase === 'waiting') pairingState = null;
188
+ pushState();
189
+ controller.refresh().catch(() => {});
190
+ }
191
+ }
192
+
193
+ async function afterSignIn() {
194
+ pairingState = null;
195
+ // Register the SSH key (idempotent) + install the Claude Desktop connection
196
+ // file once, right after sign-in — one elevation prompt at a moment the
197
+ // user expects setup, not mid-flow later.
198
+ await runKeySetup();
199
+ await installSettings();
200
+ pushState();
201
+ }
202
+
203
+ // Generate-if-missing + register + VERIFY the builder key, and publish the outcome
204
+ // to the panel. Never throws. Registration failing here used to be logged and
205
+ // forgotten, leaving the box with no builder key in authorized_keys and every later
206
+ // connect refused with nothing to act on (task 1002509).
207
+ async function runKeySetup() {
208
+ try {
209
+ const r = await sshSetup.ensureKey({
210
+ registerKey: (pk, label) => client.registerSshKey(pk, label),
211
+ listKeys: () => client.listSshKeys(),
212
+ log,
213
+ });
214
+ controller.setKeyRegistered(r.registration);
215
+ return r.registration;
216
+ } catch (e) {
217
+ log('ssh key setup failed:', e.message);
218
+ const state = { ok: false, reason: 'key_setup_failed', detail: e.message };
219
+ controller.setKeyRegistered(state);
220
+ return state;
221
+ }
222
+ }
223
+
224
+ // One re-attempt per activation while the key is still unverified — the leg that
225
+ // turns "registration failed at sign-in, silently, forever" into self-healing.
226
+ // Fire-and-forget with its own guards so it never serializes with host-key pinning.
227
+ const keyRegState = { activation: null, inFlight: false };
228
+ function retryKeyRegistration(activationKey) {
229
+ if (keyRegState.inFlight || keyRegState.activation === activationKey) return;
230
+ const s = controller.snapshot();
231
+ if (s.keyRegistered && s.keyRegistered.ok) return;
232
+ keyRegState.activation = activationKey;
233
+ keyRegState.inFlight = true;
234
+ runKeySetup().finally(() => { keyRegState.inFlight = false; });
235
+ }
236
+
237
+ async function installSettings() {
238
+ try {
239
+ const settings = await client.managedSettings();
240
+ // The load-bearing step (#910): the Desktop environment picker reads
241
+ // sshConfigs from the PER-USER ~/.claude/settings.json — written here
242
+ // with no elevation. "Linked ✓" means THIS succeeded.
243
+ const user = sshSetup.installUserSshConfig(settings, { log });
244
+ controller.setSettingsInstalled(Boolean(user.ok));
245
+ // Optional hardening layer: the system managed file (sshHostAllowlist).
246
+ // Best-effort — a declined UAC/admin prompt must not affect the link.
247
+ const managed = await sshSetup.installManagedSettings(`${JSON.stringify(settings, null, 2)}\n`, { log });
248
+ if (!managed.ok && managed.reason !== 'skipped' && managed.reason !== 'unsupported') {
249
+ log('managed-settings (optional allowlist) not installed:', managed.reason);
250
+ }
251
+ return user;
252
+ } catch (e) {
253
+ log('settings fetch/install failed:', e.message);
254
+ controller.setSettingsInstalled(false);
255
+ return { ok: false, reason: 'failed' };
256
+ }
257
+ }
258
+
259
+ async function signOut() {
260
+ try { await client.logout(); } catch { /* token may already be dead */ }
261
+ sessionStore.clear();
262
+ session = null;
263
+ pairingState = null;
264
+ controller.refresh().catch(() => {});
265
+ pushState();
266
+ }
267
+
268
+ // --- box plumbing on activation ---------------------------------------------
269
+
270
+ // When the box turns active, line up the local SSH side: fresh host keys in
271
+ // known_hosts (they change on every re-provision). The #909 hardware test
272
+ // found the original one-shot version latched its "done" guard BEFORE the
273
+ // scan succeeded — a box whose sshd wasn't up yet (or a transient tool/PATH
274
+ // failure) meant known_hosts was never written and never retried, silently.
275
+ // Now: retry with backoff while the box stays active, latch only on success,
276
+ // and surface the state to the panel (controller.setHostKeys).
277
+ const hostKeysState = { key: null, attempts: 0, retryPendingKey: null };
278
+ async function onActiveSetup(snapshot, { force = false } = {}) {
279
+ const hostname = snapshot.box && snapshot.box.hostname;
280
+ if (!hostname) return;
281
+ // A box is only reachable if the builder's key is actually on their account, so
282
+ // re-attempt an unverified registration alongside the host-key work (not awaited —
283
+ // it must not delay pinning). Guarded to one attempt per activation.
284
+ retryKeyRegistration(`${hostname}|${(snapshot.box && snapshot.box.activeSince) || ''}`);
285
+ // Re-entrancy guard. controller.setHostKeys() → emit() → onChange → onActiveSetup,
286
+ // so without this a single run recursively re-pins; on the keyscan path (which a
287
+ // just-rebuilt box forces until it re-reports its keys) the overlapping scans
288
+ // thrash each other and the panel flickers (v1.0.8 bug, task 1319). One at a time.
289
+ if (setupInFlight) return;
290
+ setupInFlight = true;
291
+ try {
292
+ const key = `${hostname}|${snapshot.box.provisionedAt || ''}`;
293
+ // Reported keys are only usable when they describe the droplet running NOW. A
294
+ // report older than active_since belongs to a box that has been rebuilt since —
295
+ // pinning it IS the "Host denied (verification failed)" lockout — and it must not
296
+ // be treated as the expected identity by the self-verify below either, or every
297
+ // poll would condemn a correctly-scanned pin and re-scan (task 1002509).
298
+ // A GDS that predates active_since on /box/me gives us nothing to compare, and a
299
+ // SHIPPED binary may well be talking to one — so the veto only applies when the
300
+ // server actually served it. Otherwise the pre-existing authoritative path stands.
301
+ const activeSince = snapshot.box && snapshot.box.activeSince;
302
+ const staleReport = Boolean(activeSince) && sshSetup.reportedKeysAreStale({
303
+ hostKeysAt: snapshot.box && snapshot.box.hostKeysAt,
304
+ activeSince,
305
+ });
306
+ const rawReported = snapshot.box && snapshot.box.hostKeysText;
307
+ const reported = staleReport ? null : rawReported;
308
+ // Self-heal, not "pin once". Skip ONLY if we already pinned for this activation
309
+ // AND the box's CURRENT identity is still actually present in known_hosts. A
310
+ // rebuild (fresh host keys, unchanged provisionedAt) or a hand `ssh-keygen -R`
311
+ // invalidates the pinned line without changing `key` — trusting the latch alone
312
+ // is what stranded builders behind a green "pinned ✓" with no recovery. Cheap
313
+ // (file read + string scan), runs every active poll.
314
+ if (key === lastSetupHostname && !force) {
315
+ if (sshSetup.verifyKnownHosts(hostname, reported).ok) return;
316
+ // Believed-pinned but the file no longer matches → drop the latch + show truth.
317
+ lastSetupHostname = null;
318
+ controller.setHostKeys({ ok: false, reason: 'stale_pin' });
319
+ }
320
+ // A backoff retry is already scheduled for this activation → let the timer drive
321
+ // it instead of letting every emit re-fire the async, flaky keyscan. This is
322
+ // what PACES the retries rather than hammering them (the flicker's second cause).
323
+ if (hostKeysState.retryPendingKey === key && !force) return;
324
+ if (key !== hostKeysState.key) { hostKeysState.key = key; hostKeysState.attempts = 0; }
325
+ // Prefer the AUTHORITATIVE keys the box reported to the GDS (task 1187 / idea
326
+ // 235): no ssh-keyscan, no TOFU window, no KEX-negotiation flakiness (#910/#925).
327
+ // Fall back to keyscan only when the box hasn't reported yet (pre-report window /
328
+ // older server). Retries re-read a fresh snapshot, so once the box reports its
329
+ // keys the authoritative path takes over on the next attempt.
330
+ if (rawReported && staleReport) {
331
+ log(`ignoring reported host keys for ${hostname} — they predate this box; scanning instead`);
332
+ }
333
+ let out = reported
334
+ ? sshSetup.refreshKnownHostsFromKeys(hostname, reported, { log })
335
+ : { ok: false, reason: rawReported ? 'stale_reported_keys' : 'no_reported_keys' };
336
+ if (!out.ok) {
337
+ out = await sshSetup.refreshKnownHosts(hostname, { log });
338
+ }
339
+ if (out.ok) {
340
+ lastSetupHostname = key;
341
+ hostKeysState.attempts = 0;
342
+ hostKeysState.retryPendingKey = null;
343
+ controller.setHostKeys({ ok: true });
344
+ return;
345
+ }
346
+ hostKeysState.attempts += 1;
347
+ controller.setHostKeys({ ok: false, reason: out.reason, attempt: hostKeysState.attempts });
348
+ if (out.reason !== 'tool_missing' && hostKeysState.attempts <= 10) {
349
+ // sshd typically trails 'active' by seconds-to-a-minute on a fresh box.
350
+ hostKeysState.retryPendingKey = key; // gate emit-driven re-fires until the timer
351
+ const delay = Math.min(10_000 * hostKeysState.attempts, 60_000);
352
+ log(`host-key pinning retry ${hostKeysState.attempts} in ${delay / 1000}s (${out.reason})`);
353
+ setTimeout(() => {
354
+ hostKeysState.retryPendingKey = null; // release the gate; let this attempt run
355
+ const s = controller.snapshot();
356
+ if (s.status === 'active') onActiveSetup(s).catch((e) => log(e.message));
357
+ }, delay).unref?.();
358
+ } else {
359
+ hostKeysState.retryPendingKey = null;
360
+ }
361
+ } finally {
362
+ setupInFlight = false;
363
+ }
364
+ }
365
+
366
+ // --- update check ------------------------------------------------------------
367
+
368
+ // Poll the server's version endpoint and, when it advertises a STRICTLY newer
369
+ // release for our platform, latch updateInfo so the panel shows the banner. The
370
+ // compare is a proper semver one (updater.isUpdateAvailable) — NOT the old `!==`,
371
+ // which advertised a *downgrade* whenever the published config trailed the
372
+ // installed app (#924). Offline / unpublished / no installer for this platform →
373
+ // clear the banner and retry next tick. Never throws.
374
+ async function checkForUpdate() {
375
+ try {
376
+ const latest = await client.latestRelease();
377
+ const target = resolveTarget(process.platform, process.arch);
378
+ if (latest && target && isUpdateAvailable(appVersion(), latest.version)) {
379
+ updateInfo = {
380
+ version: latest.version,
381
+ target,
382
+ url: `${apiBase(session && session.api_base)}/downloads/devbox/${target}`,
383
+ banner: updateBannerText(latest.version),
384
+ };
385
+ } else {
386
+ updateInfo = null;
387
+ }
388
+ pushState();
389
+ } catch { /* offline or not yet published — try again next tick */ }
390
+ }
391
+
392
+ // Tier-1 one-click update (#924): download the installer for this platform to the
393
+ // Downloads dir, then hand it to the OS — macOS mounts the .dmg (drag-to-
394
+ // Applications; unsigned, so a one-time "Open Anyway"), Windows runs the NSIS
395
+ // oneClick installer + quits so it can replace the running app. ANY failure
396
+ // (offline, disk, blocked) falls back to opening the branded URL in the browser —
397
+ // the safe, always-works path (the pre-#924 behaviour) — so a one-click attempt
398
+ // can never leave the builder worse off than a manual download.
399
+ //
400
+ // ⚠ The download + OS hand-off leg is NOT yet hardware-verified (no signing; a GUI
401
+ // installer can't run in CI — mirrors the #928 sandbox-gate verify gap). The
402
+ // browser fallback is what guarantees the feature works in the field today.
403
+ function downloadTo(url, destPath) {
404
+ return new Promise((resolve, reject) => {
405
+ const request = net.request(url); // Electron net follows 302s (apex → GitHub asset)
406
+ request.on('response', (response) => {
407
+ if (response.statusCode >= 400) { reject(new Error(`download HTTP ${response.statusCode}`)); return; }
408
+ const out = fs.createWriteStream(destPath);
409
+ response.on('data', (chunk) => out.write(chunk));
410
+ response.on('end', () => out.end(() => resolve(destPath)));
411
+ response.on('error', reject);
412
+ out.on('error', reject);
413
+ });
414
+ request.on('error', reject);
415
+ request.end();
416
+ });
417
+ }
418
+
419
+ async function performUpdate() {
420
+ if (!updateInfo || !updateInfo.url) return false;
421
+ try {
422
+ const dir = app.getPath('downloads') || app.getPath('temp');
423
+ const ext = process.platform === 'darwin' ? 'dmg' : 'exe';
424
+ const dest = path.join(dir, `${brand.PRODUCT_SLUG}-${updateInfo.version}-${updateInfo.target}.${ext}`);
425
+ await downloadTo(updateInfo.url, dest);
426
+ const openErr = await shell.openPath(dest); // mac: mount dmg; win: run installer
427
+ if (openErr) throw new Error(openErr);
428
+ if (process.platform === 'darwin') {
429
+ notify({ title: `${brand.PRODUCT_NAME} v${updateInfo.version}`, body: 'Drag the app to Applications, then right-click → Open once (unsigned build).' });
430
+ } else if (process.platform === 'win32') {
431
+ app.isQuittingForReal = true;
432
+ setTimeout(() => app.quit(), 1500); // let the NSIS installer take over
433
+ }
434
+ return true;
435
+ } catch (e) {
436
+ log('in-app update failed; opening the download in the browser instead:', e.message);
437
+ shell.openExternal(updateInfo.url);
438
+ return false;
439
+ }
440
+ }
441
+
442
+ // --- wiring ------------------------------------------------------------------
443
+
444
+ app.whenReady().then(() => {
445
+ if (process.platform === 'darwin') app.dock.hide(); // pure menu-bar app
446
+
447
+ controller = createBoxController({
448
+ client,
449
+ signedIn: () => Boolean(session && session.token),
450
+ envName: brand.DESKTOP_ENV_NAME,
451
+ onChange: (snapshot) => {
452
+ pushState();
453
+ if (snapshot.status === 'active') onActiveSetup(snapshot).catch((e) => log(e.message));
454
+ },
455
+ onNotify: notify,
456
+ });
457
+
458
+ tray = createTray({
459
+ onOpen: showWindow,
460
+ onToggle: () => {
461
+ const s = controller.snapshot();
462
+ (s.status === 'active' ? controller.turnOff() : controller.turnOn()).catch(() => {});
463
+ },
464
+ onOpenTerminal: () => {
465
+ controller.markConnectAttempt(); // leg 3: connect action → keep the box alive
466
+ const url = terminalAuthUrl(controller.snapshot().terminal);
467
+ if (url) shell.openExternal(url);
468
+ showWindow(); // the credential lives in the panel
469
+ },
470
+ onOpenHall: () => shell.openExternal(brand.BUILDERS_ORIGIN),
471
+ onQuit: () => { app.isQuittingForReal = true; app.quit(); },
472
+ getLoginItem: () => app.getLoginItemSettings().openAtLogin,
473
+ setLoginItem: (v) => app.setLoginItemSettings({ openAtLogin: v }),
474
+ });
475
+
476
+ createWindow();
477
+ controller.start();
478
+ checkForUpdate();
479
+ // #924: re-check every 12h while the app runs, so a builder who leaves the
480
+ // tray app up for days still sees a release that lands after launch. unref()
481
+ // so the timer never keeps the process alive on its own.
482
+ setInterval(checkForUpdate, 12 * 60 * 60 * 1000).unref?.();
483
+
484
+ // First launch, not signed in → open the panel so the one button is visible.
485
+ if (!session) showWindow();
486
+
487
+ // If we ARE signed in, make sure the key is registered + settings current
488
+ // (no-op elevation prompt when the file already matches).
489
+ if (session) afterSignIn().catch((e) => log('startup setup:', e.message));
490
+
491
+ // Dev/verification hook (README): OTB_DEVBOX_AUTOFLOW=1 self-drives the
492
+ // whole journey — pair sign-in → switch on → active → switch off → quit —
493
+ // against a STUB GDS, capturing screenshots along the way. Guarded on
494
+ // OTB_DEVBOX_API_BASE being set so it can never run against production.
495
+ if (process.env.OTB_DEVBOX_AUTOFLOW === '1' && process.env.OTB_DEVBOX_API_BASE) {
496
+ autoflow().catch((e) => { log('autoflow FAILED:', e.message); process.exitCode = 1; app.isQuittingForReal = true; app.quit(); });
497
+ }
498
+ });
499
+
500
+ async function autoflow() {
501
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
502
+ const waitFor = async (want, timeoutMs) => {
503
+ const until = Date.now() + timeoutMs;
504
+ while (Date.now() < until) {
505
+ if (controller.snapshot().status === want) return;
506
+ await sleep(1000);
507
+ }
508
+ throw new Error(`autoflow: timed out waiting for status=${want} (at ${controller.snapshot().status})`);
509
+ };
510
+ log('autoflow: begin');
511
+ showWindow();
512
+ await sleep(2000);
513
+ if (controller.snapshot().status === 'signed_out') {
514
+ await signIn();
515
+ if (!session) throw new Error('autoflow: sign-in did not complete');
516
+ log('autoflow: signed in as', session.builder.github_login);
517
+ }
518
+ await waitFor('none', 20_000);
519
+ log('autoflow: box off — flipping on');
520
+ await controller.turnOn();
521
+ await waitFor('active', 180_000);
522
+ log('autoflow: ACTIVE — terminal:', JSON.stringify(controller.snapshot().terminal));
523
+ await sleep(8_000);
524
+ log('autoflow: flipping off');
525
+ await controller.turnOff();
526
+ await waitFor('none', 120_000);
527
+ log('autoflow: box closed — SUCCESS');
528
+ await sleep(3_000);
529
+ app.isQuittingForReal = true;
530
+ app.quit();
531
+ }
532
+
533
+ app.on('window-all-closed', () => { /* tray app — never quit on window close */ });
534
+ app.on('before-quit', () => { app.isQuittingForReal = true; });
535
+
536
+ // --- IPC (renderer is sandboxed; every action is a named verb, no raw URLs) --
537
+
538
+ ipcMain.handle('state:get', () => ({ snapshot: controller.snapshot(), meta: meta() }));
539
+ ipcMain.handle('auth:signIn', () => { signIn(); return true; });
540
+ ipcMain.handle('auth:cancelPairing', () => { if (pairing) pairing.cancel(); return true; });
541
+ ipcMain.handle('auth:signOut', () => signOut());
542
+ ipcMain.handle('box:on', async () => {
543
+ try { return { ok: true, out: await controller.turnOn() }; }
544
+ catch (e) { return { ok: false, error: e.message }; }
545
+ });
546
+ ipcMain.handle('box:off', async () => {
547
+ try { return { ok: true, out: await controller.turnOff() }; }
548
+ catch (e) { return { ok: false, error: e.message }; }
549
+ });
550
+ // BV1.R100: "What are you working on?" — list the builder's claimable tasks.
551
+ ipcMain.handle('tasks:claimable', async () => {
552
+ try { return { ok: true, tasks: shapeClaimableTasks(await client.claimableTasks()) }; }
553
+ catch (e) { return { ok: false, error: e.message }; }
554
+ });
555
+ // Pick + confirm: claim the task over the API, then read the credential-free box
556
+ // scope so we can name exactly what the box will hold. The scope read is
557
+ // best-effort — the claim already succeeded, so a scope hiccup still confirms.
558
+ ipcMain.handle('tasks:pick', async (_e, taskId) => {
559
+ try {
560
+ await client.claimTask(taskId);
561
+ controller.markConnectAttempt(); // picking a task is a connect-intent (leg-3 keep-alive)
562
+ let scope = null;
563
+ try { scope = await client.boxScope(); } catch (e) { log('tasks:pick scope read failed (non-fatal):', e.message); }
564
+ return { ok: true, message: boxScopeMessage({ task: { id: String(taskId) }, scope }), scope };
565
+ } catch (e) {
566
+ return { ok: false, error: claimErrorMessage(e) };
567
+ }
568
+ });
569
+ ipcMain.handle('open:terminal', () => {
570
+ controller.markConnectAttempt(); // leg 3: opening the terminal is a connect action
571
+ // Embed the basic-auth userinfo so the terminal opens already signed in.
572
+ const url = terminalAuthUrl(controller.snapshot().terminal);
573
+ if (url) shell.openExternal(url);
574
+ return Boolean(url);
575
+ });
576
+ ipcMain.handle('open:hall', () => shell.openExternal(brand.BUILDERS_ORIGIN));
577
+ ipcMain.handle('open:pairPage', () => {
578
+ if (pairingState && pairingState.verifyUrl) shell.openExternal(pairingState.verifyUrl);
579
+ return true;
580
+ });
581
+ ipcMain.handle('open:update', () => performUpdate());
582
+ ipcMain.handle('copy', (_e, what) => {
583
+ const s = controller.snapshot();
584
+ if (what === 'ssh' && s.box && s.box.hostname) {
585
+ clipboard.writeText(`ssh -i ~/.ssh/otb_builder root@${s.box.hostname}`);
586
+ return true;
587
+ }
588
+ if (what === 'terminal-credential' && s.terminal && s.terminal.credential) {
589
+ clipboard.writeText(s.terminal.credential);
590
+ return true;
591
+ }
592
+ if (what === 'terminal-user' && s.terminal && s.terminal.credential) {
593
+ clipboard.writeText(TERMINAL_USER);
594
+ return true;
595
+ }
596
+ return false;
597
+ });
598
+ ipcMain.handle('settings:install', () => installSettings());
599
+ ipcMain.handle('hostkeys:retry', async () => {
600
+ hostKeysState.attempts = 0;
601
+ const s = controller.snapshot();
602
+ if (s.status === 'active') await onActiveSetup(s, { force: true }).catch((e) => log(e.message));
603
+ return controller.snapshot().hostKeys;
604
+ });
605
+ ipcMain.handle('key:retry', async () => {
606
+ keyRegState.activation = null; // an explicit ask overrides the once-per-activation gate
607
+ await runKeySetup();
608
+ return controller.snapshot().keyRegistered;
609
+ });
610
+ ipcMain.handle('panel:hide', () => { if (win) win.hide(); return true; });
@@ -0,0 +1,57 @@
1
+ // devbox-app/src/pairing.js — browser-clicks-only sign-in (#904, ADR 0045).
2
+ //
3
+ // start() asks the GDS for a pairing, opens the approve page in the default
4
+ // browser, and polls until the builder clicks Approve. The minted token comes
5
+ // back over the app's own secret poll channel — it never transits the browser.
6
+ // Counterpart server logic: src/bongos/app-pair.js + routes/auth.js.
7
+
8
+ const os = require('node:os');
9
+ const brand = require('./brand');
10
+
11
+ function platformKey() {
12
+ if (process.platform === 'darwin') return 'mac';
13
+ if (process.platform === 'win32') return 'windows';
14
+ return 'linux';
15
+ }
16
+
17
+ function deviceName() {
18
+ const host = os.hostname().replace(/\.(local|lan|home)$/i, '');
19
+ return `${brand.PRODUCT_NAME} on ${host}`.slice(0, 80);
20
+ }
21
+
22
+ // createPairing({ client, openExternal, sleep }) — deps injected for tests.
23
+ // run(onUpdate) resolves { token, builder, expires_at } or throws
24
+ // Error('expired'|'cancelled').
25
+ function createPairing({ client, openExternal, sleep }) {
26
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
27
+ let cancelled = false;
28
+
29
+ return {
30
+ cancel() { cancelled = true; },
31
+ async run(onUpdate = () => {}) {
32
+ cancelled = false;
33
+ const start = await client.pairStart(deviceName(), platformKey());
34
+ onUpdate({ phase: 'waiting', pairCode: start.pair_code, verifyUrl: start.verify_url });
35
+ await openExternal(start.verify_url);
36
+ const intervalMs = Math.max(2, start.interval || 5) * 1000;
37
+ const deadline = Date.now() + (start.expires_in || 600) * 1000;
38
+ while (!cancelled && Date.now() < deadline) {
39
+ await wait(intervalMs);
40
+ if (cancelled) break;
41
+ let out;
42
+ try {
43
+ out = await client.pairPoll(start.poll_token);
44
+ } catch {
45
+ continue; // transient network blip — the deadline bounds us
46
+ }
47
+ if (out.status === 'approved') {
48
+ return { token: out.token, builder: out.builder, expires_at: out.expires_at };
49
+ }
50
+ if (out.status === 'expired') throw new Error('expired');
51
+ }
52
+ throw new Error(cancelled ? 'cancelled' : 'expired');
53
+ },
54
+ };
55
+ }
56
+
57
+ module.exports = { createPairing, platformKey, deviceName };