@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.
- package/.bongos-core.json +1576 -286
- package/.claude/skills/builder-box/SKILL.md +6 -0
- package/.claude/skills/builder-sequence/SKILL.md +97 -0
- package/.claude/skills/builder-ship/SKILL.md +8 -2
- package/.claude/skills/builder-start/SKILL.md +11 -0
- package/.claude/skills/builder-sync/SKILL.md +41 -0
- package/.claude/skills/fix-task/SKILL.md +64 -0
- package/.claude/skills/goal-create/SKILL.md +158 -0
- package/.claude/skills/goal-review/SKILL.md +3 -1
- package/.claude/skills/idea-triage/SKILL.md +14 -8
- package/.claude/skills/merge-mode/SKILL.md +2 -2
- package/.claude/skills/new-project/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +45 -18
- package/.env.local.example +4 -3
- package/README.md +4 -2
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +2 -2
- package/clients/bongos-client/bongos-client.global.js +82 -18
- package/clients/bongos-client/index.cjs +82 -18
- package/clients/bongos-client/index.d.ts +131 -29
- package/clients/bongos-client/index.mjs +82 -18
- package/config/branding.neutral.json +6 -1
- package/config/governance.neutral.json +21 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
- package/docs/adr/0090-metic-task-authoring.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
- package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
- package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
- package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
- package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
- package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
- package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
- package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
- package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
- package/docs/adr/0152-metic-task-abandonment.md +67 -0
- package/docs/adr/0153-sequential-chain-runner.md +51 -0
- package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
- package/docs/adr/README.md +10 -0
- package/docs/api/openapi.json +843 -17
- package/docs/api-reference.md +23 -10
- package/docs/architecture.md +4 -2
- package/docs/branding-contract.md +3 -1
- package/docs/canonical-permissions.md +21 -8
- package/docs/file-map.md +8 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
- package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
- package/docs/onboarding/diagrams/assertions.json +4 -7
- package/docs/onboarding/drachmae-and-karma.md +3 -3
- package/docs/onboarding/primer.md +17 -16
- package/docs/onboarding/slash-commands.md +124 -0
- package/docs/recipes/builder-box-lifecycle.md +23 -0
- package/docs/recipes/devbox-app-release.md +14 -4
- package/docs/recipes/instance-first-run.md +16 -0
- package/docs/recipes/local-dev.md +81 -0
- package/docs/recipes/ops-gotchas.md +19 -1
- package/docs/recipes/overnight-builder.md +1 -1
- package/docs/recipes/parallel-goal-waves.md +174 -0
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/standalone-live-docs.md +48 -0
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
- package/migrations/003_pms.sql +6 -7
- package/migrations/006_pms_v2.sql +3 -31
- package/migrations/core_193_task_needs_rebase.sql +28 -0
- package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
- package/migrations/core_195_idea_triage_provenance.sql +57 -0
- package/migrations/core_196_session_reward_topup.sql +69 -0
- package/modules/autonomy/pollers/routine-timers.js +9 -8
- package/modules/builder-settings/wandering-prefs.js +61 -18
- package/modules/dev-box/app/README.md +73 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
- package/modules/dev-box/app/electron-builder.yml +95 -0
- package/modules/dev-box/app/package-lock.json +5291 -0
- package/modules/dev-box/app/package.json +30 -0
- package/modules/dev-box/app/renderer/app.js +319 -0
- package/modules/dev-box/app/renderer/index.html +45 -0
- package/modules/dev-box/app/renderer/styles.css +246 -0
- package/modules/dev-box/app/renderer/theme.generated.css +16 -0
- package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
- package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
- package/modules/dev-box/app/scripts/gen-brand.js +138 -0
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
- package/modules/dev-box/app/scripts/gen-icons.js +286 -0
- package/modules/dev-box/app/scripts/vendor-client.js +23 -0
- package/modules/dev-box/app/src/box-controller.js +287 -0
- package/modules/dev-box/app/src/brand.generated.json +13 -0
- package/modules/dev-box/app/src/brand.js +16 -0
- package/modules/dev-box/app/src/config.js +19 -0
- package/modules/dev-box/app/src/gds-client.js +90 -0
- package/modules/dev-box/app/src/main.js +610 -0
- package/modules/dev-box/app/src/pairing.js +57 -0
- package/modules/dev-box/app/src/preload.js +29 -0
- package/modules/dev-box/app/src/session-store.js +58 -0
- package/modules/dev-box/app/src/ssh-setup.js +569 -0
- package/modules/dev-box/app/src/task-picker.js +70 -0
- package/modules/dev-box/app/src/tray.js +99 -0
- package/modules/dev-box/app/src/updater.js +55 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
- package/modules/dev-box/app/test/unit.mjs +526 -0
- package/modules/dev-box/box-access.js +121 -4
- package/modules/dev-box/boxes.js +20 -0
- package/modules/dev-box/module.json +1 -1
- package/modules/dev-box/routes/box.js +87 -3
- package/modules/economy/credits.js +116 -25
- package/modules/economy/reward.js +8 -0
- package/modules/economy/streak.js +2 -1
- package/modules/governance/CLAUDE.md +15 -0
- package/modules/governance/catalog.js +419 -0
- package/modules/governance/config.js +174 -0
- package/modules/governance/db.js +125 -0
- package/modules/governance/migrations/governance_001_tables.sql +81 -0
- package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
- package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
- package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
- package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
- package/modules/governance/module.json +10 -0
- package/modules/governance/protected-surfaces.js +88 -0
- package/modules/governance/protected-surfaces.json +80 -0
- package/modules/governance/rank-roles.js +83 -0
- package/modules/governance/resolver.js +221 -0
- package/modules/governance/routes/governance.js +89 -0
- package/modules/governance/routes/role-sync.js +49 -0
- package/modules/governance/session-scopes.js +127 -0
- package/modules/governance/session-scopes.json +38 -0
- package/modules/hall-ui/public/atlas.css +117 -0
- package/modules/hall-ui/public/atlas.html +3 -114
- package/modules/hall-ui/public/brand-apply.js +34 -6
- package/modules/hall-ui/public/builders.js +187 -219
- package/modules/hall-ui/public/diagrams.html +2 -0
- package/modules/hall-ui/public/dom-utils.js +27 -0
- package/modules/hall-ui/public/drachmae.html +2 -0
- package/modules/hall-ui/public/drachmae.js +54 -1
- package/modules/hall-ui/public/gate.css +98 -0
- package/modules/hall-ui/public/gate.html +3 -95
- package/modules/hall-ui/public/goal-inbox.js +4 -1
- package/modules/hall-ui/public/goals-page.js +626 -114
- package/modules/hall-ui/public/goals.css +79 -1
- package/modules/hall-ui/public/goals.html +38 -11
- package/modules/hall-ui/public/hall-widgets.js +62 -52
- package/modules/hall-ui/public/harbor.css +123 -0
- package/modules/hall-ui/public/harbor.html +3 -120
- package/modules/hall-ui/public/index.html +11 -4
- package/modules/hall-ui/public/instances-panel.js +4 -1
- package/modules/hall-ui/public/modules.css +63 -0
- package/modules/hall-ui/public/modules.html +3 -60
- package/modules/hall-ui/public/new-project-wizard.js +7 -3
- package/modules/hall-ui/public/not-ready.html +2 -0
- package/modules/hall-ui/public/pair.css +73 -0
- package/modules/hall-ui/public/pair.html +3 -70
- package/modules/hall-ui/public/palette.js +302 -0
- package/modules/hall-ui/public/primer.css +13 -0
- package/modules/hall-ui/public/primer.html +2 -0
- package/modules/hall-ui/public/profile.html +2 -0
- package/modules/hall-ui/public/ranks.html +2 -0
- package/modules/hall-ui/public/roadmap.css +179 -0
- package/modules/hall-ui/public/roadmap.html +9 -172
- package/modules/hall-ui/public/roadmap.js +329 -316
- package/modules/hall-ui/public/sessions.css +16 -0
- package/modules/hall-ui/public/sessions.html +3 -13
- package/modules/hall-ui/public/settings.html +2 -0
- package/modules/hall-ui/public/settings.js +4 -4
- package/modules/hall-ui/public/shell.js +143 -18
- package/modules/hall-ui/public/style.css +231 -39
- package/modules/hall-ui/public/task-detail.js +347 -26
- package/modules/hall-ui/public/task.html +6 -1
- package/modules/hall-ui/public/watch.html +2 -0
- package/modules/hall-ui/public/work.html +8 -6
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/ideas/inbox.js +61 -11
- package/modules/ideas/routes/inbox.js +23 -0
- package/modules/lifecycle/claim-eligibility.js +72 -0
- package/modules/lifecycle/db.js +519 -139
- package/modules/lifecycle/done-when.js +18 -3
- package/modules/lifecycle/github-push.js +19 -2
- package/modules/lifecycle/goal-advisory.js +114 -0
- package/modules/lifecycle/goal-authz.js +62 -0
- package/modules/lifecycle/lifecycle.js +9 -0
- package/modules/lifecycle/publish-reconciler.js +109 -6
- package/modules/lifecycle/routes/claims.js +68 -23
- package/modules/lifecycle/routes/done-when.js +110 -15
- package/modules/lifecycle/routes/goals.js +8 -38
- package/modules/lifecycle/routes/tasks.js +38 -6
- package/modules/lifecycle/ship-card.js +6 -0
- package/modules/onboarding/onboarding-state.js +1 -1
- package/modules/platform-identity/CLAUDE.md +15 -0
- package/modules/platform-identity/hub-device.js +147 -0
- package/modules/platform-identity/hub-keys.js +151 -0
- package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
- package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
- package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
- package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
- package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
- package/modules/platform-identity/module.json +18 -0
- package/modules/platform-identity/platform-identity.js +531 -0
- package/modules/platform-identity/pollers/code-sweep.js +43 -0
- package/modules/platform-identity/routes/my-projects.js +140 -0
- package/modules/platform-identity/routes/projects.js +107 -0
- package/modules/platform-identity/routes/scouting.js +35 -0
- package/modules/platform-identity/routes/sso.js +402 -0
- package/modules/platform-identity/tests/platform-identity.mjs +388 -0
- package/modules/platform-identity/ui/.gitkeep +0 -0
- package/modules/security/routes/reports.js +10 -0
- package/modules/sessions/db.js +166 -21
- package/modules/sessions/module.json +1 -1
- package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
- package/modules/sessions/routes/sessions.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/anim/idle/frame_00.webp +0 -0
- package/public-landing/assets/anim/idle/frame_01.webp +0 -0
- package/public-landing/assets/anim/idle/frame_02.webp +0 -0
- package/public-landing/assets/anim/idle/frame_03.webp +0 -0
- package/public-landing/assets/anim/idle/frame_04.webp +0 -0
- package/public-landing/assets/anim/idle/frame_05.webp +0 -0
- package/public-landing/assets/anim/idle/frame_06.webp +0 -0
- package/public-landing/assets/anim/idle/frame_07.webp +0 -0
- package/public-landing/assets/anim/idle/frame_08.webp +0 -0
- package/public-landing/assets/anim/idle/frame_09.webp +0 -0
- package/public-landing/assets/anim/idle/frame_10.webp +0 -0
- package/public-landing/assets/anim/idle/frame_11.webp +0 -0
- package/public-landing/assets/anim/idle/frame_12.webp +0 -0
- package/public-landing/assets/anim/idle/frame_13.webp +0 -0
- package/public-landing/assets/anim/idle/frame_14.webp +0 -0
- package/public-landing/assets/anim/idle/frame_15.webp +0 -0
- package/public-landing/assets/anim/idle/frame_16.webp +0 -0
- package/public-landing/assets/anim/idle/frame_17.webp +0 -0
- package/public-landing/assets/anim/idle/frame_18.webp +0 -0
- package/public-landing/assets/anim/idle/frame_19.webp +0 -0
- package/public-landing/assets/anim/idle/frame_20.webp +0 -0
- package/public-landing/assets/anim/idle/frame_21.webp +0 -0
- package/public-landing/assets/anim/idle/frame_22.webp +0 -0
- package/public-landing/assets/anim/idle/frame_23.webp +0 -0
- package/public-landing/assets/anim/idle/frame_24.webp +0 -0
- package/public-landing/assets/anim/idle/frame_25.webp +0 -0
- package/public-landing/assets/anim/idle/frame_26.webp +0 -0
- package/public-landing/assets/anim/idle/frame_27.webp +0 -0
- package/public-landing/assets/anim/idle/frame_28.webp +0 -0
- package/public-landing/assets/anim/idle/frame_29.webp +0 -0
- package/public-landing/assets/anim/idle/frame_30.webp +0 -0
- package/public-landing/assets/anim/idle/frame_31.webp +0 -0
- package/public-landing/assets/anim/idle/frame_32.webp +0 -0
- package/public-landing/assets/anim/idle/frame_33.webp +0 -0
- package/public-landing/assets/anim/idle/frame_34.webp +0 -0
- package/public-landing/assets/anim/idle/frame_35.webp +0 -0
- package/public-landing/assets/anim/idle/frame_36.webp +0 -0
- package/public-landing/assets/anim/idle/frame_37.webp +0 -0
- package/public-landing/assets/anim/idle/frame_38.webp +0 -0
- package/public-landing/assets/anim/idle/frame_39.webp +0 -0
- package/public-landing/assets/anim/idle/frame_40.webp +0 -0
- package/public-landing/assets/anim/idle/frame_41.webp +0 -0
- package/public-landing/assets/anim/idle/frame_42.webp +0 -0
- package/public-landing/assets/anim/idle/frame_43.webp +0 -0
- package/public-landing/create.html +51 -58
- package/public-landing/gate/README.md +137 -0
- package/public-landing/gate/dispmaps/bar.png +0 -0
- package/public-landing/gate/dispmaps/flat.png +0 -0
- package/public-landing/gate/dispmaps/key.png +0 -0
- package/public-landing/gate/index.html +587 -0
- package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
- package/public-landing/gate/previews/off-the-boats.jpg +0 -0
- package/public-landing/gate/space.css +799 -0
- package/public-landing/gate/space.js +1826 -0
- package/public-landing/gate/sprites/enter.png +0 -0
- package/public-landing/gate/sprites/search.png +0 -0
- package/public-landing/gate/sprites/terminal-blank.png +0 -0
- package/public-landing/gate/sprites/tile.png +0 -0
- package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
- package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
- package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
- package/public-landing/world/assets/plates/scene-establishing.png +0 -0
- package/scripts/gds/abandon.js +6 -4
- package/scripts/gds/api.js +10 -5
- package/scripts/gds/api.ps1 +26 -0
- package/scripts/gds/api.sh +6 -0
- package/scripts/gds/audit-ships.js +7 -1
- package/scripts/gds/backfill-sessions.js +78 -18
- package/scripts/gds/backup.js +23 -1
- package/scripts/gds/box-connect-lib.js +124 -31
- package/scripts/gds/box-sync.js +66 -0
- package/scripts/gds/box.js +225 -53
- package/scripts/gds/cf-tunnel.js +145 -6
- package/scripts/gds/claim.js +34 -3
- package/scripts/gds/cli-lib.js +161 -27
- package/scripts/gds/code.js +6 -2
- package/scripts/gds/deploy-divergence.js +209 -0
- package/scripts/gds/dev-lib.js +309 -0
- package/scripts/gds/dev.js +322 -0
- package/scripts/gds/doctor.js +90 -61
- package/scripts/gds/fetch-art-key.js +1 -1
- package/scripts/gds/fetch-sound-prefs.js +2 -2
- package/scripts/gds/fitness.js +228 -12
- package/scripts/gds/gds-db-backup.service +16 -1
- package/scripts/gds/gds-db-offsite.service +9 -1
- package/scripts/gds/gen-api-client.js +1 -1
- package/scripts/gds/gen-api-docs.js +36 -14
- package/scripts/gds/gen-diagrams.js +45 -28
- package/scripts/gds/gen-file-map.js +2 -1
- package/scripts/gds/gen-freshness.js +32 -0
- package/scripts/gds/gen-repo-map.js +3 -2
- package/scripts/gds/gen-session-index.js +14 -5
- package/scripts/gds/hooks/pre-push.js +2 -2
- package/scripts/gds/init.js +60 -3
- package/scripts/gds/install-git-hooks.js +1 -1
- package/scripts/gds/key-card.js +15 -6
- package/scripts/gds/key-status.js +1 -1
- package/scripts/gds/linkify-refs.js +35 -9
- package/scripts/gds/login.js +16 -1
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +123 -12
- package/scripts/gds/publish-manifest.js +7 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +4 -2
- package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
- package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
- package/scripts/gds/sequence.js +425 -0
- package/scripts/gds/session-cost.js +10 -1
- package/scripts/gds/setup.js +31 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +319 -29
- package/scripts/gds/smoke-gds.js +139 -8
- package/scripts/gds/start.js +171 -22
- package/scripts/gds/status.js +1 -1
- package/scripts/gds/triage.js +10 -9
- package/scripts/gds/upgrade.js +124 -2
- package/scripts/migrate.sh +99 -14
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +447 -102
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +39 -0
- package/src/bongos/module-scope-map.js +27 -1
- package/src/bongos/permission-path-check.js +136 -102
- package/src/bongos/repo-info.js +47 -11
- package/src/bongos/route-rank-check.js +429 -46
- package/src/bongos/routes/_helpers.js +12 -1
- package/src/bongos/routes/auth.js +219 -2
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +32 -0
- package/src/instance-config.js +30 -0
- package/src/module-api.js +124 -1
- package/src/platform-server.js +86 -9
- package/tests/abandon_task.mjs +59 -3
- package/tests/api_cli_flags.mjs +53 -0
- package/tests/auth_page_gate.mjs +13 -0
- package/tests/backfill_sessions.mjs +127 -1
- package/tests/backup_cli.mjs +54 -0
- package/tests/bongos_box_connect.mjs +77 -0
- package/tests/box_access.mjs +130 -1
- package/tests/box_credential.mjs +125 -0
- package/tests/box_host_keys.mjs +24 -0
- package/tests/box_infra_resolve.mjs +149 -0
- package/tests/box_scope_session.mjs +6 -2
- package/tests/box_sync.mjs +55 -0
- package/tests/box_task_scope.mjs +99 -0
- package/tests/branding.mjs +12 -0
- package/tests/cf_ruleset_preflight.mjs +142 -0
- package/tests/cf_tunnel.mjs +37 -0
- package/tests/claim_gate_rebase.mjs +98 -0
- package/tests/clawback_confirmed_backlog.mjs +103 -0
- package/tests/cli_lib_formatters.mjs +20 -0
- package/tests/confirm_attribution.mjs +30 -46
- package/tests/credit_grant.mjs +198 -10
- package/tests/criterion_authoring_gate.mjs +272 -0
- package/tests/criterion_progress.mjs +33 -1
- package/tests/dev_lib.mjs +253 -0
- package/tests/devbox_artifact_gate.mjs +75 -0
- package/tests/devbox_dmg_backdrop.mjs +137 -0
- package/tests/devbox_icons.mjs +304 -0
- package/tests/devbox_installer_brand.mjs +232 -0
- package/tests/devbox_key_register.mjs +234 -0
- package/tests/docs_openapi_route.mjs +110 -0
- package/tests/doctor_hooks.mjs +141 -0
- package/tests/extract_json.mjs +140 -0
- package/tests/fitness.mjs +89 -0
- package/tests/gen_freshness.mjs +96 -0
- package/tests/goal_advisory.mjs +251 -0
- package/tests/goal_criterion_confirm_ui.mjs +269 -0
- package/tests/goal_routes.mjs +59 -4
- package/tests/goal_tier.mjs +12 -3
- package/tests/governance_catalog.mjs +129 -0
- package/tests/governance_claim_eligibility.mjs +262 -0
- package/tests/governance_config.mjs +187 -0
- package/tests/governance_ownership_scope.mjs +410 -0
- package/tests/governance_page_gates.mjs +324 -0
- package/tests/governance_parity.mjs +336 -0
- package/tests/governance_principals.mjs +232 -0
- package/tests/governance_protected_surfaces.mjs +208 -0
- package/tests/governance_require_permission.mjs +223 -0
- package/tests/governance_resolver.mjs +84 -0
- package/tests/governance_role_sync.mjs +231 -0
- package/tests/governance_seed.mjs +127 -0
- package/tests/governance_session_scope.mjs +434 -0
- package/tests/governance_vanilla_boot.mjs +203 -0
- package/tests/grade_attribution.mjs +7 -7
- package/tests/hall_audit.mjs +223 -0
- package/tests/hall_nav.mjs +191 -0
- package/tests/hall_palette.mjs +206 -0
- package/tests/hall_signout.mjs +124 -0
- package/tests/hook_user_agents.mjs +10 -0
- package/tests/idea_triage.mjs +162 -0
- package/tests/idp_device_exchange.mjs +93 -0
- package/tests/init.mjs +51 -0
- package/tests/instance_config.mjs +65 -0
- package/tests/instance_mismatch_guard.mjs +23 -6
- package/tests/instance_release_config.mjs +104 -0
- package/tests/linkify_refs.mjs +15 -2
- package/tests/migrate_root_resolution.mjs +124 -1
- package/tests/module_contributions.mjs +55 -0
- package/tests/module_loader.mjs +10 -5
- package/tests/module_scope_active_claims.mjs +94 -0
- package/tests/needs_rebase.mjs +92 -0
- package/tests/one_active_claim_per_session.mjs +76 -0
- package/tests/override_attribution.mjs +10 -8
- package/tests/override_request_ownership.mjs +8 -4
- package/tests/parse_cookie_decode.mjs +42 -0
- package/tests/pay_on_land.mjs +129 -0
- package/tests/platform_boot.mjs +100 -1
- package/tests/post_login_landing.mjs +42 -0
- package/tests/preflight_checks.mjs +72 -0
- package/tests/provision.mjs +132 -7
- package/tests/publish_branch_route.mjs +53 -0
- package/tests/publish_reconciler.mjs +187 -2
- package/tests/repo_info_binding.mjs +248 -0
- package/tests/reward_gate.mjs +11 -2
- package/tests/reward_mode_cost_plus_only.mjs +116 -0
- package/tests/reward_mode_surfacing.mjs +168 -0
- package/tests/route_permission_check.mjs +301 -0
- package/tests/route_rank_check.mjs +254 -12
- package/tests/route_validation.mjs +30 -0
- package/tests/sequence.mjs +267 -0
- package/tests/session_digest.mjs +44 -0
- package/tests/session_index.mjs +18 -6
- package/tests/session_records.mjs +39 -1
- package/tests/session_reward_on_land.mjs +381 -0
- package/tests/session_reward_reconcile.mjs +229 -0
- package/tests/ship_api_client_pathspec.mjs +100 -0
- package/tests/ship_base_flag.mjs +93 -0
- package/tests/ship_ci_deploy.mjs +80 -3
- package/tests/ship_upstream_deletion.mjs +57 -0
- package/tests/smoke_target.mjs +233 -0
- package/tests/start_env_guard.mjs +87 -0
- package/tests/start_rebase_warning.mjs +103 -0
- package/tests/status_mirror_sync.mjs +122 -0
- package/tests/stop_refcheck.mjs +19 -1
- package/tests/task_goal_link.mjs +15 -6
- package/tests/upgrade.mjs +139 -0
- package/tests/web_admission_status.mjs +162 -0
- 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 };
|