@bongos/core 1.18.0 → 1.18.18
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 +1740 -640
- 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 +5 -3
- 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 +11 -2
- 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/0142-project-context-is-prose-only.md +41 -0
- package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
- 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 +15 -2
- package/docs/api/openapi.json +2648 -483
- package/docs/api-reference.md +73 -13
- 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 +15 -7
- 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/packaging-the-core.md +13 -0
- 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/upgrading-the-core.md +5 -2
- 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/039_newcomer_friendly_column.sql +5 -0
- package/migrations/102_remove_box_first_provision_approval.sql +4 -0
- package/migrations/core_192_github_oauth_tokens.sql +33 -0
- 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/my-projects.html +78 -0
- package/modules/hall-ui/public/my-projects.js +142 -0
- 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/scouting.html +45 -0
- package/modules/hall-ui/public/scouting.js +73 -0
- 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/modules/status-ui/public/explore.html +54 -0
- package/modules/status-ui/public/explore.js +67 -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 +252 -62
- 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 -21
- 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/lint-migrations.js +102 -0
- package/scripts/gds/login.js +58 -11
- package/scripts/gds/mirror-redact.js +5 -0
- package/scripts/gds/package-core.js +263 -4
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +268 -19
- package/scripts/gds/publish-manifest.js +22 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +42 -16
- 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 +72 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +433 -59
- 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 +271 -7
- package/scripts/migrate.sh +10 -0
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +809 -107
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +105 -0
- package/src/bongos/github-repos.js +93 -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 +312 -13
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/github.js +90 -0
- package/src/bongos/routes/instance.js +15 -3
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/routes.js +2 -0
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +49 -2
- package/src/instance-config.js +46 -0
- package/src/module-api.js +140 -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 +41 -23
- 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/github_repos_helpers.mjs +91 -0
- package/tests/github_token_store.mjs +121 -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 +198 -0
- package/tests/idp_federation.mjs +100 -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/migrations_forward_safe.mjs +86 -0
- package/tests/module_api.mjs +1 -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/package_core.mjs +246 -1
- 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 +289 -7
- package/tests/pruned_lockfile_integrity.mjs +79 -0
- 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 +140 -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 +393 -5
- package/tests/web_admission_status.mjs +162 -0
- package/tests/write_validation_gaps.mjs +110 -0
- package/.gitignore +0 -91
- package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
- package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
- package/scripts/hall-preview/fixtures/access-requests.json +0 -20
- package/scripts/hall-preview/fixtures/achievements.json +0 -76
- package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
- package/scripts/hall-preview/fixtures/audit-log.json +0 -68
- package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
- package/scripts/hall-preview/fixtures/box__me.json +0 -5
- package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
- package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
- package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
- package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
- package/scripts/hall-preview/fixtures/goals.json +0 -502
- package/scripts/hall-preview/fixtures/goals__25.json +0 -96
- package/scripts/hall-preview/fixtures/goals__26.json +0 -67
- package/scripts/hall-preview/fixtures/goals__3.json +0 -103
- package/scripts/hall-preview/fixtures/goals__35.json +0 -49
- package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
- package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
- package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
- package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
- package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
- package/scripts/hall-preview/fixtures/inbox.json +0 -330
- package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
- package/scripts/hall-preview/fixtures/instance.json +0 -124
- package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
- package/scripts/hall-preview/fixtures/me.json +0 -476
- package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
- package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
- package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
- package/scripts/hall-preview/fixtures/me__render.json +0 -5
- package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
- package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
- package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
- package/scripts/hall-preview/fixtures/override-requests.json +0 -15
- package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
- package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
- package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
- package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
- package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
- package/scripts/hall-preview/fixtures/public__grades.json +0 -50
- package/scripts/hall-preview/fixtures/public__progress.json +0 -124
- package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
- package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
- package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
- package/scripts/hall-preview/fixtures/security__docs.json +0 -10
- package/scripts/hall-preview/fixtures/security__reports.json +0 -31
- package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
- package/scripts/hall-preview/fixtures/tasks.json +0 -2420
- package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
- package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
- package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
- package/scripts/hall-preview/fixtures/versions.json +0 -89
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// devbox-app/src/preload.js — the renderer's ONLY door into the main process.
|
|
2
|
+
// Sandboxed + contextIsolated; every capability is a named verb (no raw URLs,
|
|
3
|
+
// no arbitrary IPC) so a compromised renderer can do nothing the panel UI
|
|
4
|
+
// couldn't already do.
|
|
5
|
+
|
|
6
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
7
|
+
|
|
8
|
+
contextBridge.exposeInMainWorld('otb', {
|
|
9
|
+
getState: () => ipcRenderer.invoke('state:get'),
|
|
10
|
+
signIn: () => ipcRenderer.invoke('auth:signIn'),
|
|
11
|
+
cancelPairing: () => ipcRenderer.invoke('auth:cancelPairing'),
|
|
12
|
+
signOut: () => ipcRenderer.invoke('auth:signOut'),
|
|
13
|
+
boxOn: () => ipcRenderer.invoke('box:on'),
|
|
14
|
+
boxOff: () => ipcRenderer.invoke('box:off'),
|
|
15
|
+
claimable: () => ipcRenderer.invoke('tasks:claimable'),
|
|
16
|
+
pickTask: (taskId) => ipcRenderer.invoke('tasks:pick', taskId),
|
|
17
|
+
openTerminal: () => ipcRenderer.invoke('open:terminal'),
|
|
18
|
+
openHall: () => ipcRenderer.invoke('open:hall'),
|
|
19
|
+
openPairPage: () => ipcRenderer.invoke('open:pairPage'),
|
|
20
|
+
openUpdate: () => ipcRenderer.invoke('open:update'),
|
|
21
|
+
copy: (what) => ipcRenderer.invoke('copy', what),
|
|
22
|
+
installSettings: () => ipcRenderer.invoke('settings:install'),
|
|
23
|
+
retryHostKeys: () => ipcRenderer.invoke('hostkeys:retry'),
|
|
24
|
+
retryKey: () => ipcRenderer.invoke('key:retry'),
|
|
25
|
+
hide: () => ipcRenderer.invoke('panel:hide'),
|
|
26
|
+
onState: (cb) => {
|
|
27
|
+
ipcRenderer.on('state', (_e, payload) => cb(payload));
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// devbox-app/src/session-store.js — the app shares the GDS CLI's session file.
|
|
2
|
+
//
|
|
3
|
+
// ~/.config/<instance>/gds-session.json is the same file scripts/gds/cli-lib.js and
|
|
4
|
+
// paste-token.js read/write: { token, builder, api_base, ... }. Sharing it is
|
|
5
|
+
// a deliberate UX decision (ADR 0045): pairing the app also signs in every
|
|
6
|
+
// /builder-* skill on this machine, and a builder who already ran
|
|
7
|
+
// /builder-setup opens the app already signed in. Writes are merge-preserving
|
|
8
|
+
// (unknown fields like gemini_api_key survive, mirroring paste-token.js) and
|
|
9
|
+
// 0600 like the CLI's.
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const brand = require('./brand');
|
|
15
|
+
|
|
16
|
+
// The instance's CLI config dir (branding configDir) — so the app shares the
|
|
17
|
+
// exact session file the /builder-* CLI uses on this machine.
|
|
18
|
+
const DIR = path.join(os.homedir(), '.config', brand.CONFIG_DIR);
|
|
19
|
+
const FILE = path.join(DIR, 'gds-session.json');
|
|
20
|
+
// Read-side legacy name only (pre-GDS rename) — never written.
|
|
21
|
+
const FILE_LEGACY = path.join(DIR, 'pms-session.json');
|
|
22
|
+
|
|
23
|
+
function read() {
|
|
24
|
+
for (const f of [FILE, FILE_LEGACY]) {
|
|
25
|
+
try {
|
|
26
|
+
const data = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
27
|
+
if (data && typeof data.token === 'string' && data.token) return data;
|
|
28
|
+
} catch { /* missing or unparsable → keep looking */ }
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function write({ token, builder, apiBase }) {
|
|
34
|
+
const existing = read() || {};
|
|
35
|
+
const next = {
|
|
36
|
+
...existing,
|
|
37
|
+
token,
|
|
38
|
+
builder: builder ?? existing.builder ?? null,
|
|
39
|
+
api_base: apiBase ?? existing.api_base ?? null,
|
|
40
|
+
};
|
|
41
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
42
|
+
fs.writeFileSync(FILE, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
43
|
+
// Re-assert the mode for pre-existing files (chmod is a no-op on Windows,
|
|
44
|
+
// where the home-dir ACL is the protection — same posture as the CLI).
|
|
45
|
+
try { fs.chmodSync(FILE, 0o600); } catch { /* best-effort */ }
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Sign-out: drop ONLY our credential fields; preserve everything else
|
|
50
|
+
// (gemini_api_key etc.) so the app never destroys CLI-adjacent config.
|
|
51
|
+
function clear() {
|
|
52
|
+
const existing = read();
|
|
53
|
+
if (!existing) return;
|
|
54
|
+
const { token: _t, builder: _b, ...rest } = existing;
|
|
55
|
+
fs.writeFileSync(FILE, `${JSON.stringify(rest, null, 2)}\n`, { mode: 0o600 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { read, write, clear, FILE };
|
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
// devbox-app/src/ssh-setup.js — everything connect.sh could not do reliably,
|
|
2
|
+
// done natively (#904, ADR 0045). Three jobs, each fixing a failure proven in
|
|
3
|
+
// the 2026-06-10 onboarding session exports:
|
|
4
|
+
//
|
|
5
|
+
// 1. ensureKey() — ~/.ssh/otb_builder ed25519 keypair with REAL 600
|
|
6
|
+
// perms (git-bash left 644 world-readable keys) and
|
|
7
|
+
// a proper Windows ACL, registered via the API.
|
|
8
|
+
// 2. refreshKnownHosts() — ssh-keyscan the box hostname into
|
|
9
|
+
// ~/.ssh/known_hosts, REPLACING stale lines. Claude
|
|
10
|
+
// Desktop's embedded ssh2 reads only known_hosts —
|
|
11
|
+
// never ~/.ssh/config — and every re-provision mints
|
|
12
|
+
// new host keys, so stale entries hard-fail the
|
|
13
|
+
// connect (the known project bug).
|
|
14
|
+
// 3. installManagedSettings() — write the Claude Desktop managed-settings
|
|
15
|
+
// file to the SYSTEM path with a native elevation
|
|
16
|
+
// prompt (osascript admin / UAC). The agent-driven
|
|
17
|
+
// flow could never finish this step: Claude
|
|
18
|
+
// Desktop's own classifier blocks an agent touching
|
|
19
|
+
// permission machinery; a native app the human
|
|
20
|
+
// launched can simply ask the OS.
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const crypto = require('node:crypto');
|
|
26
|
+
const { execFile } = require('node:child_process');
|
|
27
|
+
|
|
28
|
+
const SSH_DIR = path.join(os.homedir(), '.ssh');
|
|
29
|
+
const KEY_PATH = path.join(SSH_DIR, 'otb_builder');
|
|
30
|
+
const KNOWN_HOSTS = path.join(SSH_DIR, 'known_hosts');
|
|
31
|
+
|
|
32
|
+
const IS_MAC = process.platform === 'darwin';
|
|
33
|
+
const IS_WIN = process.platform === 'win32';
|
|
34
|
+
|
|
35
|
+
function run(cmd, args, opts = {}) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
execFile(cmd, args, { timeout: 30_000, windowsHide: true, ...opts }, (err, stdout, stderr) => {
|
|
38
|
+
if (err) { err.stdout = stdout; err.stderr = stderr; return reject(err); }
|
|
39
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Resolve an OpenSSH client tool to an absolute path. A GUI-launched Electron
|
|
45
|
+
// process on Windows can carry a leaner PATH than a terminal, and the
|
|
46
|
+
// Parallels hardware test (#909) showed a bare 'ssh-keyscan' failing silently
|
|
47
|
+
// while C:\Windows\System32\OpenSSH\ssh-keyscan.exe existed. Order: the
|
|
48
|
+
// canonical Windows install, then Git-for-Windows' bundled copy, then PATH.
|
|
49
|
+
function resolveSshTool(name) {
|
|
50
|
+
if (IS_WIN) {
|
|
51
|
+
const candidates = [
|
|
52
|
+
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', `${name}.exe`),
|
|
53
|
+
'C:\\Program Files\\Git\\usr\\bin\\' + `${name}.exe`,
|
|
54
|
+
];
|
|
55
|
+
for (const c of candidates) {
|
|
56
|
+
try { if (fs.existsSync(c)) return c; } catch { /* keep looking */ }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return name; // POSIX or fall through to PATH resolution
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- 1. SSH key ------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
// PURE (unit-tested): the SHA256 fingerprint `ssh-keygen -l` prints — 'SHA256:' +
|
|
65
|
+
// base64(sha256(decoded key blob)) with '=' padding stripped. Byte-identical to the
|
|
66
|
+
// server's own box-onboard.sshFingerprintSha256, so a local fingerprint can be
|
|
67
|
+
// compared straight against a row from GET /box/ssh-key. Deliberately node:crypto
|
|
68
|
+
// and NOT a shell-out: box-connect-lib's ssh-keygen-based localKeyFingerprint
|
|
69
|
+
// returns null whenever the tool is missing from a GUI process's leaner PATH, and
|
|
70
|
+
// "no fingerprint" is exactly what used to make the caller give up.
|
|
71
|
+
function sshFingerprintSha256(base64Body) {
|
|
72
|
+
const blob = Buffer.from(String(base64Body || ''), 'base64');
|
|
73
|
+
const digest = crypto.createHash('sha256').update(blob).digest('base64').replace(/=+$/, '');
|
|
74
|
+
return `SHA256:${digest}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// PURE: fingerprint of a `<type> <base64> [comment]` public-key line, or null when
|
|
78
|
+
// the line isn't a key. The comment is excluded — that is why re-registering the
|
|
79
|
+
// same key under a different label de-dupes to one server row.
|
|
80
|
+
function publicKeyFingerprint(pubLine) {
|
|
81
|
+
const parts = String(pubLine || '').trim().split(/\s+/);
|
|
82
|
+
if (parts.length < 2 || !parts[1]) return null;
|
|
83
|
+
return sshFingerprintSha256(parts[1]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const REGISTER_ATTEMPTS = 3;
|
|
87
|
+
|
|
88
|
+
// Normalize whatever listKeys() hands back (the route body `{ keys: [...] }`, or a
|
|
89
|
+
// bare array) into a fingerprint list.
|
|
90
|
+
function listedFingerprints(listed) {
|
|
91
|
+
const rows = Array.isArray(listed) ? listed : (listed && Array.isArray(listed.keys) ? listed.keys : null);
|
|
92
|
+
if (!rows) return null;
|
|
93
|
+
return rows.map((k) => (k && k.fingerprint) || '').filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Register the public key, retry on failure, then VERIFY against the server's own
|
|
97
|
+
// key list. The old path was a single un-retried POST whose rejection was logged and
|
|
98
|
+
// dropped: a flaky network or a 500 at sign-in left the builder with no key in the
|
|
99
|
+
// box's authorized_keys and nothing ever tried again — every later connect refused,
|
|
100
|
+
// with the panel showing no hint why. Registering is not proof; being in the list is.
|
|
101
|
+
//
|
|
102
|
+
// FULLY INJECTED (registerKey/listKeys/sleep) so it is unit-testable with no network,
|
|
103
|
+
// no fs and no Electron. NEVER throws — callers get a reason they can surface.
|
|
104
|
+
// Reasons: registered_verified | verify_failed | register_failed | list_failed.
|
|
105
|
+
async function registerKeyWithVerify({
|
|
106
|
+
publicKey,
|
|
107
|
+
label,
|
|
108
|
+
registerKey,
|
|
109
|
+
listKeys,
|
|
110
|
+
sleep: sleepFn = sleep,
|
|
111
|
+
attempts = REGISTER_ATTEMPTS,
|
|
112
|
+
log = () => {},
|
|
113
|
+
} = {}) {
|
|
114
|
+
const fingerprint = publicKeyFingerprint(publicKey);
|
|
115
|
+
let registered = false;
|
|
116
|
+
let used = 0;
|
|
117
|
+
let detail = '';
|
|
118
|
+
for (let i = 1; i <= attempts; i += 1) {
|
|
119
|
+
used = i;
|
|
120
|
+
try {
|
|
121
|
+
await registerKey(publicKey, label);
|
|
122
|
+
registered = true;
|
|
123
|
+
break;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
detail = String((e && e.message) || e).slice(0, 160);
|
|
126
|
+
log(`ssh key registration attempt ${i}/${attempts} failed: ${detail}`);
|
|
127
|
+
// 1s / 2s / 4s — a wake-from-sleep network or a restarting server settles
|
|
128
|
+
// well inside that, and the whole budget is under 10s.
|
|
129
|
+
if (i < attempts) await sleepFn(1000 * 2 ** (i - 1));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
let listed;
|
|
133
|
+
try {
|
|
134
|
+
listed = await listKeys();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
fingerprint,
|
|
139
|
+
verified: false,
|
|
140
|
+
attempts: used,
|
|
141
|
+
reason: 'list_failed',
|
|
142
|
+
detail: String((e && e.message) || e).slice(0, 160),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const fingerprints = listedFingerprints(listed);
|
|
146
|
+
// A key already on the server counts as verified even if every POST failed — the
|
|
147
|
+
// POST is the means, presence in authorized_keys is the end.
|
|
148
|
+
const verified = Boolean(fingerprint) && Array.isArray(fingerprints) && fingerprints.includes(fingerprint);
|
|
149
|
+
if (verified) return { ok: true, fingerprint, verified: true, attempts: used, reason: 'registered_verified' };
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
fingerprint,
|
|
153
|
+
verified: false,
|
|
154
|
+
attempts: used,
|
|
155
|
+
reason: registered ? 'verify_failed' : 'register_failed',
|
|
156
|
+
detail,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function ensureKey({ registerKey, listKeys, sleep: sleepFn = sleep, log = () => {} }) {
|
|
161
|
+
fs.mkdirSync(SSH_DIR, { recursive: true, mode: 0o700 });
|
|
162
|
+
if (!fs.existsSync(KEY_PATH)) {
|
|
163
|
+
log('generating ed25519 keypair');
|
|
164
|
+
await run(resolveSshTool('ssh-keygen'), ['-t', 'ed25519', '-N', '', '-C', 'otb-devbox-app', '-f', KEY_PATH]);
|
|
165
|
+
}
|
|
166
|
+
// Permissions: real chmod on POSIX; on Windows scope the ACL to the current
|
|
167
|
+
// user (ssh.exe refuses world-readable keys the same way OpenSSH does, and
|
|
168
|
+
// a git-bash chmod is cosmetic there).
|
|
169
|
+
if (IS_WIN) {
|
|
170
|
+
const user = process.env.USERNAME || os.userInfo().username;
|
|
171
|
+
try {
|
|
172
|
+
await run('icacls', [KEY_PATH, '/inheritance:r', '/grant:r', `${user}:F`]);
|
|
173
|
+
} catch (e) { log(`icacls failed (non-fatal): ${e.message}`); }
|
|
174
|
+
} else {
|
|
175
|
+
fs.chmodSync(KEY_PATH, 0o600);
|
|
176
|
+
}
|
|
177
|
+
const publicKey = fs.readFileSync(`${KEY_PATH}.pub`, 'utf8').trim();
|
|
178
|
+
// Upsert by fingerprint server-side — safe to repeat on every sign-in — then
|
|
179
|
+
// verify it actually landed. `registration` never throws; the caller surfaces it.
|
|
180
|
+
const registration = await registerKeyWithVerify({
|
|
181
|
+
publicKey,
|
|
182
|
+
label: `devbox-app ${os.hostname()}`.slice(0, 80),
|
|
183
|
+
registerKey,
|
|
184
|
+
listKeys,
|
|
185
|
+
sleep: sleepFn,
|
|
186
|
+
log,
|
|
187
|
+
});
|
|
188
|
+
return { keyPath: KEY_PATH, publicKey, registration };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- 2. known_hosts --------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
// PURE (unit-tested): does this known_hosts line name `hostname`? Field 1 is either
|
|
194
|
+
// a plain `host` / `host,ip` list, or — when the client runs with HashKnownHosts on
|
|
195
|
+
// (Debian/Ubuntu ship it ON by default) — the opaque `|1|<b64 salt>|<b64 hash>` form,
|
|
196
|
+
// where hash = HMAC-SHA1(keyed by the salt) over the hostname. Matching only the
|
|
197
|
+
// plain form is why stale lines ACCUMULATED instead of being replaced: a hashed
|
|
198
|
+
// entry never matched, so a re-pin appended a second line for the same host and the
|
|
199
|
+
// client kept verifying against the first, stale one ("Host denied").
|
|
200
|
+
function knownHostsLineMatchesHost(line, hostname) {
|
|
201
|
+
if (!hostname) return false;
|
|
202
|
+
const t = String(line || '').trim();
|
|
203
|
+
if (!t || t.startsWith('#')) return false;
|
|
204
|
+
const field = t.split(/\s+/)[0] || '';
|
|
205
|
+
if (!field.startsWith('|1|')) return field.split(',').includes(hostname);
|
|
206
|
+
const [, , salt, hash] = field.split('|');
|
|
207
|
+
if (!salt || !hash) return false;
|
|
208
|
+
try {
|
|
209
|
+
return crypto.createHmac('sha1', Buffer.from(salt, 'base64')).update(hostname).digest('base64') === hash;
|
|
210
|
+
} catch {
|
|
211
|
+
return false; // unparseable salt → leave the line alone rather than guess
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Pure helper (unit-tested): replace every line for `hostname` with the
|
|
216
|
+
// freshly scanned ones. Handles plain, `host,ip` and hashed entries alike.
|
|
217
|
+
function mergeKnownHosts(existingText, hostname, scannedLines) {
|
|
218
|
+
const keep = String(existingText || '')
|
|
219
|
+
.split('\n')
|
|
220
|
+
.filter((line) => !knownHostsLineMatchesHost(line, hostname));
|
|
221
|
+
const fresh = scannedLines
|
|
222
|
+
.map((l) => l.trim())
|
|
223
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
224
|
+
const out = [...keep, ...fresh].join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
225
|
+
return `${out}\n`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Every ssh-keyscan we might be able to use, best first. The Parallels
|
|
229
|
+
// hardware run (#910) showed why a CHAIN is required: Windows' bundled
|
|
230
|
+
// OpenSSH was too old to negotiate Ubuntu 24.04 sshd's KEX
|
|
231
|
+
// ("unsupported KEX method sntrup761x25519-sha512…"), while Git for
|
|
232
|
+
// Windows' bundled copy is modern and succeeds.
|
|
233
|
+
function keyscanCandidates() {
|
|
234
|
+
const out = [];
|
|
235
|
+
if (IS_WIN) {
|
|
236
|
+
for (const c of [
|
|
237
|
+
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh-keyscan.exe'),
|
|
238
|
+
'C:\\Program Files\\Git\\usr\\bin\\ssh-keyscan.exe',
|
|
239
|
+
]) {
|
|
240
|
+
try { if (fs.existsSync(c)) out.push(c); } catch { /* skip */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
out.push('ssh-keyscan'); // PATH fallback, and the POSIX path
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const HOST_KEY_TYPES = ['ed25519', 'ecdsa', 'rsa'];
|
|
248
|
+
|
|
249
|
+
// Claude Desktop's embedded ssh2 negotiates ed25519 first and hard-fails when
|
|
250
|
+
// the type it picked is absent from known_hosts — so a scan without an
|
|
251
|
+
// ed25519 line is NOT a usable pin, no matter how many other keys it got
|
|
252
|
+
// (#925: rsa-only pin → "Host denied" behind a green check).
|
|
253
|
+
const REQUIRED_KEY_ALGO = 'ssh-ed25519';
|
|
254
|
+
const SCAN_ATTEMPTS_PER_TYPE = 3;
|
|
255
|
+
|
|
256
|
+
function scanComplete(lines) {
|
|
257
|
+
return lines.some((l) => (l.trim().split(/\s+/)[1] || '') === REQUIRED_KEY_ALGO);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sleep(ms) {
|
|
261
|
+
return new Promise((resolve) => { setTimeout(resolve, ms).unref?.(); });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Scan one host with one tool. Per-type sequential connections (a single
|
|
265
|
+
// multi-type scan opens parallel connections and sshd's MaxStartups drops
|
|
266
|
+
// some — observed as "Connection closed by remote host"), each type retried
|
|
267
|
+
// when its probe comes back empty (the same drops hit single probes too),
|
|
268
|
+
// and stdout is harvested EVEN when the tool exits non-zero: a non-empty
|
|
269
|
+
// partial scan still carries usable keys.
|
|
270
|
+
async function scanWithTool(tool, hostname) {
|
|
271
|
+
const lines = [];
|
|
272
|
+
let lastErr = '';
|
|
273
|
+
for (const type of HOST_KEY_TYPES) {
|
|
274
|
+
for (let attempt = 1; attempt <= SCAN_ATTEMPTS_PER_TYPE; attempt += 1) {
|
|
275
|
+
let stdout = '';
|
|
276
|
+
try {
|
|
277
|
+
({ stdout } = await run(tool, ['-T', '8', '-t', type, hostname]));
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (e.code === 'ENOENT') throw e; // tool itself missing — try the next one
|
|
280
|
+
stdout = String(e.stdout || '');
|
|
281
|
+
lastErr = String(e.stderr || e.message).split('\n').find((l) => l.trim()) || lastErr;
|
|
282
|
+
}
|
|
283
|
+
const got = stdout.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
284
|
+
if (got.length > 0) { lines.push(...got); break; }
|
|
285
|
+
if (attempt < SCAN_ATTEMPTS_PER_TYPE) await sleep(1000 * attempt);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { lines, lastErr };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function writeKnownHosts(hostname, lines) {
|
|
292
|
+
fs.mkdirSync(SSH_DIR, { recursive: true, mode: 0o700 });
|
|
293
|
+
const existing = fs.existsSync(KNOWN_HOSTS) ? fs.readFileSync(KNOWN_HOSTS, 'utf8') : '';
|
|
294
|
+
fs.writeFileSync(KNOWN_HOSTS, mergeKnownHosts(existing, hostname, lines));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function refreshKnownHosts(hostname, { log = () => {} } = {}) {
|
|
298
|
+
if (!hostname) return { ok: false, reason: 'no_hostname' };
|
|
299
|
+
let lastErr = '';
|
|
300
|
+
let sawTool = false;
|
|
301
|
+
let partial = null; // best incomplete scan — written as a fallback, never latched as ok
|
|
302
|
+
for (const tool of keyscanCandidates()) {
|
|
303
|
+
let scan;
|
|
304
|
+
try {
|
|
305
|
+
scan = await scanWithTool(tool, hostname);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (e.code === 'ENOENT') continue; // candidate not present — next
|
|
308
|
+
scan = { lines: [], lastErr: String(e.message) };
|
|
309
|
+
}
|
|
310
|
+
sawTool = true;
|
|
311
|
+
if (scanComplete(scan.lines)) {
|
|
312
|
+
writeKnownHosts(hostname, scan.lines);
|
|
313
|
+
log(`known_hosts refreshed for ${hostname} (${scan.lines.length} keys via ${tool})`);
|
|
314
|
+
return { ok: true, keys: scan.lines.length, tool };
|
|
315
|
+
}
|
|
316
|
+
if (scan.lines.length > 0 && (!partial || scan.lines.length > partial.lines.length)) {
|
|
317
|
+
partial = { lines: scan.lines, tool };
|
|
318
|
+
}
|
|
319
|
+
lastErr = scan.lastErr || lastErr;
|
|
320
|
+
log(`ssh-keyscan (${tool}) incomplete for ${hostname} (${scan.lines.length} keys, no ${REQUIRED_KEY_ALGO}): ${lastErr.slice(0, 160)}`);
|
|
321
|
+
}
|
|
322
|
+
if (!sawTool) return { ok: false, reason: 'tool_missing', detail: 'no ssh-keyscan found' };
|
|
323
|
+
if (partial) {
|
|
324
|
+
// Fresher-than-stale: keep what we scanned (plain OpenSSH can use it),
|
|
325
|
+
// but report failure so the caller retries and the panel never shows the
|
|
326
|
+
// green check without the key Desktop verifies (#925).
|
|
327
|
+
writeKnownHosts(hostname, partial.lines);
|
|
328
|
+
log(`known_hosts partially refreshed for ${hostname} (${partial.lines.length} keys via ${partial.tool}, missing ${REQUIRED_KEY_ALGO})`);
|
|
329
|
+
return { ok: false, reason: 'partial_scan', keys: partial.lines.length, detail: `missing ${REQUIRED_KEY_ALGO}` };
|
|
330
|
+
}
|
|
331
|
+
return { ok: false, reason: 'scan_failed', detail: lastErr.slice(0, 160) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- 2b. AUTHORITATIVE host keys (task 1187 / idea 235) --------------------
|
|
335
|
+
// The box reports its own PUBLIC host keys to the GDS; GET /box/me serves them
|
|
336
|
+
// (box.host_keys, newline-joined `<keytype> <base64>`). Pinning from THOSE beats
|
|
337
|
+
// ssh-keyscan: no TOFU window, no KEX-negotiation flakiness (#910), no partial
|
|
338
|
+
// scan (#925) — the key is known for certain. main.js prefers this and falls back
|
|
339
|
+
// to refreshKnownHosts (keyscan) only when the box hasn't reported yet.
|
|
340
|
+
|
|
341
|
+
// PURE (unit-tested): build known_hosts lines from the reported keys by prefixing
|
|
342
|
+
// the hostname to each `<keytype> <base64>` pair. Requires an ed25519 line — the
|
|
343
|
+
// algo Desktop verifies (#925). Returns { ok, lines } or { ok:false, reason }.
|
|
344
|
+
function hostKeyLinesFor(hostname, hostKeysText) {
|
|
345
|
+
if (!hostname) return { ok: false, reason: 'no_hostname' };
|
|
346
|
+
const pairs = String(hostKeysText || '')
|
|
347
|
+
.split('\n')
|
|
348
|
+
.map((l) => l.trim())
|
|
349
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
350
|
+
if (pairs.length === 0) return { ok: false, reason: 'no_reported_keys' };
|
|
351
|
+
const lines = pairs.map((p) => `${hostname} ${p}`);
|
|
352
|
+
if (!scanComplete(lines)) return { ok: false, reason: 'no_ed25519' };
|
|
353
|
+
return { ok: true, lines };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// PURE (unit-tested): are the GDS-reported host keys older than the droplet that is
|
|
357
|
+
// running now? A rebuild mints fresh host keys and the row keeps the OLD ones until
|
|
358
|
+
// the new box re-reports (~10 min) — pinning in that window is the "Host denied
|
|
359
|
+
// (verification failed)" lockout. Missing either timestamp is also "can't prove
|
|
360
|
+
// fresh". Callers fall back to a live keyscan rather than pin a dead identity.
|
|
361
|
+
//
|
|
362
|
+
// Mirrors modules/dev-box/boxes.hostKeysAreStale (this app takes ZERO requires into
|
|
363
|
+
// the repo — modules/dev-box/CLAUDE.md); tests/devbox_key_register.mjs pins the two
|
|
364
|
+
// to the same fixtures so they cannot drift apart.
|
|
365
|
+
function reportedKeysAreStale({ hostKeysAt, activeSince } = {}) {
|
|
366
|
+
const toMs = (x) => {
|
|
367
|
+
if (x == null) return null;
|
|
368
|
+
const t = x instanceof Date ? x.getTime() : new Date(x).getTime();
|
|
369
|
+
return Number.isFinite(t) ? t : null;
|
|
370
|
+
};
|
|
371
|
+
const reported = toMs(hostKeysAt);
|
|
372
|
+
const activated = toMs(activeSince);
|
|
373
|
+
if (reported == null || activated == null) return true;
|
|
374
|
+
return reported < activated;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Pin known_hosts from the GDS-reported keys, using the SAME replace-by-host merge
|
|
378
|
+
// as the keyscan path. Same return shape as refreshKnownHosts so main.js treats
|
|
379
|
+
// them interchangeably.
|
|
380
|
+
function refreshKnownHostsFromKeys(hostname, hostKeysText, { log = () => {} } = {}) {
|
|
381
|
+
const built = hostKeyLinesFor(hostname, hostKeysText);
|
|
382
|
+
if (!built.ok) return built;
|
|
383
|
+
writeKnownHosts(hostname, built.lines);
|
|
384
|
+
log(`known_hosts pinned for ${hostname} from GDS-reported keys (${built.lines.length})`);
|
|
385
|
+
return { ok: true, keys: built.lines.length, source: 'api' };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// --- 2c. VERIFY the on-disk pin (self-heal, idea 264) ----------------------
|
|
389
|
+
// The app used to pin once per activation and then trust an in-memory ok flag
|
|
390
|
+
// forever. A box rebuild (fresh host keys) or a hand `ssh-keygen -R` silently
|
|
391
|
+
// invalidates the pinned line, leaving the panel showing "pinned ✓" while Claude
|
|
392
|
+
// Desktop's ssh2 hard-fails with "Host denied (verification failed)". These two
|
|
393
|
+
// helpers let main.js re-check the actual file each poll and re-pin on a miss.
|
|
394
|
+
|
|
395
|
+
// PURE (unit-tested): is the box's CURRENT ed25519 identity pinned in this
|
|
396
|
+
// known_hosts text? ed25519 is the algo Claude Desktop verifies first (#925), so
|
|
397
|
+
// it is the load-bearing line. With `expectedLines` (GDS-authoritative, from
|
|
398
|
+
// hostKeyLinesFor) require the EXACT ed25519 key for `hostname` — that catches a
|
|
399
|
+
// stale key left by a rebuild. Without them, fall back to "an ed25519 line for
|
|
400
|
+
// `hostname` exists at all" — that catches a missing / `ssh-keygen -R`'d pin.
|
|
401
|
+
function knownHostsHasKey(existingText, hostname, expectedLines = []) {
|
|
402
|
+
if (!hostname) return false;
|
|
403
|
+
const pinned = new Set();
|
|
404
|
+
for (const raw of String(existingText || '').split('\n')) {
|
|
405
|
+
const t = raw.trim();
|
|
406
|
+
if (!t || t.startsWith('#')) continue;
|
|
407
|
+
const parts = t.split(/\s+/);
|
|
408
|
+
if (!(parts[0] || '').split(',').includes(hostname)) continue;
|
|
409
|
+
if ((parts[1] || '') !== REQUIRED_KEY_ALGO) continue;
|
|
410
|
+
pinned.add(parts[2] || '');
|
|
411
|
+
}
|
|
412
|
+
if (pinned.size === 0) return false; // no ed25519 pin for this host at all
|
|
413
|
+
const wantEd = (Array.isArray(expectedLines) ? expectedLines : [])
|
|
414
|
+
.map((l) => String(l).trim().split(/\s+/))
|
|
415
|
+
.find((p) => (p[1] || '') === REQUIRED_KEY_ALGO);
|
|
416
|
+
// Authoritative key known → require an exact match (a stale rebuild key fails);
|
|
417
|
+
// otherwise the presence of any ed25519 pin is the strongest check available.
|
|
418
|
+
return wantEd ? pinned.has(wantEd[2] || '') : true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Thin IO wrapper: read the real known_hosts and report whether the box's current
|
|
422
|
+
// identity is pinned. main.js calls this to decide self-heal, instead of trusting
|
|
423
|
+
// a latched flag. Same { ok, reason? } shape as the refresh helpers.
|
|
424
|
+
function verifyKnownHosts(hostname, hostKeysText) {
|
|
425
|
+
if (!hostname) return { ok: false, reason: 'no_hostname' };
|
|
426
|
+
const built = hostKeyLinesFor(hostname, hostKeysText);
|
|
427
|
+
let existing = '';
|
|
428
|
+
try {
|
|
429
|
+
if (fs.existsSync(KNOWN_HOSTS)) existing = fs.readFileSync(KNOWN_HOSTS, 'utf8');
|
|
430
|
+
} catch { /* unreadable → treat as not-pinned; caller re-pins */ }
|
|
431
|
+
const ok = knownHostsHasKey(existing, hostname, built.ok ? built.lines : []);
|
|
432
|
+
return ok ? { ok: true } : { ok: false, reason: 'not_pinned' };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// --- 3. Claude Desktop managed settings ------------------------------------
|
|
436
|
+
|
|
437
|
+
// The PER-USER Claude settings file — what the Desktop app's environment
|
|
438
|
+
// picker actually reads sshConfigs from (same file its "Add SSH host" UI
|
|
439
|
+
// writes; verified against the official docs in #910 after the managed-file
|
|
440
|
+
// route left the picker empty on Windows). Writing here needs NO elevation.
|
|
441
|
+
const CLAUDE_USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
|
|
442
|
+
|
|
443
|
+
// Merge the server-issued sshConfigs into ~/.claude/settings.json, keyed by
|
|
444
|
+
// entry id (replace ours, preserve everything else in the file). Pure-ish
|
|
445
|
+
// core exported for tests via mergeUserSshConfigs.
|
|
446
|
+
function mergeUserSshConfigs(existingJsonText, sshConfigs) {
|
|
447
|
+
let settings = {};
|
|
448
|
+
try {
|
|
449
|
+
const parsed = JSON.parse(existingJsonText);
|
|
450
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) settings = parsed;
|
|
451
|
+
} catch { /* corrupt/missing → start clean but NEVER throw */ }
|
|
452
|
+
const ours = new Set(sshConfigs.map((c) => c.id));
|
|
453
|
+
const kept = Array.isArray(settings.sshConfigs)
|
|
454
|
+
? settings.sshConfigs.filter((c) => !c || !ours.has(c.id))
|
|
455
|
+
: [];
|
|
456
|
+
return { ...settings, sshConfigs: [...kept, ...sshConfigs] };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function installUserSshConfig(serverSettings, { log = () => {} } = {}) {
|
|
460
|
+
const sshConfigs = serverSettings && Array.isArray(serverSettings.sshConfigs)
|
|
461
|
+
? serverSettings.sshConfigs : [];
|
|
462
|
+
if (sshConfigs.length === 0) return { ok: false, reason: 'no_ssh_configs' };
|
|
463
|
+
const existing = fs.existsSync(CLAUDE_USER_SETTINGS)
|
|
464
|
+
? fs.readFileSync(CLAUDE_USER_SETTINGS, 'utf8') : '';
|
|
465
|
+
const next = mergeUserSshConfigs(existing, sshConfigs);
|
|
466
|
+
const nextText = `${JSON.stringify(next, null, 2)}\n`;
|
|
467
|
+
if (existing.trim() === nextText.trim()) {
|
|
468
|
+
return { ok: true, unchanged: true };
|
|
469
|
+
}
|
|
470
|
+
fs.mkdirSync(path.dirname(CLAUDE_USER_SETTINGS), { recursive: true });
|
|
471
|
+
fs.writeFileSync(CLAUDE_USER_SETTINGS, nextText);
|
|
472
|
+
log(`user settings: sshConfigs written to ${CLAUDE_USER_SETTINGS} (${sshConfigs.map((c) => c.name).join(', ')})`);
|
|
473
|
+
return { ok: true };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// System-level managed settings — now only the OPTIONAL hardening layer
|
|
477
|
+
// (sshHostAllowlist); the picker entry itself comes from the user settings
|
|
478
|
+
// above. The docs name Program Files on Windows while older Desktop builds
|
|
479
|
+
// referenced ProgramData — installManagedSettings writes BOTH in the one
|
|
480
|
+
// elevation, and settingsUpToDate checks the documented one.
|
|
481
|
+
function managedSettingsPath() {
|
|
482
|
+
if (IS_MAC) return '/Library/Application Support/ClaudeCode/managed-settings.json';
|
|
483
|
+
if (IS_WIN) return 'C:\\Program Files\\ClaudeCode\\managed-settings.json';
|
|
484
|
+
return null; // Linux is not a managed-settings target
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function managedSettingsPathsAll() {
|
|
488
|
+
if (IS_MAC) return [managedSettingsPath()];
|
|
489
|
+
if (IS_WIN) return [managedSettingsPath(), 'C:\\ProgramData\\ClaudeCode\\managed-settings.json'];
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function settingsUpToDate(targetPath, content) {
|
|
494
|
+
try {
|
|
495
|
+
const current = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
496
|
+
return JSON.stringify(current) === JSON.stringify(JSON.parse(content));
|
|
497
|
+
} catch { return false; }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Write `content` to the system path via a native elevation prompt.
|
|
501
|
+
// Returns { ok } | { ok: false, reason: 'cancelled' | 'unsupported' | 'failed' }.
|
|
502
|
+
async function installManagedSettings(content, { log = () => {} } = {}) {
|
|
503
|
+
// Dev/test hook (README): never pop an elevation dialog in stub runs / CI.
|
|
504
|
+
if (process.env.OTB_DEVBOX_NO_ELEVATE === '1') return { ok: false, reason: 'skipped' };
|
|
505
|
+
const target = managedSettingsPath();
|
|
506
|
+
if (!target) return { ok: false, reason: 'unsupported' };
|
|
507
|
+
if (settingsUpToDate(target, content)) {
|
|
508
|
+
log('managed settings already current — no elevation needed');
|
|
509
|
+
return { ok: true, unchanged: true };
|
|
510
|
+
}
|
|
511
|
+
const tmp = path.join(os.tmpdir(), `otb-managed-settings-${process.pid}.json`);
|
|
512
|
+
fs.writeFileSync(tmp, content, { mode: 0o644 });
|
|
513
|
+
try {
|
|
514
|
+
if (IS_MAC) {
|
|
515
|
+
const dir = path.dirname(target);
|
|
516
|
+
// Single quoted shell line inside osascript; paths are fixed constants
|
|
517
|
+
// (no user input). The OS shows its own admin-password dialog.
|
|
518
|
+
const shell = `mkdir -p '${dir}' && cp '${tmp}' '${target}' && chmod 644 '${target}'`;
|
|
519
|
+
const script = `do shell script "${shell.replace(/[\\"]/g, '\\$&')}" with administrator privileges with prompt "OTB Dev Box installs the Claude Desktop connection file."`;
|
|
520
|
+
await run('osascript', ['-e', script], { timeout: 120_000 });
|
|
521
|
+
} else {
|
|
522
|
+
// UAC prompt via Start-Process -Verb RunAs; -Wait so we can re-check.
|
|
523
|
+
// One elevation writes EVERY candidate path (Program Files per current
|
|
524
|
+
// docs + ProgramData for older Desktop builds).
|
|
525
|
+
const inner = managedSettingsPathsAll()
|
|
526
|
+
.map((t) => `New-Item -ItemType Directory -Force -Path '${path.dirname(t)}' | Out-Null; Copy-Item -Force '${tmp}' '${t}'`)
|
|
527
|
+
.join('; ');
|
|
528
|
+
const outer = `Start-Process -FilePath powershell -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList '-NoProfile','-NonInteractive','-Command',"${inner.replace(/"/g, '\\"')}"`;
|
|
529
|
+
await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', outer], { timeout: 120_000 });
|
|
530
|
+
}
|
|
531
|
+
} catch (e) {
|
|
532
|
+
const msg = String((e && (e.stderr || e.message)) || '');
|
|
533
|
+
const cancelled = /canceled|cancelled|-128|was cancelled by the user|Operation did not complete/i.test(msg);
|
|
534
|
+
log(`managed-settings install ${cancelled ? 'cancelled' : 'failed'}: ${msg.slice(0, 200)}`);
|
|
535
|
+
return { ok: false, reason: cancelled ? 'cancelled' : 'failed' };
|
|
536
|
+
} finally {
|
|
537
|
+
try { fs.unlinkSync(tmp); } catch { /* gone is fine */ }
|
|
538
|
+
}
|
|
539
|
+
const ok = settingsUpToDate(target, content);
|
|
540
|
+
log(`managed settings install ${ok ? 'verified' : 'NOT verified'} at ${target}`);
|
|
541
|
+
return ok ? { ok: true } : { ok: false, reason: 'failed' };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
module.exports = {
|
|
545
|
+
ensureKey,
|
|
546
|
+
registerKeyWithVerify,
|
|
547
|
+
sshFingerprintSha256,
|
|
548
|
+
publicKeyFingerprint,
|
|
549
|
+
knownHostsLineMatchesHost,
|
|
550
|
+
refreshKnownHosts,
|
|
551
|
+
refreshKnownHostsFromKeys,
|
|
552
|
+
hostKeyLinesFor,
|
|
553
|
+
reportedKeysAreStale,
|
|
554
|
+
knownHostsHasKey,
|
|
555
|
+
verifyKnownHosts,
|
|
556
|
+
resolveSshTool,
|
|
557
|
+
keyscanCandidates,
|
|
558
|
+
scanWithTool,
|
|
559
|
+
scanComplete,
|
|
560
|
+
installUserSshConfig,
|
|
561
|
+
mergeUserSshConfigs,
|
|
562
|
+
CLAUDE_USER_SETTINGS,
|
|
563
|
+
installManagedSettings,
|
|
564
|
+
managedSettingsPath,
|
|
565
|
+
settingsUpToDate,
|
|
566
|
+
mergeKnownHosts,
|
|
567
|
+
KEY_PATH,
|
|
568
|
+
KNOWN_HOSTS,
|
|
569
|
+
};
|