From a3d07e64bd67f7e595516c687c0cc9f035d7b3e5 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 16:22:30 -0700 Subject: [PATCH 01/21] Rebase machine providers onto the environment consolidation Squash of bb/machine-provider-apis on top of 12e6e97b6a. Machine creation is now the environment's first provisioning step, the machine migration is 0117 on top of the consolidation's 0116, and machine-phase progress and failures are reported into the thread transcript. Co-Authored-By: Claude Fable 5.1 --- apps/app/.ladle/machine-story-fixtures.ts | 131 + apps/app/.ladle/settings-story-fixtures.tsx | 10 + apps/app/.ladle/story-fixtures.ts | 18 + .../dialogs/AddMachineDialog.expiry.test.tsx | 58 + .../dialogs/AddMachineDialog.stories.tsx | 214 + .../dialogs/AddMachineDialog.test.tsx | 373 +- .../components/dialogs/AddMachineDialog.tsx | 665 ++- .../dialogs/ConfirmDeleteDialog.tsx | 4 +- .../components/dialogs/ProjectPathDialog.tsx | 6 +- .../machines/MachineLabel.stories.tsx | 34 + .../components/machines/MachineLabel.test.tsx | 42 + .../src/components/machines/MachineLabel.tsx | 74 + .../machines/MachineLifecycleActions.tsx | 77 + .../machines/MachineLifecycleNotice.tsx | 28 + .../machines/MachineRemoveDialog.tsx | 83 + .../MachineServerAccessNotice.stories.tsx | 34 + .../machines/MachineServerAccessNotice.tsx | 71 + .../components/machines/MachineStatusDot.tsx | 12 +- .../machines/machine-server-access.test.ts | 64 + .../machines/machine-server-access.ts | 27 + .../src/components/machines/machine-status.ts | 48 + .../pickers/EnvironmentPicker.test.tsx | 161 +- .../components/pickers/EnvironmentPicker.tsx | 105 +- .../components/pickers/MachinePicker.test.tsx | 30 + .../src/components/pickers/MachinePicker.tsx | 54 +- .../pickers/ReuseEnvironmentPicker.test.ts | 1 + .../pickers/machine-provider-inputs.tsx | 137 + .../plugin/EnvironmentProviderIcon.test.tsx | 1 + .../components/plugin/MachineProviderIcon.tsx | 30 + .../plugin/PluginNewThreadComposer.test.tsx | 93 +- .../components/plugin/PluginSettings.test.tsx | 34 + .../src/components/plugin/PluginSettings.tsx | 11 +- .../components/plugin/PluginThreadChat.tsx | 20 +- .../new-thread-environment-seed.test.ts | 19 + .../plugin/new-thread-environment-seed.ts | 7 +- .../usePluginBranchPickerState.test.tsx | 31 + .../plugin/usePluginBranchPickerState.ts | 16 +- .../promptbox/NewThreadComposer.tsx | 357 +- .../promptbox/NewThreadPromptBox.test.tsx | 73 +- .../promptbox/NewThreadPromptBox.tsx | 51 +- .../ThreadEnvironmentSummary.test.tsx | 43 + .../promptbox/ThreadEnvironmentSummary.tsx | 19 +- .../banner/ProviderCliVersionBanner.tsx | 42 +- .../ProviderRequirementBanner.stories.tsx | 192 + .../banner/ProviderRequirementBanner.tsx | 41 + .../banner/QueuedMessagesList.test.tsx | 2 +- .../banner/ThreadMachineStatus.stories.tsx | 122 + .../banner/ThreadMachineStatus.test.tsx | 41 + .../promptbox/banner/ThreadMachineStatus.tsx | 157 + .../banner/ThreadPromptContextBanner.tsx | 19 +- .../banner/prompt-banner-actions.tsx | 22 +- .../ThreadMetadataContent.rows.stories.tsx | 6 +- .../ThreadMetadataContent.test.tsx | 72 +- .../secondary-panel/ThreadMetadataContent.tsx | 29 +- .../MachineAccessSettings.stories.tsx | 161 + .../settings/MachineAccessSettings.tsx | 320 ++ .../MachineEnvironmentSettings.stories.tsx | 120 + .../MachineEnvironmentSettings.test.tsx | 98 + .../settings/MachineEnvironmentSettings.tsx | 348 ++ .../MachinesSettingsSection.stories.tsx | 177 + .../settings/MachinesSettingsSection.test.tsx | 103 +- .../settings/MachinesSettingsSection.tsx | 446 +- .../settings/ProjectsSettingsSection.tsx | 4 +- .../cache-owners/cache-owner-registry.test.ts | 1 + .../cache-owners/system-cache-effects.ts | 9 + .../app/src/hooks/mutations/host-mutations.ts | 30 + .../environment-provider-queries.test.tsx | 1 + .../src/hooks/queries/host-queries.test.ts | 24 +- apps/app/src/hooks/queries/host-queries.ts | 24 +- .../hooks/queries/machine-provider-queries.ts | 20 + apps/app/src/hooks/queries/query-keys.ts | 31 +- apps/app/src/hooks/useLocalPathPicker.tsx | 4 +- .../src/hooks/useQuickCreateProject.test.tsx | 18 +- apps/app/src/hooks/useQuickCreateProject.tsx | 7 +- .../hooks/useThreadCreationOptions.test.tsx | 1 - apps/app/src/hooks/useUpdateInventory.test.ts | 24 +- apps/app/src/hooks/useUpdateInventory.ts | 12 +- .../lib/environment-workspace-display.test.ts | 3 + apps/app/src/lib/plugin-slots.ts | 8 + apps/app/src/lib/queued-message-wait.test.ts | 2 +- apps/app/src/lib/queued-message-wait.ts | 2 +- apps/app/src/lib/system-config-atoms.ts | 6 + apps/app/src/test/fixtures/system-config.ts | 6 + .../src/views/MachineSettingsView.stories.tsx | 193 + .../src/views/MachineSettingsView.test.tsx | 39 +- apps/app/src/views/MachineSettingsView.tsx | 239 +- .../src/views/ProjectDetailSettingsView.tsx | 4 +- .../views/RootComposeMobileRecents.test.tsx | 1 + apps/app/src/views/RootComposeView.test.ts | 15 + apps/app/src/views/RootComposeView.tsx | 28 +- apps/app/src/views/SettingsView.tsx | 23 +- .../root-compose-environment-selection.ts | 5 +- .../root-compose-thread-environment.test.ts | 23 + .../views/root-compose-thread-environment.ts | 4 +- .../ThreadDetailPromptArea.test.tsx | 76 +- .../thread-detail/ThreadDetailPromptArea.tsx | 17 + .../views/thread-detail/ThreadDetailView.tsx | 17 +- .../machine-environment.test.ts | 72 + .../__tests__/command-output/machine.test.ts | 187 +- .../__tests__/command-output/project.test.ts | 8 - .../__tests__/command-output/provider.test.ts | 1 - .../__tests__/command-output/settings.test.ts | 22 +- .../__tests__/command-output/terminal.test.ts | 1 - .../command-output/thread-spawn.test.ts | 3 - .../__tests__/command-output/updates.test.ts | 20 +- .../src/commands/machine-enrollment.test.ts | 178 + apps/cli/src/commands/machine-enrollment.ts | 249 + apps/cli/src/commands/machine-environment.ts | 104 + apps/cli/src/commands/machine.ts | 235 +- apps/cli/src/commands/settings.ts | 2 +- apps/cli/src/commands/thread/spawn.ts | 134 +- apps/demo-server/src/demo-world.ts | 13 + apps/demo-server/src/fixtures/world.ts | 10 +- apps/host-daemon/src/app.test.ts | 48 +- apps/host-daemon/src/app.ts | 26 +- apps/host-daemon/src/auth-state.test.ts | 4 - apps/host-daemon/src/auth-state.ts | 1 - apps/host-daemon/src/command-dispatch.test.ts | 9 +- apps/host-daemon/src/command-dispatch.ts | 16 + .../src/command-handlers/environment-hook.ts | 38 +- .../src/command-handlers/project.test.ts | 10 +- .../src/command-handlers/project.ts | 27 +- apps/host-daemon/src/enroll.test.ts | 8 + apps/host-daemon/src/enroll.ts | 17 +- .../src/environment-lifecycle-script.ts | 40 +- apps/host-daemon/src/index.ts | 9 +- .../src/machine-auth-proxy.test.ts | 18 +- apps/host-daemon/src/machine-auth-proxy.ts | 19 +- .../src/operation-environment.test.ts | 32 + apps/host-daemon/src/operation-environment.ts | 21 + .../src/plugin-host-manager.test.ts | 164 + apps/host-daemon/src/plugin-host-manager.ts | 11 +- apps/host-daemon/src/plugin-host-worker.ts | 47 + apps/host-daemon/src/server-client.test.ts | 15 +- apps/host-daemon/src/server-client.ts | 18 +- .../src/server-connection-support.ts | 7 +- .../host-daemon/src/server-connection.test.ts | 57 +- apps/host-daemon/src/server-connection.ts | 20 +- apps/host-daemon/src/start-host-daemon.ts | 30 +- apps/host-daemon/src/suspension-marker.ts | 23 + .../src/terminals/terminal-manager.test.ts | 38 + .../src/terminals/terminal-manager.ts | 12 +- .../test/command/environment-hook.test.ts | 123 + .../command/project-clone-private-env.test.ts | 59 + apps/server/src/assets/install-machine.sh | 415 +- apps/server/src/install-machine-asset.ts | 5 + apps/server/src/internal/auth.ts | 8 +- apps/server/src/internal/hosts.ts | 16 +- .../internal/session-owner-side-effects.ts | 2 +- apps/server/src/internal/session.ts | 23 +- apps/server/src/routes/environments.ts | 16 +- apps/server/src/routes/files.ts | 10 +- apps/server/src/routes/hosts.ts | 114 +- apps/server/src/routes/plugins.ts | 1 + apps/server/src/routes/projects.ts | 99 +- apps/server/src/routes/system.ts | 116 +- apps/server/src/server.ts | 62 +- apps/server/src/services/desktop-browsers.ts | 56 +- .../environments/environment-engine.ts | 37 +- .../environments/environment-hooks.ts | 98 +- .../environments/provider-availability.ts | 2 +- .../services/environments/workspace-status.ts | 30 +- .../src/services/hosts/host-enrollment.ts | 7 +- .../services/hosts/host-environment.test.ts | 103 + .../src/services/hosts/host-environment.ts | 60 + .../src/services/hosts/host-lifecycle.ts | 25 + .../src/services/hosts/live-command-wait.ts | 4 +- .../server/src/services/hosts/live-command.ts | 8 +- apps/server/src/services/hosts/online-rpc.ts | 122 +- .../src/services/hosts/primary-host.test.ts | 11 + .../server/src/services/hosts/primary-host.ts | 9 +- apps/server/src/services/lib/entity-lookup.ts | 61 +- .../src/services/lib/lifecycle-api-errors.ts | 25 +- apps/server/src/services/machine-auth.ts | 172 +- .../src/services/machines/bootstrap.test.ts | 151 + .../server/src/services/machines/bootstrap.ts | 127 + .../src/services/machines/enrollments.test.ts | 220 + .../src/services/machines/enrollments.ts | 277 ++ .../services/machines/environment-settings.ts | 65 + .../machines/environment-storage.test.ts | 125 + .../services/machines/environment-storage.ts | 187 + .../services/machines/git-credentials.test.ts | 188 + .../src/services/machines/git-credentials.ts | 92 + .../server/src/services/machines/lifecycle.ts | 250 + .../src/services/machines/machine-services.ts | 29 + .../manual-enrollment-command.test.ts | 33 + .../machines/manual-enrollment-command.ts | 20 + .../src/services/machines/manual-provider.ts | 100 + .../machines/provider-availability.ts | 85 + .../machines/provider-orchestration.ts | 1448 ++++++ .../src/services/machines/server-access.ts | 233 + .../src/services/plugins/builtin-registry.ts | 5 + .../plugins/plugin-agent-contributions.ts | 11 +- .../server/src/services/plugins/plugin-api.ts | 142 +- .../plugin-environment-provider-registry.ts | 10 + .../src/services/plugins/plugin-host-rpc.ts | 5 + .../plugin-machine-provider-registry.ts | 52 + .../src/services/plugins/plugin-runtime.ts | 78 +- .../plugins/plugin-server-access-registry.ts | 43 + .../plugins/plugin-service-internal.ts | 6 + .../src/services/plugins/plugin-service.ts | 46 + .../src/services/plugins/plugin-settings.ts | 2 +- .../services/plugins/plugin-thread-events.ts | 20 + .../services/projects/project-source-setup.ts | 229 + .../services/skills/global-skill-install.ts | 7 +- .../src/services/skills/skill-listing.ts | 8 +- .../src/services/system/execution-options.ts | 35 +- .../src/services/system/periodic-sweeps.ts | 8 + .../services/system/provider-installations.ts | 30 +- .../src/services/system/provider-states.ts | 26 +- .../terminals/terminal-session-lifecycle.ts | 14 + .../src/services/threads/dispatch-attempt.ts | 33 +- .../threads/queued-message-dispatch.ts | 44 + .../src/services/threads/queued-messages.ts | 2 +- .../src/services/threads/thread-archive.ts | 12 + .../src/services/threads/thread-create.ts | 18 +- .../services/threads/thread-default-policy.ts | 5 +- .../threads/thread-environment-placement.ts | 423 +- .../threads/thread-environment-providers.ts | 4 + .../src/services/threads/thread-lifecycle.ts | 4 + .../threads/thread-metadata-inference.ts | 4 +- .../services/threads/thread-runtime-config.ts | 28 +- .../src/services/threads/thread-send.ts | 11 +- .../src/services/threads/thread-storage.ts | 18 + apps/server/src/ws/daemon-protocol.ts | 3 + apps/server/src/ws/hub.ts | 55 + .../server/test/app/host-shared-ports.test.ts | 11 - .../test/app/install-machine-script.test.ts | 501 +- apps/server/test/app/skeleton.test.ts | 1 - apps/server/test/app/watch-interests.test.ts | 1 - .../workspace-read-cache-routes.test.ts | 23 + apps/server/test/helpers/commands.ts | 14 +- apps/server/test/helpers/seed.ts | 2 - apps/server/test/helpers/test-app.ts | 18 +- apps/server/test/host-join-enroll.test.ts | 6 - apps/server/test/hosts/online-rpc.test.ts | 50 +- .../background-task-reconciliation.test.ts | 12 - .../internal-session-protocol-version.test.ts | 102 +- apps/server/test/lifecycle-policy.test.ts | 277 ++ apps/server/test/machine-auth.test.ts | 7 - .../test/provider-corpus/corpus-harness.ts | 1 - .../test/provider-corpus/synthetic-thread.ts | 1 - .../test/public/machine-environment.test.ts | 129 + .../public/public-host-management.test.ts | 65 +- .../public-project-clone-sources.test.ts | 73 +- .../public/public-project-commands.test.ts | 1 + .../public-provider-installations.test.ts | 74 +- .../test/public/public-terminals.test.ts | 70 + .../public-thread-offline-followup.test.ts | 2 +- ...blic-thread-queue-gone-environment.test.ts | 4 +- .../database-maintenance-sweep.test.ts | 1 - .../test/services/entity-lookup.test.ts | 29 +- .../services/machines/manual-provider.test.ts | 104 + .../machines/provider-orchestration.test.ts | 535 +++ .../machines/runtime-enrollments.test.ts | 305 ++ .../services/machines/server-access.test.ts | 269 ++ .../services/plugins/builtin-plugins.test.ts | 1 + .../test/services/plugins/keep-awake.test.ts | 2 + .../plugin-agent-contributions.test.ts | 7 - .../plugins/plugin-authoring-docs.test.ts | 30 +- .../plugins/plugin-dev-build-problems.test.ts | 1 + .../services/plugins/plugin-service.test.ts | 1 - .../plugins/plugin-thread-events.test.ts | 46 + .../projects/project-source-setup.test.ts | 232 + .../test/services/prompt-history.test.ts | 1 - .../conversation-outline-performance.test.ts | 1 - .../threads/lifecycle-outcome.test.ts | 2 - .../threads/thread-runtime-display.test.ts | 2 - .../threads/timeline-context-clear.test.ts | 1 - .../threads/timeline-event-budget.test.ts | 1 - .../threads/timeline-head-state.test.ts | 1 - .../threads/timeline-in-turn-window.test.ts | 1 - .../timeline-parented-pagination.test.ts | 1 - .../threads/timeline-provider-input.test.ts | 1 - .../timeline-workflow-progress-window.test.ts | 1 - .../test/system/execution-options.test.ts | 36 +- .../test/system/provider-states.test.ts | 40 +- .../threads/environment-providers.test.ts | 129 +- .../threads/thread-create-helpers.test.ts | 1 - apps/server/test/threads/thread-data.test.ts | 1 - .../server/test/threads/thread-parent.test.ts | 1 - .../threads/thread-provisioning-state.test.ts | 1 - .../test/threads/turn-failed-retry.test.ts | 2 + .../src/routes/api.connect.machine-code.tsx | 11 + apps/web/src/server/api.test.ts | 31 + apps/web/src/server/api.ts | 52 +- docs/api_to_audit.md | 311 +- docs/cli-guide-and-skill.md | 17 + docs/configuration.md | 140 +- docs/multiple-devices.md | 6 +- docs/worktrees.md | 18 + .../src/integration.env-isolation.test.ts | 57 + .../src/runtime.lifecycle.test.ts | 83 +- .../src/test/runtime-test-harness.ts | 3 + .../src/thread-shell-environment.ts | 12 +- packages/agent-runtime/src/types.ts | 3 +- packages/bb-app/src/launcher.ts | 59 +- packages/bb-app/test/index.test.ts | 33 +- packages/config/src/bb-app-managed-config.ts | 5 + packages/config/src/env-vars.ts | 37 +- packages/config/src/host-daemon-entrypoint.ts | 42 +- packages/config/test/config.test.ts | 13 - .../db/drizzle/0117_machine_providers.sql | 36 + packages/db/drizzle/meta/0117_snapshot.json | 4256 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/data/environments.ts | 28 + packages/db/src/data/hosts.ts | 98 +- packages/db/src/data/index.ts | 4 + packages/db/src/data/machines.ts | 71 + packages/db/src/data/project-sources.ts | 9 + packages/db/src/data/sessions.ts | 3 - packages/db/src/migrate.ts | 14 + packages/db/src/schema.ts | 56 +- .../completed-event-output-migration.test.ts | 1 - .../test/data/environment-lifecycle.test.ts | 2 +- packages/db/test/data/environments.test.ts | 61 +- packages/db/test/data/events.test.ts | 4 +- packages/db/test/data/hosts.test.ts | 33 +- packages/db/test/data/machines.test.ts | 58 + packages/db/test/data/maintenance.test.ts | 1 - .../db/test/data/pending-interactions.test.ts | 1 - .../data/project-execution-defaults.test.ts | 1 - packages/db/test/data/project-sources.test.ts | 14 - packages/db/test/data/projects.test.ts | 2 - .../db/test/data/queued-message-waits.test.ts | 1 - .../test/data/queued-thread-messages.test.ts | 1 - .../test/data/retained-event-outputs.test.ts | 1 - packages/db/test/data/sessions.test.ts | 15 - packages/db/test/data/sweeps.test.ts | 4 - .../db/test/data/terminal-sessions.test.ts | 2 - packages/db/test/data/thread-count.test.ts | 2 - .../db/test/data/thread-lifecycle.test.ts | 1 - packages/db/test/data/thread-running.test.ts | 4 +- packages/db/test/data/thread-search.test.ts | 1 - packages/db/test/data/threads.test.ts | 5 +- .../db/test/machine-provider-upgrade.test.ts | 82 + .../db/test/manual-machine-migration.test.ts | 68 + packages/db/test/migrate.test.ts | 263 +- packages/db/test/query-plans.test.ts | 2 - packages/db/test/schema.test.ts | 3 - packages/domain/src/app-settings.ts | 17 + packages/domain/src/environment.ts | 12 +- packages/domain/src/host.ts | 29 +- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/domain/src/provider-event.ts | 3 + packages/domain/src/queued-message.ts | 2 +- packages/domain/test/environment.test.ts | 29 +- packages/domain/test/host.test.ts | 9 + packages/host-daemon-contract/src/commands.ts | 14 +- .../host-daemon-contract/src/local-state.ts | 23 +- packages/host-daemon-contract/src/protocol.ts | 2 +- packages/host-daemon-contract/src/session.ts | 45 +- .../test/contract.test.ts | 16 +- .../host-daemon-contract/test/local.test.ts | 15 + .../test/workspace-root-ignores.test.ts | 4 +- packages/host-workspace/src/git.ts | 27 +- packages/plugin-api-map/src/surfaces.ts | 106 +- packages/plugin-api-map/test/surfaces.test.ts | 41 + .../src/builtin-server-artifacts.test.ts | 15 + packages/plugin-sdk/README.md | 12 - packages/plugin-sdk/package.json | 8 +- .../plugin-sdk/scripts/build-bundled-dts.mjs | 4 + packages/plugin-sdk/scripts/build-runtime.mjs | 5 + .../src/__tests__/package-exports.test.ts | 1 + .../src/__tests__/public-types.test.ts | 9 + packages/plugin-sdk/src/app-contract.ts | 43 +- packages/plugin-sdk/src/backend-contract.ts | 100 +- .../plugin-sdk/src/environment-provider.ts | 4 +- packages/plugin-sdk/src/index.ts | 2 + .../plugin-sdk/src/internal/host-policy.ts | 159 +- .../src/internal/plugin-app-collector.ts | 16 + packages/plugin-sdk/src/machine-bootstrap.ts | 28 + packages/plugin-sdk/src/machine-provider.ts | 105 + .../__tests__/fake-plugin-host.test.ts | 134 +- packages/plugin-sdk/src/testing/app.tsx | 2 + .../src/testing/fake-plugin-host.ts | 101 +- packages/plugin-sdk/src/testing/fixtures.ts | 20 +- packages/scripts/test/run-host-daemon.test.ts | 4 - packages/sdk/src/areas/hosts.ts | 120 +- packages/sdk/src/areas/system.ts | 18 + packages/sdk/src/areas/threads.ts | 3 +- packages/sdk/test/public-types.test.ts | 8 + packages/sdk/test/sdk.test.ts | 123 + packages/server-contract/src/api/hosts.ts | 31 +- .../src/api/machine-environment.ts | 41 + packages/server-contract/src/api/shared.ts | 11 +- packages/server-contract/src/api/system.ts | 69 + packages/server-contract/src/index.ts | 2 + packages/server-contract/src/public-api.ts | 70 +- .../server-contract/test/contract.test.ts | 68 + .../src/templates/bb-guide-machines.md | 254 +- .../src/templates/bb-guide-plugins.md | 13 + packages/test-helpers/src/domain-fixtures.ts | 10 +- .../src/parse-operation-message.ts | 7 +- plugins/automations/src/automations.test.ts | 1 - plugins/bb-guide/skills/bb-cli/SKILL.md | 28 +- .../skills/bb-cli/references/app-settings.md | 15 + .../skills/bb-cli/references/command-index.md | 21 + .../skills/bb-cli/references/configuration.md | 30 + .../bb-cli/references/thread-creation.md | 55 +- .../skills/bb-plugin-authoring/SKILL.md | 4 +- .../references/backend-api-index.md | 28 + .../references/backend-events.md | 15 +- .../references/backend-foundation.md | 2 +- .../references/backend-machines.md | 191 + .../references/backend-sdk.md | 18 + .../references/frontend-api-index.md | 4 + .../references/frontend-components.md | 15 + .../references/frontend-renderer-slots.md | 1 + .../references/providers.md | 9 +- plugins/bb-official.json | 4 + plugins/connect/README.md | 20 + plugins/connect/src/machine-code.ts | 22 + plugins/connect/src/redeem.ts | 26 + plugins/connect/src/server-access.test.ts | 343 ++ plugins/connect/src/server-access.ts | 166 + plugins/connect/src/server.ts | 13 +- plugins/environment-git-worktree/app.test.tsx | 8 +- plugins/environment-git-worktree/app.tsx | 3 +- plugins/environment-modal-sandbox/Dockerfile | 29 + plugins/environment-modal-sandbox/README.md | 168 + plugins/environment-modal-sandbox/account.ts | 324 ++ plugins/environment-modal-sandbox/app.tsx | 625 +++ .../configuration.test.ts | 33 + .../configuration.ts | 86 + .../debug-sandbox.ts | 139 + .../image-definition.ts | 29 + .../launch-options.ts | 124 + .../lifecycle.test.ts | 39 + .../environment-modal-sandbox/lifecycle.ts | 18 + .../environment-modal-sandbox/modal-logo.svg | 10 + .../environment-modal-sandbox/package.json | 45 + .../environment-modal-sandbox/provider-id.ts | 1 + .../sandbox-backend.test.ts | 219 + .../sandbox-backend.ts | 250 + .../scripts/stage-assets.mjs | 5 + .../environment-modal-sandbox/server.test.ts | 1178 +++++ plugins/environment-modal-sandbox/server.ts | 624 +++ .../skills/modal-sandboxes/SKILL.md | 124 + .../standard-image.test.ts | 171 + .../standard-image.ts | 110 + .../environment-modal-sandbox/tsconfig.json | 37 + .../vitest.config.ts | 16 + .../environment-project-checkout/app.test.tsx | 49 +- plugins/environment-project-checkout/app.tsx | 57 +- .../server.test.ts | 45 + .../environment-project-checkout/server.ts | 4 +- pnpm-lock.yaml | 210 +- tests/integration/helpers/harness.ts | 2 - .../src/provider-bridge.ts | 31 + turbo.json | 4 + 451 files changed, 30273 insertions(+), 2770 deletions(-) create mode 100644 apps/app/.ladle/machine-story-fixtures.ts create mode 100644 apps/app/src/components/dialogs/AddMachineDialog.expiry.test.tsx create mode 100644 apps/app/src/components/dialogs/AddMachineDialog.stories.tsx create mode 100644 apps/app/src/components/machines/MachineLabel.stories.tsx create mode 100644 apps/app/src/components/machines/MachineLabel.test.tsx create mode 100644 apps/app/src/components/machines/MachineLabel.tsx create mode 100644 apps/app/src/components/machines/MachineLifecycleActions.tsx create mode 100644 apps/app/src/components/machines/MachineLifecycleNotice.tsx create mode 100644 apps/app/src/components/machines/MachineRemoveDialog.tsx create mode 100644 apps/app/src/components/machines/MachineServerAccessNotice.stories.tsx create mode 100644 apps/app/src/components/machines/MachineServerAccessNotice.tsx create mode 100644 apps/app/src/components/machines/machine-server-access.test.ts create mode 100644 apps/app/src/components/machines/machine-server-access.ts create mode 100644 apps/app/src/components/machines/machine-status.ts create mode 100644 apps/app/src/components/pickers/machine-provider-inputs.tsx create mode 100644 apps/app/src/components/plugin/MachineProviderIcon.tsx create mode 100644 apps/app/src/components/promptbox/banner/ProviderRequirementBanner.stories.tsx create mode 100644 apps/app/src/components/promptbox/banner/ProviderRequirementBanner.tsx create mode 100644 apps/app/src/components/promptbox/banner/ThreadMachineStatus.stories.tsx create mode 100644 apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx create mode 100644 apps/app/src/components/promptbox/banner/ThreadMachineStatus.tsx create mode 100644 apps/app/src/components/settings/MachineAccessSettings.stories.tsx create mode 100644 apps/app/src/components/settings/MachineAccessSettings.tsx create mode 100644 apps/app/src/components/settings/MachineEnvironmentSettings.stories.tsx create mode 100644 apps/app/src/components/settings/MachineEnvironmentSettings.test.tsx create mode 100644 apps/app/src/components/settings/MachineEnvironmentSettings.tsx create mode 100644 apps/app/src/components/settings/MachinesSettingsSection.stories.tsx create mode 100644 apps/app/src/hooks/queries/machine-provider-queries.ts create mode 100644 apps/app/src/views/MachineSettingsView.stories.tsx create mode 100644 apps/cli/src/__tests__/command-output/machine-environment.test.ts create mode 100644 apps/cli/src/commands/machine-enrollment.test.ts create mode 100644 apps/cli/src/commands/machine-enrollment.ts create mode 100644 apps/cli/src/commands/machine-environment.ts create mode 100644 apps/host-daemon/src/enroll.test.ts create mode 100644 apps/host-daemon/src/operation-environment.test.ts create mode 100644 apps/host-daemon/src/operation-environment.ts create mode 100644 apps/host-daemon/src/suspension-marker.ts create mode 100644 apps/host-daemon/test/command/project-clone-private-env.test.ts create mode 100644 apps/server/src/install-machine-asset.ts create mode 100644 apps/server/src/services/hosts/host-environment.test.ts create mode 100644 apps/server/src/services/hosts/host-environment.ts create mode 100644 apps/server/src/services/machines/bootstrap.test.ts create mode 100644 apps/server/src/services/machines/bootstrap.ts create mode 100644 apps/server/src/services/machines/enrollments.test.ts create mode 100644 apps/server/src/services/machines/enrollments.ts create mode 100644 apps/server/src/services/machines/environment-settings.ts create mode 100644 apps/server/src/services/machines/environment-storage.test.ts create mode 100644 apps/server/src/services/machines/environment-storage.ts create mode 100644 apps/server/src/services/machines/git-credentials.test.ts create mode 100644 apps/server/src/services/machines/git-credentials.ts create mode 100644 apps/server/src/services/machines/lifecycle.ts create mode 100644 apps/server/src/services/machines/machine-services.ts create mode 100644 apps/server/src/services/machines/manual-enrollment-command.test.ts create mode 100644 apps/server/src/services/machines/manual-enrollment-command.ts create mode 100644 apps/server/src/services/machines/manual-provider.ts create mode 100644 apps/server/src/services/machines/provider-availability.ts create mode 100644 apps/server/src/services/machines/provider-orchestration.ts create mode 100644 apps/server/src/services/machines/server-access.ts create mode 100644 apps/server/src/services/plugins/plugin-machine-provider-registry.ts create mode 100644 apps/server/src/services/plugins/plugin-server-access-registry.ts create mode 100644 apps/server/src/services/projects/project-source-setup.ts create mode 100644 apps/server/test/lifecycle-policy.test.ts create mode 100644 apps/server/test/public/machine-environment.test.ts create mode 100644 apps/server/test/services/machines/manual-provider.test.ts create mode 100644 apps/server/test/services/machines/provider-orchestration.test.ts create mode 100644 apps/server/test/services/machines/runtime-enrollments.test.ts create mode 100644 apps/server/test/services/machines/server-access.test.ts create mode 100644 apps/server/test/services/projects/project-source-setup.test.ts create mode 100644 packages/db/drizzle/0117_machine_providers.sql create mode 100644 packages/db/drizzle/meta/0117_snapshot.json create mode 100644 packages/db/src/data/machines.ts create mode 100644 packages/db/test/data/machines.test.ts create mode 100644 packages/db/test/machine-provider-upgrade.test.ts create mode 100644 packages/db/test/manual-machine-migration.test.ts create mode 100644 packages/domain/test/host.test.ts create mode 100644 packages/plugin-sdk/src/machine-bootstrap.ts create mode 100644 packages/plugin-sdk/src/machine-provider.ts create mode 100644 packages/server-contract/src/api/machine-environment.ts create mode 100644 plugins/bb-guide/skills/bb-plugin-authoring/references/backend-machines.md create mode 100644 plugins/connect/README.md create mode 100644 plugins/connect/src/server-access.test.ts create mode 100644 plugins/connect/src/server-access.ts create mode 100644 plugins/environment-modal-sandbox/Dockerfile create mode 100644 plugins/environment-modal-sandbox/README.md create mode 100644 plugins/environment-modal-sandbox/account.ts create mode 100644 plugins/environment-modal-sandbox/app.tsx create mode 100644 plugins/environment-modal-sandbox/configuration.test.ts create mode 100644 plugins/environment-modal-sandbox/configuration.ts create mode 100644 plugins/environment-modal-sandbox/debug-sandbox.ts create mode 100644 plugins/environment-modal-sandbox/image-definition.ts create mode 100644 plugins/environment-modal-sandbox/launch-options.ts create mode 100644 plugins/environment-modal-sandbox/lifecycle.test.ts create mode 100644 plugins/environment-modal-sandbox/lifecycle.ts create mode 100644 plugins/environment-modal-sandbox/modal-logo.svg create mode 100644 plugins/environment-modal-sandbox/package.json create mode 100644 plugins/environment-modal-sandbox/provider-id.ts create mode 100644 plugins/environment-modal-sandbox/sandbox-backend.test.ts create mode 100644 plugins/environment-modal-sandbox/sandbox-backend.ts create mode 100644 plugins/environment-modal-sandbox/scripts/stage-assets.mjs create mode 100644 plugins/environment-modal-sandbox/server.test.ts create mode 100644 plugins/environment-modal-sandbox/server.ts create mode 100644 plugins/environment-modal-sandbox/skills/modal-sandboxes/SKILL.md create mode 100644 plugins/environment-modal-sandbox/standard-image.test.ts create mode 100644 plugins/environment-modal-sandbox/standard-image.ts create mode 100644 plugins/environment-modal-sandbox/tsconfig.json create mode 100644 plugins/environment-modal-sandbox/vitest.config.ts diff --git a/apps/app/.ladle/machine-story-fixtures.ts b/apps/app/.ladle/machine-story-fixtures.ts new file mode 100644 index 0000000000..ddd56ed15f --- /dev/null +++ b/apps/app/.ladle/machine-story-fixtures.ts @@ -0,0 +1,131 @@ +import type { + ServerAccessStatus, + SystemMachineProvider, +} from "@bb/server-contract"; +import type { MachineAccessState } from "../src/components/settings/MachineAccessSettings"; +import modalLogoUrl from "../../../plugins/environment-modal-sandbox/modal-logo.svg"; + +const noop = () => {}; +const noopAsync = async () => {}; + +export const CONNECT_UNPAIRED: ServerAccessStatus = { + providers: [ + { + id: "connect", + displayName: "bb connect", + description: "Use a private getbb.app address.", + pluginId: "connect", + }, + { + id: "direct", + displayName: "Manual", + description: "Use your own domain or network address.", + pluginId: null, + }, + ], + defaultProviderId: "connect", + effectiveUrl: null, + urlSource: null, +}; + +export const CONNECT_PAIRED = CONNECT_UNPAIRED; + +export const CONNECT_PAIRED_WITHOUT_URL = CONNECT_UNPAIRED; + +export const CONNECT_UNAVAILABLE = CONNECT_UNPAIRED; + +export const MANUAL_WITHOUT_URL: ServerAccessStatus = { + ...CONNECT_UNPAIRED, + defaultProviderId: "direct", +}; + +export const MANUAL_WITH_URL: ServerAccessStatus = { + ...CONNECT_UNPAIRED, + defaultProviderId: "direct", + effectiveUrl: "https://bb.example.com", + urlSource: "setting", +}; + +export const METHOD_NOT_INSTALLED: ServerAccessStatus = { + ...CONNECT_UNPAIRED, + providers: [CONNECT_UNPAIRED.providers[1]!], + defaultProviderId: "tailscale", +}; + +export function machineAccessState( + access: ServerAccessStatus, + overrides: Partial = {}, +): MachineAccessState { + const selected = overrides.selected ?? access.defaultProviderId ?? "connect"; + return { + access, + disabled: false, + draft: null, + error: null, + effective: access.providers.find((provider) => provider.id === selected), + configurationMessage: null, + saving: false, + selected, + value: access.urlSource === "setting" ? (access.effectiveUrl ?? "") : "", + editDraft: noop, + selectProvider: noop, + commitUrl: noopAsync, + ...overrides, + }; +} + +export function machineProvider( + overrides: Partial & + Pick, +): SystemMachineProvider { + return { + description: "Run a machine for development.", + icon: "Terminal", + logoUrl: null, + pluginId: `plugin-${overrides.id}`, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: false, + ...overrides, + }; +} + +export const MODAL_MACHINE_PROVIDER = machineProvider({ + id: "modal-sandbox", + displayName: "Modal Sandbox", + description: "Create a sandbox in your Modal account.", + pluginId: "environment-modal-sandbox", + icon: "./modal-logo.svg", + logoUrl: modalLogoUrl, + supportsSuspend: true, +}); + +export const MANUAL_MACHINE_PROVIDER = machineProvider({ + id: "manual", + displayName: "Manual machine setup", + description: + "Run one command on a machine you already have to connect it to this server.", + pluginId: "core", + icon: "Terminal", +}); + +export const MODAL_NEEDS_TOKEN_PROVIDER = machineProvider({ + ...MODAL_MACHINE_PROVIDER, +}); + +export const MODAL_UNRENDERABLE_INPUTS_PROVIDER = machineProvider({ + ...MODAL_MACHINE_PROVIDER, + inputs: { + type: "object", + properties: { region: { type: "string" } }, + required: ["region"], + }, + acceptsEmptyInputs: false, +}); + +export const UNAVAILABLE_MACHINE_PROVIDER = machineProvider({ + id: "fleet", + displayName: "Fleet", + description: "Rent a machine from a managed fleet.", + icon: "Server", +}); diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx index 46eda1df7d..1e9d2306c6 100644 --- a/apps/app/.ladle/settings-story-fixtures.tsx +++ b/apps/app/.ladle/settings-story-fixtures.tsx @@ -24,6 +24,11 @@ import { } from "../src/hooks/useUpdateInventory"; import { createAppQueryClient } from "../src/lib/query-client"; import { makeSystemConfig } from "../src/test/fixtures/system-config"; +import { systemMachineProvidersQueryKey } from "../src/hooks/queries/query-keys"; +import { + MANUAL_MACHINE_PROVIDER, + MODAL_MACHINE_PROVIDER, +} from "./machine-story-fixtures"; import { makeProviderInfo } from "@bb/test-helpers/domain-fixtures"; import { getSettingsRoutePath } from "../src/lib/route-paths"; import { @@ -278,6 +283,7 @@ function createSettingsStoryQueryClient() { }, }); queryClient.setQueryData(hostsQueryKey(), SETTINGS_STORY_HOSTS); + queryClient.setQueryData(hostsQueryKey(true), SETTINGS_STORY_HOSTS); queryClient.setQueryData(systemConfigQueryKey(), systemConfig); queryClient.setQueryData(systemProvidersQueryKey(), systemProviders); queryClient.setQueryData(systemVersionQueryKey(), systemVersion); @@ -292,6 +298,10 @@ function createSettingsStoryQueryClient() { remoteProviderStatus, ); queryClient.setQueryData(pluginListQueryKey(true), []); + queryClient.setQueryData(systemMachineProvidersQueryKey(), [ + MANUAL_MACHINE_PROVIDER, + MODAL_MACHINE_PROVIDER, + ]); return queryClient; } diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 903ff0e50d..9e009bac98 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -15,6 +15,7 @@ import type { import type { ProjectResponse, SystemEnvironmentProvider, + SystemMachineProvider, } from "@bb/server-contract"; import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { @@ -323,6 +324,7 @@ export const STORY_WORKTREE_OPTIONS: readonly ReuseThreadOption[] = [ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = [ { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", icon: "Laptop", @@ -340,6 +342,7 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = inputs: null, }, { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", icon: "GitBranch", @@ -357,6 +360,7 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = inputs: null, }, { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", icon: "Folder", @@ -375,6 +379,20 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = }, ]; +export const STORY_MACHINE_PROVIDERS: readonly SystemMachineProvider[] = [ + { + id: "modal-sandbox", + displayName: "Modal Sandbox", + description: "Run a machine for development.", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + }, +]; + export const STORY_PROJECTS: readonly ProjectSelectorOption[] = [ { id: PROJECT_IDS.bb, name: PROJECT_NAMES.bb }, { id: PROJECT_IDS.pierre, name: PROJECT_NAMES.pierre }, diff --git a/apps/app/src/components/dialogs/AddMachineDialog.expiry.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.expiry.test.tsx new file mode 100644 index 0000000000..df0f181e20 --- /dev/null +++ b/apps/app/src/components/dialogs/AddMachineDialog.expiry.test.tsx @@ -0,0 +1,58 @@ +// @vitest-environment jsdom + +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MachineLaunchCommand } from "./AddMachineDialog"; + +const COMMAND = + "curl -fsSL -H 'X-BB-Enrollment: secret' https://bb/install.sh | sh"; + +describe("MachineLaunchCommand", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("counts the remaining time down while the command is still valid", () => { + render( + {}} + />, + ); + expect(screen.getByRole("status").textContent).toBe( + "Command expires in 15:00", + ); + act(() => void vi.advanceTimersByTime(61_000)); + expect(screen.getByRole("status").textContent).toBe( + "Command expires in 13:59", + ); + expect(screen.getByText(COMMAND)).toBeTruthy(); + }); + + it("stops offering a copy and offers a replacement once it expires", () => { + const onRegenerate = vi.fn(); + render( + , + ); + expect( + screen.getByRole("button", { name: "Copy" }).hasAttribute("disabled"), + ).toBe(false); + act(() => void vi.advanceTimersByTime(6_000)); + expect(screen.getByRole("status").textContent).toBe("Command expired"); + expect( + screen.getByRole("button", { name: "Copy" }).hasAttribute("disabled"), + ).toBe(true); + screen.getByRole("button", { name: "Generate a new command" }).click(); + expect(onRegenerate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/app/src/components/dialogs/AddMachineDialog.stories.tsx b/apps/app/src/components/dialogs/AddMachineDialog.stories.tsx new file mode 100644 index 0000000000..42f353eff8 --- /dev/null +++ b/apps/app/src/components/dialogs/AddMachineDialog.stories.tsx @@ -0,0 +1,214 @@ +import { useState } from "react"; +import { MachineAccessGate, ManualMachineSetupView } from "./AddMachineDialog"; +import { MachineAccessControlsContent } from "@/components/settings/MachineAccessSettings"; +import { + CONNECT_UNAVAILABLE, + CONNECT_UNPAIRED, + MANUAL_WITHOUT_URL, + machineAccessState, +} from "../../../.ladle/machine-story-fixtures"; +import { makeHost } from "../../../.ladle/story-fixtures"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { DialogStage } from "../../../.ladle/story-dialog-stage"; + +export default { + title: "dialogs/Add a Machine", +}; + +const noop = () => {}; +const CONNECTED_HOST = makeHost({ + id: "host_new", + name: "build-box", + status: "connected", +}); +const ENROLLMENT_COMMAND = + "curl -fsSL -H 'X-BB-Enrollment: bbde_TZpKWsJpWiPulVIRKNENmEVtvNwnEwDobjPFlnlsyCUUzorssgdxmgxUblRIWAUA' 'https://bb.example.com/install.sh' | sh"; + +export function AccessGate() { + return ( + + + + + {null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {null} + + + + + ); +} + +export function EnrollmentCommandState() { + const [issuedAt] = useState(() => Date.now()); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx index 0c931e991f..b6c329db7a 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx @@ -1,335 +1,70 @@ // @vitest-environment jsdom -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import type { Host } from "@bb/domain"; -import { makeHost as host } from "@bb/test-helpers/domain-fixtures"; -import type { InstalledPlugin } from "@bb/server-contract"; -import { MemoryRouter } from "react-router-dom"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { BbHttpError, sdk } from "@/lib/sdk"; -import { hostsQueryKey } from "@/hooks/queries/query-keys"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { AddMachineDialog } from "./AddMachineDialog"; -import { makeInstalledPlugin } from "@/test/fixtures/plugins"; - -vi.mock("@/lib/sdk", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - sdk: { - hosts: { - createJoinCode: vi.fn(), - list: vi.fn(), - }, - plugins: { callRpc: vi.fn(), list: vi.fn() }, +import { Dialog, DialogContent } from "@bb/shared-ui/dialog"; +import { ManualMachineSetup } from "./AddMachineDialog"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { + hosts: { + delete: vi.fn(), + experimental_create: vi.fn(), + experimental_getEnrollmentCommand: vi.fn(), + get: vi.fn(), }, - }; -}); - -vi.mock("@/lib/ws", () => ({ - wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, + }, })); -const existingHost = host({ id: "host_primary", name: "MacBook Pro" }); - -function connectPlugin( - overrides: Pick, -): InstalledPlugin { - return makeInstalledPlugin({ - id: "connect", - source: "builtin:connect", - rootDir: "/plugins/connect", - provenance: "builtin", - publisherLabel: "BB Official", - sourceDisplay: "builtin · connect", - name: "Remote access", - hasSettings: true, - ...overrides, - }); -} - -function notRunningRpcError(status: string): BbHttpError { - const message = `plugin "connect" is not running (status: ${status})`; - return new BbHttpError({ - body: { ok: false, error: message }, - code: null, - message, - status: 503, - }); -} -const writeTextMock = vi.fn().mockResolvedValue(undefined); -Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { writeText: writeTextMock }, -}); - afterEach(() => { cleanup(); vi.clearAllMocks(); }); -describe("AddMachineDialog", () => { - it("mints a join code, shows the pairing command, and detects the new machine connecting", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockResolvedValue({ - code: "mc_test456", - expiresAt: Date.now() + 10 * 60 * 1000, - serverUrl: "https://example.getbb.app", - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { queryClient, wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const command = await screen.findByText(/--join-code jc_test123/); - expect(sdk.plugins.callRpc).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "connect", - method: "createMachineCode", - input: null, - }), - ); - expect(command.textContent).toContain("--host-id host_new"); - expect(command.textContent).toContain( - "curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 https://example.getbb.app/install.sh", - ); - expect(command.textContent).toContain("--server https://example.getbb.app"); - expect(command.textContent).toContain("--machine-code mc_test456"); - expect(command.textContent).not.toContain(window.location.origin); - expect(command.closest("[data-add-machine-command]")).not.toBeNull(); - expect( - screen.getByText( - /It installs bb and keeps the machine connected to this server/u, - ), - ).toBeDefined(); - expect(screen.getByText(/Code expires in \d+:\d{2}/)).toBeDefined(); - const waiting = screen.getByText("Waiting for the machine to connect…"); - expect(waiting).toBeDefined(); - expect(waiting.parentElement?.className).not.toContain("border-border"); - - fireEvent.click(screen.getByRole("button", { name: "Copy" })); - await waitFor(() => { - expect(writeTextMock).toHaveBeenCalledWith(command.textContent); - expect(screen.getByRole("button", { name: "Copied" })).toBeDefined(); - }); - - await waitFor(() => { - expect(queryClient.getQueryData(hostsQueryKey())).toHaveLength(1); - }); - - act(() => { - queryClient.setQueryData(hostsQueryKey(), [ - existingHost, - host({ id: "host_new", name: "Mac Studio" }), - ]); - }); - - expect(await screen.findByText("Mac Studio connected")).toBeDefined(); - expect( - screen.getByRole("button", { name: "Set up a project on it →" }), - ).toBeDefined(); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - }); - - it("falls back to direct pairing when connect is unpaired and ignores known hosts", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - new BbHttpError({ - body: { - ok: false, - error: { code: "handler_error", message: "not_paired" }, - }, - code: "handler_error", - message: "not_paired", - status: 500, - }), - ); - vi.mocked(sdk.hosts.list).mockResolvedValue([ - existingHost, - host({ id: "host_offline", name: "dev-vm", status: "disconnected" }), - ]); - - const { queryClient, wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const command = await screen.findByText(/--join-code jc_test123/); - expect(command.textContent).toContain( - "curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 http://direct.example.test:38886/install.sh", - ); - expect(command.textContent).toContain( - "--server http://direct.example.test:38886", - ); - expect(command.textContent).not.toContain("--machine-code"); - - await waitFor(() => { - expect(queryClient.getQueryData(hostsQueryKey())).toHaveLength(2); - }); - - act(() => { - queryClient.setQueryData(hostsQueryKey(), [ - existingHost, - host({ id: "host_offline", name: "dev-vm" }), - ]); - }); - - expect( - await screen.findByText("Waiting for the machine to connect…"), - ).toBeDefined(); - expect(screen.queryByText("dev-vm connected")).toBeNull(); - }); - - it("explains that a loopback server is unreachable when connect is unpaired", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - new BbHttpError({ - body: { - ok: false, - error: { code: "handler_error", message: "not_paired" }, - }, - code: "handler_error", - message: "not_paired", - status: 500, - }), - ); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const notice = await screen.findByRole("status"); - expect(notice.textContent).toContain( - "Another machine cannot use this address.", - ); - expect(notice.textContent).toContain("http://127.0.0.1:38886"); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - const link = screen.getByRole("link", { name: "Set up remote access" }); - expect(link.getAttribute("href")).toBe("/settings/plugins/connect"); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); +it("cancels a creating manual launch when the dialog content closes", async () => { + vi.mocked(sdk.hosts.experimental_create).mockResolvedValue({ + id: "host-reserved", + name: "Manual machine", + type: "persistent", + status: "disconnected", + machineProviderId: "manual", + lifecycle: { + phase: "creating", + suspendedAt: null, + message: "Waiting for the machine", + pendingLog: "", + teardown: null, + }, + maxPermissionMode: "full", + lastSeenAt: null, + lastRejectedProtocolVersion: null, + createdAt: 1, + updatedAt: 1, }); - - it("offers a retry when connect is temporarily unavailable on a loopback server", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - notRunningRpcError("degraded"), - ); - vi.mocked(sdk.plugins.list).mockResolvedValue({ - plugins: [connectPlugin({ enabled: true, status: "degraded" })], - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - expect( - await screen.findByText("Remote access isn't ready yet."), - ).toBeDefined(); - expect(screen.getByRole("button", { name: "Try again" })).toBeDefined(); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - expect(screen.queryByRole("status")).toBeNull(); + vi.mocked(sdk.hosts.experimental_getEnrollmentCommand).mockResolvedValue({ + command: "bb machine enroll test", + expiresAt: Date.now() + 60_000, }); - - it("links to the Connect plugin when it is disabled on a loopback server", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - notRunningRpcError("disabled"), - ); - vi.mocked(sdk.plugins.list).mockResolvedValue({ - plugins: [connectPlugin({ enabled: false, status: "disabled" })], - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const notice = await screen.findByRole("status"); - expect(notice.textContent).toContain("The Connect plugin is disabled"); - const link = screen.getByRole("link", { - name: "Enable the Connect plugin", + vi.mocked(sdk.hosts.get).mockImplementation(() => new Promise(() => {})); + vi.mocked(sdk.hosts.delete).mockResolvedValue({ ok: true }); + const { wrapper } = createQueryClientTestHarness(); + const rendered = render( + + + {}} /> + + , + { wrapper }, + ); + + await screen.findByText("bb machine enroll test"); + rendered.unmount(); + + await waitFor(() => { + expect(sdk.hosts.delete).toHaveBeenCalledWith({ + hostId: "host-reserved", }); - expect(link.getAttribute("href")).toBe( - "/settings/plugins/connect?view=installed", - ); - expect(screen.queryByText("Remote access isn't ready yet.")).toBeNull(); - expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); }); }); diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx index 585f44b8dd..480f7df455 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx @@ -1,397 +1,396 @@ -import { useEffect, useRef, useState } from "react"; -import { Link } from "react-router-dom"; +import { MachineAccessControls } from "@/components/settings/MachineAccessSettings"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { machineServerAccessReady } from "@/components/machines/machine-server-access"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useMutation } from "@tanstack/react-query"; import type { Host } from "@bb/domain"; -import { z } from "zod"; import { Button } from "@bb/shared-ui/button"; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@bb/shared-ui/dialog"; import { Icon } from "@bb/shared-ui/icon"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useHosts } from "@/hooks/queries/host-queries"; +import { sdk } from "@/lib/sdk"; import { useClipboardCopy } from "@/lib/clipboard"; -import { isLocalOnlyUrl } from "@/lib/loopback-hostname"; -import { - getPluginConfigurationRoutePath, - getPluginDetailRoutePath, -} from "@/lib/route-paths"; -import { BbHttpError, sdk } from "@/lib/sdk"; +import { Link } from "react-router-dom"; +import { getSettingsMachineRoutePath } from "@/lib/route-paths"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -interface AddMachineDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - serverUrl: string | null; -} - -const connectMachineCodeSchema = z.object({ - code: z.string(), - expiresAt: z.number(), - serverUrl: z.string(), -}); - -const pluginRpcErrorEnvelopeSchema = z.object({ - error: z.object({ message: z.string() }), -}); - -type ConnectMachineCode = z.infer; - -function isNotPairedRpcError(error: BbHttpError): boolean { - const envelope = pluginRpcErrorEnvelopeSchema.safeParse(error.body); - return envelope.success && envelope.data.error.message === "not_paired"; -} - -type ConnectMachineCodeResult = - | { kind: "issued"; code: ConnectMachineCode } - | { kind: "unpaired" } - | { kind: "disabled" } - | { kind: "unavailable" }; - -async function isConnectPluginDisabled(): Promise { - try { - const { plugins } = await sdk.plugins.list(); - const connect = plugins.find((plugin) => plugin.id === "connect"); - return connect !== undefined && !connect.enabled; - } catch { - return false; - } -} - -async function createConnectMachineCode(): Promise { - try { - const code = await sdk.plugins.callRpc({ - pluginId: "connect", - method: "createMachineCode", - input: null, - outputSchema: connectMachineCodeSchema, - }); - return { kind: "issued", code }; - } catch (error) { - if (!(error instanceof BbHttpError)) throw error; - if ( - error.code === "not_paired" || - isNotPairedRpcError(error) || - error.status === 404 - ) { - return { kind: "unpaired" }; - } - if (error.status === 503) { - return (await isConnectPluginDisabled()) - ? { kind: "disabled" } - : { kind: "unavailable" }; - } - if (error.status === 422) { - return { kind: "unavailable" }; - } - throw error; - } -} +const MANUAL_MACHINE_PROVIDER_ID = "manual"; export function AddMachineDialog({ open, onOpenChange, - serverUrl, -}: AddMachineDialogProps) { +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const hosts = useHosts(); + const close = (next: boolean) => { + if (!next) void hosts.refetch(); + onOpenChange(next); + }; return ( - - - {open ? ( - - ) : null} + + + {open && } ); } -function formatCountdown(remainingMs: number): string { - const totalSeconds = Math.max(0, Math.floor(remainingMs / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -} - -function pairingCommand( - joinCode: string, - hostId: string, - machineCode: ConnectMachineCode | null, - directServerUrl: string | null, -): string | null { - const serverUrl = machineCode?.serverUrl ?? directServerUrl; - if (serverUrl === null) return null; - const machineFlag = - machineCode === null ? "" : ` --machine-code ${machineCode.code}`; - return `curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 ${serverUrl}/install.sh | sh -s -- --join-code ${joinCode} --host-id ${hostId} --server ${serverUrl}${machineFlag}`; +export function AddMachineContent({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const config = useSystemConfig(); + const accessReady = machineServerAccessReady(config.data?.serverAccess); + if (!accessReady) { + return ( + void config.refetch() } + : { status: "blocked" } + } + > + onOpenChange(false)} /> + + ); + } + return ; } -const REMOTE_ACCESS_ROUTE = getPluginConfigurationRoutePath({ - pluginId: "connect", -}); -const CONNECT_PLUGIN_ROUTE = getPluginDetailRoutePath({ - pluginId: "connect", - view: "installed", -}); +export type MachineAccessGateState = + | { status: "checking" } + | { status: "failed"; onRetry: () => void } + | { status: "blocked" }; -function UnreachableServerNotice({ - serverUrl, - reason, +export function MachineAccessGate({ + state, + children, }: { - serverUrl: string; - reason: "unpaired" | "disabled"; + state: MachineAccessGateState; + children: ReactNode; }) { + if (state.status === "checking") { + return ( + <> + Add a machine +

+ Checking machine access… +

+ + ); + } + if (state.status === "failed") { + return ( + <> + + Add a machine + + Couldn’t check whether machines can reach this server. + + +
+ +
+ + ); + } return ( -
-

- Another machine cannot use this address. -

-

- The pairing command would target{" "} - {serverUrl}, which points to the - machine that runs it, not to this bb.{" "} - {reason === "disabled" - ? "The Connect plugin is disabled, so remote access is off. Enable it, then come back here to get a pairing command that works from anywhere." - : "Set up remote access first, then come back here to get a pairing command that works from anywhere."} -

-
- - - Other options - -
-
+ <> + + Set up machine access + + A new machine has to reach this server over the network. Choose the + address it should use. + + + {children} + ); } -function AddMachineDialogContent({ +export interface EnrollmentCommand { + value: string; + expiresAt: number; +} + +export function ManualMachineSetup({ onOpenChange, - serverUrl, }: { onOpenChange: (open: boolean) => void; - serverUrl: string | null; }) { - const hostsQuery = useHosts(); - const mintJoinCode = useMutation({ + const createController = useRef(null); + const createKey = useRef(null); + const pendingHostIds = useRef(new Set()); + const lifecycleGeneration = useRef(0); + const [command, setCommand] = useState(null); + const [connectedHost, setConnectedHost] = useState(null); + useEffect( + () => () => { + lifecycleGeneration.current += 1; + createController.current?.abort(); + createKey.current = null; + for (const hostId of pendingHostIds.current) { + void sdk.hosts.delete({ hostId }).catch(() => undefined); + } + pendingHostIds.current.clear(); + }, + [], + ); + const createMachine = useMutation({ meta: { showErrorToast: false }, - mutationFn: async () => { - const [join, machine] = await Promise.all([ - sdk.hosts.createJoinCode(), - createConnectMachineCode(), - ]); - return { join, machine }; + mutationFn: async (options: { replaceLaunch: boolean }) => { + const generation = lifecycleGeneration.current; + if (options.replaceLaunch) { + createController.current?.abort(); + createController.current = null; + const ids = [...pendingHostIds.current]; + pendingHostIds.current.clear(); + await Promise.all(ids.map((hostId) => sdk.hosts.delete({ hostId }))); + createKey.current = null; + } + setCommand(null); + const controller = new AbortController(); + createController.current = controller; + createKey.current ??= crypto.randomUUID(); + try { + let host = await sdk.hosts.experimental_create({ + key: createKey.current, + machineProviderId: MANUAL_MACHINE_PROVIDER_ID, + inputs: null, + wait: false, + signal: controller.signal, + }); + pendingHostIds.current.add(host.id); + if (generation !== lifecycleGeneration.current) { + pendingHostIds.current.delete(host.id); + await sdk.hosts.delete({ hostId: host.id }); + throw new Error("Machine setup closed"); + } + const enrollment = await sdk.hosts.experimental_getEnrollmentCommand({ + hostId: host.id, + signal: controller.signal, + }); + setCommand( + enrollment === null + ? null + : { value: enrollment.command, expiresAt: enrollment.expiresAt }, + ); + while (host.lifecycle.phase === "creating") { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + controller.signal.throwIfAborted(); + host = await sdk.hosts.get({ + hostId: host.id, + signal: controller.signal, + }); + } + createKey.current = null; + pendingHostIds.current.delete(host.id); + if (host.lifecycle.phase !== "active") { + throw new Error(host.lifecycle.message ?? "Machine setup cancelled"); + } + return host; + } finally { + if (createController.current === controller) + createController.current = null; + } + }, + onSuccess: (host: Host) => { + createKey.current = null; + setConnectedHost(host); }, }); - const mint = mintJoinCode.mutate; + const start = createMachine.mutate; useEffect(() => { - mint(); - }, [mint]); - - const baselineHostIds = useRef | null>(null); - if (baselineHostIds.current === null && hostsQuery.data !== undefined) { - baselineHostIds.current = new Set(hostsQuery.data.map((host) => host.id)); - } - const connectedNewHost: Host | null = - (baselineHostIds.current !== null - ? hostsQuery.data?.find( - (host) => - host.status === "connected" && - !baselineHostIds.current?.has(host.id), - ) - : undefined) ?? null; - - const joinCode = mintJoinCode.data?.join ?? null; - const machineCodeResult = mintJoinCode.data?.machine ?? null; - const machineCode = - machineCodeResult?.kind === "issued" ? machineCodeResult.code : null; - const expiresAt = - joinCode === null - ? null - : Math.min(joinCode.expiresAt, machineCode?.expiresAt ?? Infinity); - const localOnlyServerUrl = - serverUrl !== null && isLocalOnlyUrl(serverUrl) ? serverUrl : null; - const unreachable = - (machineCodeResult?.kind === "unpaired" || - machineCodeResult?.kind === "disabled") && - localOnlyServerUrl !== null - ? { serverUrl: localOnlyServerUrl, reason: machineCodeResult.kind } - : null; - const connectUnavailable = - machineCodeResult?.kind === "unavailable" && localOnlyServerUrl !== null; - const showCommand = - joinCode !== null && unreachable === null && !connectUnavailable; + start({ replaceLaunch: false }); + }, [start]); - const [now, setNow] = useState(() => Date.now()); - const hasCountdown = showCommand && expiresAt !== null; - useEffect(() => { - if (!hasCountdown) return; - const interval = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(interval); - }, [hasCountdown]); - const remainingMs = - hasCountdown && expiresAt !== null ? expiresAt - now : null; - const expired = remainingMs !== null && remainingMs <= 0; - const command = - showCommand && joinCode !== null - ? pairingCommand( - joinCode.joinCode, - joinCode.hostId, - machineCode, - serverUrl, - ) - : null; - const { copied, copy } = useClipboardCopy({ text: command ?? "" }); + return ( + createMachine.mutate({ replaceLaunch: false })} + onRegenerate={() => createMachine.mutate({ replaceLaunch: true })} + onOpenMachine={() => onOpenChange(false)} + /> + ); +} +export function ManualMachineSetupView({ + command, + connectedHost, + errorMessage, + onRetry, + onRegenerate, + onOpenMachine, +}: { + command: EnrollmentCommand | null; + connectedHost: Host | null; + errorMessage: string | null; + onRetry: () => void; + onRegenerate: () => void; + onOpenMachine: () => void; +}) { return ( <> Add a machine - - {unreachable !== null - ? "Pair a machine to run projects and threads on it." - : "Run this command on the machine you want to add. It installs bb and keeps the machine connected to this server."} + + {errorMessage ?? + "Run this command on the machine you want to add. It installs bb and keeps the machine connected to this server."} -
- {mintJoinCode.isError || connectUnavailable ? ( -
-

- {connectUnavailable - ? "Remote access isn't ready yet." - : getMutationErrorMessage({ - error: mintJoinCode.error, - fallbackMessage: "Couldn't create a join code.", - })} -

+ {errorMessage === null ? null : ( +
+ +
+ )} + {command === null ? null : ( + + )} + {errorMessage === null ? ( +
+ {connectedHost === null ? ( + <> + + + {command === null + ? "Preparing an enrollment command…" + : "Waiting for the machine to connect…"} + + + ) : ( + <> + + + {connectedHost.name} connected + + + + )} +
+ ) : null} + + ); +} + +function formatCountdown(remainingMs: number): string { + const totalSeconds = Math.max(0, Math.floor(remainingMs / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function MachineLaunchCommand({ + command, + expiresAt, + onRegenerate, +}: { + command: string; + expiresAt: number; + onRegenerate: () => void; +}) { + const { copied, copy } = useClipboardCopy({ text: command }); + const [remaining, setRemaining] = useState(() => expiresAt - Date.now()); + useEffect(() => { + const timer = setInterval( + () => setRemaining(expiresAt - Date.now()), + 1_000, + ); + return () => clearInterval(timer); + }, [expiresAt]); + const expired = remaining <= 0; + return ( +
+
+        {command}
+      
+
+ {expired ? ( + <> + + Command expired + -
- ) : unreachable !== null ? ( - - ) : command !== null ? ( -
-
-              {command}
-            
-
- {expired ? ( - <> - - Code expired - - - - ) : remainingMs !== null ? ( - - Code expires in {formatCountdown(remainingMs)} - - ) : null} - -
-
+ ) : ( -

- - Creating a join code… -

- )} - {unreachable !== null ? null : ( -
- {connectedNewHost !== null ? ( - <> - - - {connectedNewHost.name} connected - - - - ) : ( - <> - - - Waiting for the machine to connect… - - - )} -
+ + Command expires in {formatCountdown(remaining)} + )} -
- - - +
+
); } diff --git a/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx b/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx index 4278244b98..34631a956d 100644 --- a/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx +++ b/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx @@ -57,18 +57,20 @@ export function ConfirmDeleteDialogContent({ } interface ConfirmDeleteDialogProps { + modal?: boolean; open: boolean; onOpenChange: (open: boolean) => void; children: ReactNode; } export function ConfirmDeleteDialog({ + modal = true, open, onOpenChange, children, }: ConfirmDeleteDialogProps) { return ( - + {open ? children : null} ); diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index 2658126dc2..61fddb4aff 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -1,4 +1,3 @@ -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useEffect, useId, useState, type FormEvent } from "react"; import { DropdownMenu, @@ -26,8 +25,9 @@ import { import { Input } from "@bb/shared-ui/input"; import { cn } from "@bb/shared-ui/lib/utils"; import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; -import { selectPersistentHosts } from "@/hooks/queries/host-queries"; +import { selectHosts } from "@/hooks/queries/host-queries"; export type ProjectPathDialogTarget = | { @@ -159,7 +159,7 @@ export function ProjectPathDialogContent({ const inputId = useId(); const isPointerCoarse = usePointerCoarse(); const machineOptions = - target.kind === "create" ? selectPersistentHosts(hosts) : undefined; + target.kind === "create" ? selectHosts(hosts) : undefined; const firstConnectedHostId = machineOptions?.find( (host) => host.status === "connected", )?.id; diff --git a/apps/app/src/components/machines/MachineLabel.stories.tsx b/apps/app/src/components/machines/MachineLabel.stories.tsx new file mode 100644 index 0000000000..3dad7660ca --- /dev/null +++ b/apps/app/src/components/machines/MachineLabel.stories.tsx @@ -0,0 +1,34 @@ +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { MachineLabel } from "./MachineLabel"; + +export default { + title: "machines/Machine Label", +}; + +const modalProvider = { + id: "modal-sandbox", + displayName: "Modal Sandbox", + icon: "Cloud", + logoUrl: null, +}; + +export function States() { + return ( + + + + + + + + + ); +} diff --git a/apps/app/src/components/machines/MachineLabel.test.tsx b/apps/app/src/components/machines/MachineLabel.test.tsx new file mode 100644 index 0000000000..0d409bd4f2 --- /dev/null +++ b/apps/app/src/components/machines/MachineLabel.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { describe, expect, it } from "vitest"; +import { MachineLabel } from "./MachineLabel"; + +const modalProvider = { + id: "modal-sandbox", + displayName: "Modal Sandbox", + icon: "Cloud", + logoUrl: null, +}; + +describe("MachineLabel", () => { + it("uses the laptop icon and host name for a persistent machine", () => { + const { container } = render( + , + ); + + expect(screen.getByText("MacBook Pro")).toBeTruthy(); + expect(container.querySelector('[data-icon="Laptop"]')).not.toBeNull(); + }); + + it("uses the provider icon and only the host name for an ephemeral machine", () => { + const { container } = render( + , + ); + + expect(screen.getByText("Modal sandbox ugxe6e")).toBeTruthy(); + expect(screen.queryByText(modalProvider.displayName)).toBeNull(); + expect(container.querySelector('[data-icon="Cloud"]')).not.toBeNull(); + expect(container.querySelector('[data-icon="Laptop"]')).toBeNull(); + }); +}); diff --git a/apps/app/src/components/machines/MachineLabel.tsx b/apps/app/src/components/machines/MachineLabel.tsx new file mode 100644 index 0000000000..7732a7f57a --- /dev/null +++ b/apps/app/src/components/machines/MachineLabel.tsx @@ -0,0 +1,74 @@ +import type { Host } from "@bb/domain"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + MachineProviderIcon, + type MachineProviderPresentation, +} from "@/components/plugin/MachineProviderIcon"; + +export type MachineLabelHost = Pick< + Host, + "machineProviderId" | "name" | "type" +>; + +export function MachineIcon({ + host, + machineProvider, + className, +}: { + host: MachineLabelHost; + machineProvider?: MachineProviderPresentation | null; + className?: string; +}) { + const provider = + host.type === "ephemeral" && host.machineProviderId !== null + ? machineProvider?.id === host.machineProviderId + ? machineProvider + : { + id: host.machineProviderId, + displayName: host.machineProviderId, + icon: "Server", + logoUrl: null, + } + : null; + if (provider === null) { + return ( + + ); + } + return ( + + ); +} + +export function MachineLabel({ + host, + machineProvider, + className, + iconClassName, + nameClassName, +}: { + host: MachineLabelHost; + machineProvider?: MachineProviderPresentation | null; + className?: string; + iconClassName?: string; + nameClassName?: string; +}) { + return ( + + + {host.name} + + ); +} diff --git a/apps/app/src/components/machines/MachineLifecycleActions.tsx b/apps/app/src/components/machines/MachineLifecycleActions.tsx new file mode 100644 index 0000000000..a6317cdc04 --- /dev/null +++ b/apps/app/src/components/machines/MachineLifecycleActions.tsx @@ -0,0 +1,77 @@ +import type { Host } from "@bb/domain"; +import type { SystemMachineProvider } from "@bb/server-contract"; +import { Button } from "@bb/shared-ui/button"; +import { DropdownMenuItem } from "@bb/shared-ui/dropdown-menu"; +import { Icon, type IconName } from "@bb/shared-ui/icon"; + +interface MachineLifecycleActionsProps { + host: Host; + machineProvider: SystemMachineProvider | null; + pending: boolean; + presentation: "buttons" | "menu"; + onSuspend: () => void; + onResume: () => void; + onRetryCleanup: () => void; +} + +export function MachineLifecycleActions({ + host, + machineProvider, + pending, + presentation, + onSuspend, + onResume, + onRetryCleanup, +}: MachineLifecycleActionsProps) { + let action: { + icon: IconName; + label: string; + onSelect: () => void; + } | null = null; + + if (machineProvider?.supportsSuspend && host.lifecycle.phase === "active") { + action = { icon: "Pause", label: "Suspend", onSelect: onSuspend }; + } else if ( + machineProvider?.supportsSuspend && + host.lifecycle.phase === "suspended" + ) { + action = { icon: "Play", label: "Resume", onSelect: onResume }; + } else if ( + host.lifecycle.phase === "removing" && + host.lifecycle.teardown?.status === "failed" + ) { + action = { + icon: "RotateCcw", + label: "Retry cleanup", + onSelect: onRetryCleanup, + }; + } + + if (action === null) return null; + + if (presentation === "menu") { + return ( + + + {action.label} + + ); + } + + return ( + + ); +} diff --git a/apps/app/src/components/machines/MachineLifecycleNotice.tsx b/apps/app/src/components/machines/MachineLifecycleNotice.tsx new file mode 100644 index 0000000000..03cc1ed6ad --- /dev/null +++ b/apps/app/src/components/machines/MachineLifecycleNotice.tsx @@ -0,0 +1,28 @@ +import type { MachineLifecycle } from "@bb/domain"; +import { cn } from "@bb/shared-ui/lib/utils"; + +export type MachineLifecycleNoticeState = Pick & { + message: string | null; +}; + +export function MachineLifecycleNoticeContent({ + notice, +}: { + notice: MachineLifecycleNoticeState | null; +}) { + if (notice === null || notice.message === null) return null; + return ( +

+ {notice.message} +

+ ); +} diff --git a/apps/app/src/components/machines/MachineRemoveDialog.tsx b/apps/app/src/components/machines/MachineRemoveDialog.tsx new file mode 100644 index 0000000000..a8f5aeecd7 --- /dev/null +++ b/apps/app/src/components/machines/MachineRemoveDialog.tsx @@ -0,0 +1,83 @@ +import type { Host } from "@bb/domain"; +import { Button } from "@bb/shared-ui/button"; +import { + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@bb/shared-ui/dialog"; +import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog"; +import { useRemoveHost } from "@/hooks/mutations/host-mutations"; +import { getMutationErrorMessage } from "@/lib/mutation-errors"; + +export function machineRemovalConsequences(host: Host): string { + if (host.type === "ephemeral") { + return "The compute and its saved snapshots are deleted. Its environments remain as read-only history."; + } + if (host.machineProviderId !== null) { + return "The provider cleans up resources it owns. Its environments remain as read-only history."; + } + return "Project checkouts stay on its disk, but its environments become read-only history and it cannot run new work until paired again."; +} + +export function MachineRemoveDialog({ + target, + onOpenChange, + onRemoved, +}: { + target: Host | null; + onOpenChange: (open: boolean) => void; + onRemoved?: (host: Host) => void; +}) { + const removeHost = useRemoveHost(); + + return ( + { + if (!open && !removeHost.isPending) { + removeHost.reset(); + onOpenChange(false); + } + }} + > + {target === null ? null : ( + <> + + Remove {target.name}? + + This revokes {target.name}'s access to this server.{" "} + {machineRemovalConsequences(target)} + + + {removeHost.isError ? ( +

+ {getMutationErrorMessage({ + error: removeHost.error, + fallbackMessage: `Couldn't remove ${target.name}.`, + })} +

+ ) : null} + + + + + )} +
+ ); +} diff --git a/apps/app/src/components/machines/MachineServerAccessNotice.stories.tsx b/apps/app/src/components/machines/MachineServerAccessNotice.stories.tsx new file mode 100644 index 0000000000..09339eca19 --- /dev/null +++ b/apps/app/src/components/machines/MachineServerAccessNotice.stories.tsx @@ -0,0 +1,34 @@ +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { MachineServerAccessNoticeContent } from "./MachineServerAccessNotice"; +import { machineServerAccessBlockedReason } from "./machine-server-access"; +import { + CONNECT_PAIRED, + CONNECT_UNPAIRED, +} from "../../../.ladle/machine-story-fixtures"; + +export default { + title: "settings/Machine server access notice", +}; + +export function Reasons() { + return ( + + + + + + + + + ); +} diff --git a/apps/app/src/components/machines/MachineServerAccessNotice.tsx b/apps/app/src/components/machines/MachineServerAccessNotice.tsx new file mode 100644 index 0000000000..7bbe609217 --- /dev/null +++ b/apps/app/src/components/machines/MachineServerAccessNotice.tsx @@ -0,0 +1,71 @@ +import { Link } from "react-router-dom"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; +import { getSettingsRoutePath } from "@/lib/route-paths"; +import { + MACHINE_SERVER_ACCESS_TITLE, + machineServerAccessBlockedReason, +} from "./machine-server-access"; + +export function PluginMachineServerAccessNotice({ + pluginId, +}: { + pluginId: string; +}) { + const { providers } = useSystemMachineProviders(); + const access = useSystemConfig().data?.serverAccess; + const ownsMachineProvider = (providers ?? []).some( + (provider) => provider.pluginId === pluginId, + ); + if (!ownsMachineProvider) return null; + return ( + + ); +} + +export function MachineServerAccessNoticeContent({ + reason, +}: { + reason: string | null; +}) { + if (reason === null) return null; + return ( +
+
+
+ +
+

+ {MACHINE_SERVER_ACCESS_TITLE} +

+

+ {reason} +

+
+
+ +
+
+ ); +} diff --git a/apps/app/src/components/machines/MachineStatusDot.tsx b/apps/app/src/components/machines/MachineStatusDot.tsx index ce64856a33..6595a72b17 100644 --- a/apps/app/src/components/machines/MachineStatusDot.tsx +++ b/apps/app/src/components/machines/MachineStatusDot.tsx @@ -1,18 +1,26 @@ import { cn } from "@bb/shared-ui/lib/utils"; +import type { MachineStatusTone } from "./machine-status"; export function MachineStatusDot({ connected, + tone, className, }: { - connected: boolean; + connected?: boolean; + tone?: MachineStatusTone; className?: string; }) { + const resolved: MachineStatusTone = + tone ?? (connected === true ? "online" : "offline"); return ( diff --git a/apps/app/src/components/machines/machine-server-access.test.ts b/apps/app/src/components/machines/machine-server-access.test.ts new file mode 100644 index 0000000000..cdd8a3834c --- /dev/null +++ b/apps/app/src/components/machines/machine-server-access.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + machineServerAccessBlockedReason, + machineServerAccessReady, +} from "./machine-server-access"; +import type { ServerAccessStatus } from "@bb/server-contract"; + +function status( + overrides: Partial = {}, +): ServerAccessStatus { + return { + providers: [ + { + id: "relay", + displayName: "Relay", + description: "Use a managed relay.", + pluginId: "relay-plugin", + }, + ], + defaultProviderId: "relay", + effectiveUrl: null, + urlSource: null, + ...overrides, + }; +} + +describe("machine server access readiness", () => { + it("accepts a registered provider and rejects missing configuration", () => { + expect(machineServerAccessReady(status())).toBe(true); + expect(machineServerAccessReady(status({ providers: [] }))).toBe(false); + expect(machineServerAccessReady(undefined)).toBe(false); + }); + + it("requires a reachable address for direct access", () => { + const direct = status({ + defaultProviderId: "direct", + providers: [ + { + id: "direct", + displayName: "Manual", + description: "Use a network address.", + pluginId: null, + }, + ], + effectiveUrl: "https://bb.example.com", + urlSource: "setting", + }); + expect(machineServerAccessReady(direct)).toBe(true); + expect( + machineServerAccessReady({ + ...direct, + effectiveUrl: "http://localhost:3000", + }), + ).toBe(false); + }); + + it("explains blocked configuration and clears the reason when ready", () => { + const blocked = status({ providers: [] }); + expect(machineServerAccessBlockedReason(blocked)).toBe( + "Configure how machines should connect to this bb server.", + ); + expect(machineServerAccessBlockedReason(status())).toBeNull(); + }); +}); diff --git a/apps/app/src/components/machines/machine-server-access.ts b/apps/app/src/components/machines/machine-server-access.ts new file mode 100644 index 0000000000..60f525ac08 --- /dev/null +++ b/apps/app/src/components/machines/machine-server-access.ts @@ -0,0 +1,27 @@ +import type { ServerAccessStatus } from "@bb/server-contract"; +import { isLocalOnlyUrl } from "@/lib/loopback-hostname"; + +export const MACHINE_SERVER_ACCESS_TITLE = "Machines cannot reach this bb yet"; + +export function machineServerAccessReady( + access: ServerAccessStatus | undefined, +): boolean { + if (access === undefined) return false; + const provider = access.providers.find( + (candidate) => candidate.id === access.defaultProviderId, + ); + if (provider === undefined) return false; + if (access.defaultProviderId !== "direct") return true; + return access.effectiveUrl !== null && !isLocalOnlyUrl(access.effectiveUrl); +} + +export const MACHINE_SERVER_ACCESS_UNSET_REASON = + "Configure how machines should connect to this bb server."; + +export function machineServerAccessBlockedReason( + access: ServerAccessStatus | undefined, +): string | null { + return machineServerAccessReady(access) + ? null + : MACHINE_SERVER_ACCESS_UNSET_REASON; +} diff --git a/apps/app/src/components/machines/machine-status.ts b/apps/app/src/components/machines/machine-status.ts new file mode 100644 index 0000000000..dcf5e4e750 --- /dev/null +++ b/apps/app/src/components/machines/machine-status.ts @@ -0,0 +1,48 @@ +import type { Host, MachineLifecycle } from "@bb/domain"; +import { formatRelativeTime } from "@/lib/relative-time"; + +export type MachineStatusTone = "online" | "attention" | "failed" | "offline"; + +export function machinePhaseLabel( + lifecycle: MachineLifecycle, +): "Paused" | "Pausing" | "Removing" | "Cleanup failed" | null { + if ( + lifecycle.phase === "removing" && + lifecycle.teardown?.status === "failed" + ) { + return "Cleanup failed"; + } + if (lifecycle.phase === "suspending") return "Pausing"; + if (lifecycle.phase === "suspended") return "Paused"; + if (lifecycle.phase === "removing") return "Removing"; + return null; +} + +export function machineStatusTone(host: Host): MachineStatusTone { + if (machinePhaseLabel(host.lifecycle) === "Cleanup failed") return "failed"; + if ( + host.lifecycle.phase === "removing" || + host.lifecycle.phase === "suspending" + ) + return "attention"; + return host.status === "connected" ? "online" : "offline"; +} + +export function machineStatusLabel({ + host, + now, +}: { + host: Host; + now: number; +}): string { + const parts: string[] = []; + const phase = machinePhaseLabel(host.lifecycle); + parts.push(phase ?? (host.status === "connected" ? "Online" : "Offline")); + if (host.lifecycle.message !== null) parts.push(host.lifecycle.message); + else if (host.status !== "connected" && host.lastSeenAt !== null) { + parts.push( + `last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`, + ); + } + return parts.join(" · "); +} diff --git a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx index 2aecfcbd12..eb1ae3a5e1 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { MemoryRouter } from "react-router-dom"; import type { Host, ProjectSource } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; @@ -12,6 +14,7 @@ import { } from "./EnvironmentPicker"; const checkoutProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", icon: "Laptop", @@ -30,6 +33,7 @@ const checkoutProvider: SystemEnvironmentProvider = { }; const branchProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "branchy", displayName: "New branch workspace", icon: "GitBranch", @@ -48,6 +52,7 @@ const branchProvider: SystemEnvironmentProvider = { }; const sandboxProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "container", displayName: "Docker container", icon: "Container", @@ -70,6 +75,7 @@ const sandboxProvider: SystemEnvironmentProvider = { }; const optionalInputsProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "optional-sandbox", displayName: "Optional sandbox", icon: "Container", @@ -113,7 +119,42 @@ afterEach(() => { vi.clearAllMocks(); }); +function renderPicker(ui: ReactElement) { + return render({ui}); +} + describe("EnvironmentPickerUI", () => { + it("does not expose an ephemeral host through the single-machine fallback", () => { + const ephemeralHost: Host = { + ...host, + name: "Modal sandbox 3f9a", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }; + renderPicker( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + expect(screen.queryByText(ephemeralHost.name)).toBeNull(); + expect( + screen.getByRole("menuitem", { name: "No host connected" }), + ).toBeTruthy(); + expect( + screen.queryByRole("menuitem", { name: /Project checkout/u }), + ).toBeNull(); + }); + it.each([false, true])( "shows loading instead of empty options (multiple machines: %s)", (multipleMachines) => { @@ -179,7 +220,7 @@ describe("EnvironmentPickerUI", () => { displayName: "Project checkout with a deliberately long provider label", }; const onSelectProvider = vi.fn(); - render( + renderPicker( { ).toBeTruthy(); expect(screen.getByText("Configure credentials")).toBeTruthy(); }); - it("omits a projectless-only provider from a project picker", () => { - render( + renderPicker( { expect(screen.getByText("Configure credentials")).toBeTruthy(); }); - it("keeps a setup-required provider selectable and shows its message", () => { + it("selects a setup-required composed provider without navigating away", () => { const onSelectProvider = vi.fn(); const setupRequiredProvider: SystemEnvironmentProvider = { ...sandboxProvider, + machineProviderId: "modal-sandbox", acceptsEmptyInputs: true, machineAvailability: {}, inputs: null, @@ -396,7 +437,7 @@ describe("EnvironmentPickerUI", () => { message: "Add Modal credentials", }, }; - render( + renderPicker( { const providerItem = screen.getByRole("menuitem", { name: /Docker container/u, }); + expect(providerItem.getAttribute("href")).toBeNull(); + expect(screen.queryByText("Set it up in plugin settings")).toBeNull(); expect(providerItem.getAttribute("aria-disabled")).toBeNull(); expect(screen.getByText("Add Modal credentials")).toBeTruthy(); fireEvent.click(providerItem); - expect(onSelectProvider).toHaveBeenCalledWith( - setupRequiredProvider, - host.id, - ); + expect(onSelectProvider).toHaveBeenCalledWith(setupRequiredProvider, null); }); it("disables a provider that declares inputs until its plugin registers a control", () => { @@ -471,7 +511,7 @@ describe("EnvironmentPickerUI", () => { it("keeps a provider whose inputs schema requires nothing selectable without a control", () => { const onSelectProvider = vi.fn(); - render( + renderPicker( { hostId: string | null, ) => void; }) { - render( + renderPicker( { expect(screen.getByText("MacBook Pro")).toBeTruthy(); expect(screen.getByText("this machine")).toBeTruthy(); expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect( + screen + .getByText("MacBook Pro") + .parentElement?.querySelector('[data-icon="Laptop"]'), + ).not.toBeNull(); const checkoutItems = screen.getAllByRole("menuitem", { name: /Project checkout/u, @@ -596,6 +641,50 @@ describe("EnvironmentPickerUI multi-machine menu", () => { expect(onSelectProvider).toHaveBeenCalledWith(branchProvider, studio.id); }); + it("hides existing ephemeral hosts and keeps their composition entry", () => { + const ephemeralHost: Host = { + ...studio, + id: "host_sandbox", + name: "Modal sandbox 3f9a", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }; + const modalComposition: SystemEnvironmentProvider = { + ...sandboxProvider, + id: "modal-composition", + displayName: "Modal Sandbox", + machineProviderId: "modal-sandbox", + }; + renderPicker( + , + ); + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + + expect(screen.queryByText(ephemeralHost.name)).toBeNull(); + expect( + screen.getByRole("menuitem", { name: /Modal Sandbox/u }), + ).toBeTruthy(); + expect( + screen.getAllByRole("menuitem", { name: /Project checkout/u }), + ).toHaveLength(2); + }); + it("hides a provider on the machine that reports it unavailable", () => { render( { ...devVm, lastRejectedProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION - 1, }; - render( + renderPicker( { it("disables options on an offline machine that has a source", () => { const offlineStudio: Host = { ...studio, status: "disconnected" }; - render( + renderPicker( { expect(checkoutItems[1]!.getAttribute("aria-disabled")).toBe("true"); }); + it("disables options on a machine being removed", () => { + const removingStudio: Host = { + ...studio, + lifecycle: { ...studio.lifecycle, phase: "removing" }, + }; + renderPicker( + , + ); + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + + const checkoutItems = screen.getAllByRole("menuitem", { + name: /Project checkout/u, + }); + expect(checkoutItems[1]!.getAttribute("aria-disabled")).toBe("true"); + }); + it("offers guided setup for a connected machine without a source", () => { const onRequestMachineSetup = vi.fn(); const onlineVm: Host = { ...devVm, status: "connected", lastSeenAt: null }; - render( + renderPicker( { projectCheckout: false, }, }; - render( + renderPicker( { }); it("keeps the disabled not-set-up row for an offline machine", () => { - render( + renderPicker( { it("lists every eligible provider row under each machine", () => { const onSelectProvider = vi.fn(); - render( + renderPicker( { }); it("names the selected machine and provider display name in the trigger label", () => { - render( + renderPicker( { it("reports an offline machine ahead of the provider it was selected on", () => { const offlineStudio: Host = { ...studio, status: "disconnected" }; - render( + renderPicker( { }); it("keeps the single-host menu when only one host exists", () => { - render( + renderPicker( machines === null || machines === undefined ? null - : { ...machines, hosts: selectPersistentHosts(machines.hosts) }, + : { + ...machines, + hosts: selectHosts(machines.hosts).filter( + (machineHost) => machineHost.type === "persistent", + ), + }, [machines], ); - const hostId = host?.id ?? null; + const availableHost = host?.type === "ephemeral" ? null : host; + const hostId = availableHost?.id ?? null; const hasMultipleMachines = (availableMachines?.hosts.length ?? 0) > 1; const environmentProviders = useMemo( () => providers.filter( - (provider) => provider.requires.projectless === projectless, + (provider) => + !provider.machineProviderId && + provider.requires.projectless === projectless, ), [projectless, providers], ); const isMachineMenu = hasMultipleMachines; - const hostConnected = host?.status === "connected"; - const hostUnavailableReason = !host + const hostConnected = availableHost?.status === "connected"; + const hostUnavailableReason = !availableHost ? "No host connected" : !hostConnected ? "Host is offline" @@ -215,15 +224,24 @@ export function EnvironmentPickerUI({ const selectedProvider = useMemo( () => parsed?.type === "provider" - ? environmentProviders.find( + ? providers.find( (provider) => provider.id === parsed.environmentProviderId, ) : undefined, - [environmentProviders, parsed], + [providers, parsed], + ); + const hostlessProviders = providers.filter( + (provider) => + provider.machineProviderId && + provider.requires.projectless === projectless, ); const selected = useMemo((): SelectedEnvironment => { - if (selectedProvider !== undefined && hostUnavailableReason === null) { - const showsHost = selectedMachineName !== null; + if ( + selectedProvider !== undefined && + (selectedProvider.machineProviderId || hostUnavailableReason === null) + ) { + const showsHost = + !selectedProvider.machineProviderId && selectedMachineName !== null; return { modeLabel: showsHost ? `${selectedMachineName} · ${selectedProvider.displayName}` @@ -237,7 +255,7 @@ export function EnvironmentPickerUI({ modeLabel: selectedMachineName ? `${selectedMachineName} · ${hostUnavailableReason}` : hostUnavailableReason, - compactModeLabel: host ? "Offline" : "No host", + compactModeLabel: availableHost ? "Offline" : "No host", icon: "AlertTriangle" as const, }; } @@ -256,7 +274,7 @@ export function EnvironmentPickerUI({ }, [ parsed, hostUnavailableReason, - host, + availableHost, selectedMachineName, selectedProvider, ]); @@ -353,7 +371,7 @@ export function EnvironmentPickerUI({ sources={sources} value={value} onRequestMachineSetup={onRequestMachineSetup} - machineProviders={environmentProviders} + environmentProviders={environmentProviders} providersByHostId={providersByHostId} selectedProviderHostId={selectedProviderHostId} inputsControlProviderIds={inputsControlProviderIds} @@ -362,10 +380,10 @@ export function EnvironmentPickerUI({ ) : ( )} + {!isLoading && + onSelectProvider && + hostlessProviders.length > 0 && + environmentProviders.length > 0 ? ( + + ) : null} + {!isLoading && onSelectProvider + ? hostlessProviders.map((provider) => ( + onSelectProvider(provider, null)} + /> + )) + : null} ); @@ -418,7 +462,7 @@ interface EnvironmentOptionsSectionProps { hostName: string | null; hostUnavailableReason: string | null; value: string; - machineProviders: readonly SystemEnvironmentProvider[]; + environmentProviders: readonly SystemEnvironmentProvider[]; selectedProviderHostId: string | null; inputsControlProviderIds: ReadonlySet; onSelectProvider: @@ -431,7 +475,7 @@ function EnvironmentOptionsSection({ hostName, hostUnavailableReason, value, - machineProviders, + environmentProviders, selectedProviderHostId, inputsControlProviderIds, onSelectProvider, @@ -451,7 +495,7 @@ function EnvironmentOptionsSection({ {hostUnavailableReason} ) : onSelectProvider !== undefined && hostId !== null ? ( - machineProviders.map((provider) => { + environmentProviders.map((provider) => { const disabledReason = providerDisabledReason( provider, inputsControlProviderIds, @@ -488,8 +532,8 @@ interface MachineGroupedEnvironmentOptionsProps { sources: readonly ProjectSource[]; value: string; onRequestMachineSetup: ((host: Host) => void) | undefined; - machineProviders: readonly SystemEnvironmentProvider[]; - providersByHostId: EnvironmentPickerUIProps["providersByHostId"]; + environmentProviders: readonly SystemEnvironmentProvider[]; + providersByHostId?: EnvironmentPickerUIProps["providersByHostId"]; selectedProviderHostId: string | null; inputsControlProviderIds: ReadonlySet; onSelectProvider: @@ -502,7 +546,7 @@ function MachineGroupedEnvironmentOptions({ sources, value, onRequestMachineSetup, - machineProviders, + environmentProviders, providersByHostId, selectedProviderHostId, inputsControlProviderIds, @@ -527,8 +571,8 @@ function MachineGroupedEnvironmentOptions({ now={now} value={value} onRequestMachineSetup={onRequestMachineSetup} - machineProviders={scopedProviders( - machineProviders, + environmentProviders={scopedProviders( + environmentProviders, providersByHostId, machineHost.id, { value, selectedProviderHostId }, @@ -549,7 +593,7 @@ interface MachineSectionProps { now: number; value: string; onRequestMachineSetup: ((host: Host) => void) | undefined; - machineProviders: readonly SystemEnvironmentProvider[]; + environmentProviders: readonly SystemEnvironmentProvider[]; selectedProviderHostId: string | null; inputsControlProviderIds: ReadonlySet; onSelectProvider: @@ -564,19 +608,20 @@ function MachineSection({ now, value, onRequestMachineSetup, - machineProviders, + environmentProviders, selectedProviderHostId, inputsControlProviderIds, onSelectProvider, }: MachineSectionProps) { const connected = host.status === "connected"; - const hostProviders = machineProviders; + const hostProviders = environmentProviders; + const selectable = connected && host.lifecycle.phase !== "removing"; return ( - {host.name} + {isThisMachine ? ( this machine ) : null} @@ -613,7 +658,7 @@ function MachineSection({ providerValueSelected(value, provider) && selectedProviderHostId === host.id } - disabled={!connected || disabledReason !== null} + disabled={!selectable || disabledReason !== null} onSelect={() => onSelectProvider(provider, host.id)} /> ); diff --git a/apps/app/src/components/pickers/MachinePicker.test.tsx b/apps/app/src/components/pickers/MachinePicker.test.tsx index f5df74bb56..0c6a245c41 100644 --- a/apps/app/src/components/pickers/MachinePicker.test.tsx +++ b/apps/app/src/components/pickers/MachinePicker.test.tsx @@ -5,6 +5,7 @@ import type { Host } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MachinePickerUI } from "./MachinePicker"; +import type { MachineProviderPresentation } from "@/components/plugin/MachineProviderIcon"; const HOUR_MS = 60 * 60 * 1000; @@ -34,6 +35,7 @@ function renderMachineMenu(overrides?: { hosts?: readonly Host[]; selectedHostId?: string | null; onChange?: (hostId: string) => void; + machineProviders?: readonly MachineProviderPresentation[]; }) { render( , ); fireEvent.pointerDown(screen.getByRole("button", { name: "Machine" }), { @@ -102,4 +105,31 @@ describe("MachinePickerUI", () => { screen.getByRole("button", { name: "Machine" }).textContent, ).toContain("MacBook Pro"); }); + + it("includes provider-made hosts in machine pickers", () => { + const modalHost = makeHost({ + id: "host_modal", + name: "Modal sandbox 3f9a", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }); + renderMachineMenu({ + hosts: [thisMachine, studio, modalHost], + selectedHostId: modalHost.id, + machineProviders: [ + { + id: "modal-sandbox", + displayName: "Modal Sandbox", + icon: "Cloud", + logoUrl: null, + }, + ], + }); + + expect(screen.getAllByText("Modal sandbox 3f9a")).toHaveLength(2); + const trigger = screen.getByRole("button", { name: "Machine" }); + expect(trigger.querySelector('[data-icon="Cloud"]')).not.toBeNull(); + expect(trigger.querySelector('[data-icon="Laptop"]')).toBeNull(); + expect(screen.queryByText("Modal Sandbox")).toBeNull(); + }); }); diff --git a/apps/app/src/components/pickers/MachinePicker.tsx b/apps/app/src/components/pickers/MachinePicker.tsx index 4750e5b30a..47e070a634 100644 --- a/apps/app/src/components/pickers/MachinePicker.tsx +++ b/apps/app/src/components/pickers/MachinePicker.tsx @@ -1,4 +1,3 @@ -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useMemo } from "react"; import type { Host } from "@bb/domain"; import { Icon } from "@bb/shared-ui/icon"; @@ -15,13 +14,16 @@ import { COARSE_POINTER_ICON_SIZE_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; -import { - selectPersistentHosts, - selectPrimaryHost, -} from "@/hooks/queries/host-queries"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; +import { selectHosts, selectPrimaryHost } from "@/hooks/queries/host-queries"; import { formatRelativeTime } from "@/lib/relative-time"; import { cn } from "@bb/shared-ui/lib/utils"; import { formatHostUpdateStatus } from "@/lib/host-update-status"; +import { + MachineLabel, + type MachineLabelHost, +} from "@/components/machines/MachineLabel"; +import type { MachineProviderPresentation } from "@/components/plugin/MachineProviderIcon"; import { OPTION_BASE_CLASS_NAME, OPTION_INTERACTIVE_CLASS_NAME, @@ -43,6 +45,7 @@ interface MachinePickerUIProps { disabled?: boolean; className?: string; modal?: boolean; + machineProviders?: readonly MachineProviderPresentation[]; } export function MachinePickerUI({ @@ -55,8 +58,9 @@ export function MachinePickerUI({ disabled = false, className, modal, + machineProviders = [], }: MachinePickerUIProps) { - const availableHosts = useMemo(() => selectPersistentHosts(hosts), [hosts]); + const availableHosts = useMemo(() => selectHosts(hosts), [hosts]); const selectedHost = useMemo( () => availableHosts.find((host) => host.id === selectedHostId) ?? @@ -94,13 +98,18 @@ export function MachinePickerUI({ )} > - - - {selectedHost?.name ?? "Machine"} - + {selectedHost == null ? ( + Machine + ) : ( + + )} {disabled ? null : ( - {host.name} + {host.id === localDaemonHostId ? ( this machine ) : null} @@ -165,3 +179,15 @@ export function MachinePickerUI({ ); } + +function findMachineProvider( + host: MachineLabelHost, + machineProviders: readonly MachineProviderPresentation[], +): MachineProviderPresentation | null { + if (host.machineProviderId === null) return null; + return ( + machineProviders.find( + (provider) => provider.id === host.machineProviderId, + ) ?? null + ); +} diff --git a/apps/app/src/components/pickers/ReuseEnvironmentPicker.test.ts b/apps/app/src/components/pickers/ReuseEnvironmentPicker.test.ts index 2d2a999626..9b63d4a3bb 100644 --- a/apps/app/src/components/pickers/ReuseEnvironmentPicker.test.ts +++ b/apps/app/src/components/pickers/ReuseEnvironmentPicker.test.ts @@ -6,6 +6,7 @@ import { } from "./ReuseEnvironmentPicker"; const provider: SystemEnvironmentProvider = { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", icon: "Laptop", diff --git a/apps/app/src/components/pickers/machine-provider-inputs.tsx b/apps/app/src/components/pickers/machine-provider-inputs.tsx new file mode 100644 index 0000000000..73143b8dda --- /dev/null +++ b/apps/app/src/components/pickers/machine-provider-inputs.tsx @@ -0,0 +1,137 @@ +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { + usePluginSlots, + type PluginMachineProviderInputsSlot, +} from "@/lib/plugin-slots"; +import type { JsonValue } from "@bb/domain"; +import type { PluginMachineProviderInputsChange } from "@get-bb/plugin-sdk"; +import { useCallback, useState, type ReactNode } from "react"; + +export interface MachineProviderInputsDescriptor { + id: string; + displayName: string; + pluginId: string; + inputs: JsonValue | null; + acceptsEmptyInputs: boolean; +} + +interface ScopedMachineProviderInputsState { + scopeKey: string; + value: JsonValue | null; + blockedReason: string | null; +} + +const EMPTY_MACHINE_PROVIDER_INPUTS: JsonValue = {}; + +function findRegistration( + provider: MachineProviderInputsDescriptor | null, + slots: readonly PluginMachineProviderInputsSlot[], +): PluginMachineProviderInputsSlot | undefined { + if (provider === null || provider.inputs === null) return undefined; + return slots.find( + (slot) => + slot.machineProviderId === provider.id && + slot.pluginId === provider.pluginId, + ); +} + +export function useMachineProviderInputs({ + provider, + initialValue, + instanceId, +}: { + provider: MachineProviderInputsDescriptor | null; + initialValue?: JsonValue | null; + instanceId: string; +}): { + value: JsonValue | null; + blockedReason: string | null; + control: ReactNode; +} { + const slots = usePluginSlots().machineProviderInputs; + const registration = findRegistration(provider, slots); + const scopeKey = + provider === null + ? "" + : `${provider.pluginId}\0${provider.id}\0${JSON.stringify(initialValue ?? null)}`; + const [state, setState] = useState( + null, + ); + const activeState = state?.scopeKey === scopeKey ? state : null; + const defaultValue = + provider?.inputs === null || provider === null + ? null + : initialValue !== undefined && initialValue !== null + ? initialValue + : provider.acceptsEmptyInputs + ? EMPTY_MACHINE_PROVIDER_INPUTS + : null; + const value = activeState?.value ?? defaultValue; + const reportCrash = useCallback(() => { + if (provider === null) return; + setState({ + scopeKey, + value, + blockedReason: `${provider.displayName} input control crashed.`, + }); + }, [provider, scopeKey, value]); + const handleChange = useCallback( + (next: PluginMachineProviderInputsChange) => { + setState((current) => { + const currentValue = + current?.scopeKey === scopeKey ? current.value : defaultValue; + if (next.status === "blocked") { + if ( + current?.scopeKey === scopeKey && + current.blockedReason === next.reason + ) { + return current; + } + return { + scopeKey, + value: currentValue, + blockedReason: next.reason, + }; + } + if ( + current?.scopeKey === scopeKey && + current.blockedReason === null && + JSON.stringify(current.value) === JSON.stringify(next.value) + ) { + return current; + } + return { scopeKey, value: next.value, blockedReason: null }; + }); + }, + [defaultValue, scopeKey], + ); + const blockedReason = + provider === null || provider.inputs === null + ? null + : activeState?.blockedReason !== undefined + ? activeState.blockedReason + : registration === undefined && !provider.acceptsEmptyInputs + ? `${provider.displayName} needs its plugin's control.` + : value === null + ? `Configure ${provider.displayName}.` + : null; + if (provider === null || registration === undefined) { + return { value, blockedReason, control: null }; + } + const InputsComponent = registration.component; + return { + value, + blockedReason, + control: ( + + + + ), + }; +} diff --git a/apps/app/src/components/plugin/EnvironmentProviderIcon.test.tsx b/apps/app/src/components/plugin/EnvironmentProviderIcon.test.tsx index b15b810785..f6def5daee 100644 --- a/apps/app/src/components/plugin/EnvironmentProviderIcon.test.tsx +++ b/apps/app/src/components/plugin/EnvironmentProviderIcon.test.tsx @@ -11,6 +11,7 @@ import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; import { EnvironmentProviderIcon } from "./EnvironmentProviderIcon"; const provider: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", pluginId: "environment-git-worktree", acceptsEmptyInputs: true, diff --git a/apps/app/src/components/plugin/MachineProviderIcon.tsx b/apps/app/src/components/plugin/MachineProviderIcon.tsx new file mode 100644 index 0000000000..223d9f4d81 --- /dev/null +++ b/apps/app/src/components/plugin/MachineProviderIcon.tsx @@ -0,0 +1,30 @@ +import { Icon } from "@bb/shared-ui/icon"; +import { getProviderIconInfo } from "@/lib/provider-icon"; +import { pluginIconName } from "./PluginIcon"; + +export interface MachineProviderPresentation { + id: string; + displayName: string; + icon: string; + logoUrl: string | null; +} + +export function MachineProviderIcon({ + provider, + className, +}: { + provider: MachineProviderPresentation; + className?: string; +}) { + const info = getProviderIconInfo(provider.id, { + logoUrl: provider.logoUrl, + displayName: provider.displayName, + icon: { glyph: provider.icon }, + }); + const ProviderIcon = info?.icon; + return ProviderIcon === undefined ? ( + + ) : ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index 218927b9aa..7241e35cb8 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -66,6 +66,8 @@ const mocks = vi.hoisted(() => ({ promptHistoryQueryOptions: [] as Array<{ enabled?: boolean } | undefined>, environmentProviders: [] as unknown[], closeTerminal: vi.fn(), + plugins: [] as unknown[], + serverAccessReady: true, })); vi.mock("@/views/RootComposePanelCommandHandlers", () => ({ @@ -109,6 +111,7 @@ vi.mock("@/components/promptbox/NewThreadPromptBox", () => ({ mocks.promptBoxProps.push(props); return (
+ {props.modeConfig?.banner ?? null} {props.modeConfig?.environmentProviderInputsSlot ?? null}
); @@ -121,6 +124,14 @@ vi.mock("@/hooks/queries/environment-provider-queries", () => ({ }), })); +vi.mock("@/hooks/queries/machine-provider-queries", () => ({ + useSystemMachineProviders: () => ({ providers: [] }), +})); + +vi.mock("@/hooks/queries/plugin-settings-queries", () => ({ + usePluginList: () => ({ data: { plugins: mocks.plugins } }), +})); + vi.mock("@/lib/sdk", () => ({ sdk: { projects: { attachments: { copy: mocks.copyAttachments } } }, })); @@ -205,7 +216,7 @@ vi.mock("@/hooks/queries/host-queries", () => ({ { id: "host_2", name: "Other machine" }, ], }), - selectPersistentHosts: (hosts: T[] | undefined) => hosts ?? [], + selectHosts: (hosts: T[] | undefined) => hosts ?? [], selectPrimaryHost: ( hosts: Array<{ id: string }> | undefined, primaryHostId: string | null, @@ -218,7 +229,26 @@ vi.mock("@/hooks/queries/system-queries", () => ({ useKnownProviderModelCatalogScope: () => undefined, useHostProviderCliStatus: () => ({ data: undefined }), useSystemConfig: () => ({ - data: { primaryHostId: "host_1", generalSettings: defaultAppSettings }, + data: { + primaryHostId: "host_1", + generalSettings: defaultAppSettings, + serverAccess: { + providers: [ + { + id: "connect", + displayName: "bb connect", + description: "Use a private getbb.app address.", + pluginId: "connect", + availability: mocks.serverAccessReady + ? { status: "available", serverUrl: "https://sawyer.getbb.app" } + : { status: "setup-required", message: "Pair with bb connect" }, + }, + ], + defaultProviderId: "connect", + effectiveUrl: "https://sawyer.getbb.app", + urlSource: null, + }, + }, }), useSystemExecutionOptions: () => ({ data: { @@ -449,6 +479,7 @@ const BRANCH_INPUTS_SCHEMA = { }; const CHECKOUT_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", icon: "Laptop", @@ -470,6 +501,7 @@ const CHECKOUT_PROVIDER: SystemEnvironmentProvider = { }; const PERSONAL_WORKSPACE_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", icon: "Folder", @@ -488,6 +520,7 @@ const PERSONAL_WORKSPACE_PROVIDER: SystemEnvironmentProvider = { }; const MANAGED_WORKTREE_SUGAR_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", icon: "GitBranch", @@ -612,6 +645,8 @@ describe("PluginNewThreadComposer seeding", () => { mocks.sidebarNavigationSettled = true; mocks.sidebarNavigationReplayed = false; mocks.extraProjects = []; + mocks.plugins = []; + mocks.serverAccessReady = true; mocks.environmentProviders = [ CHECKOUT_PROVIDER, MANAGED_WORKTREE_SUGAR_PROVIDER, @@ -1638,6 +1673,7 @@ describe("PluginNewThreadComposer seeding", () => { }); const SANDBOX_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "container", displayName: "Docker container", icon: "Container", @@ -1660,6 +1696,7 @@ const SANDBOX_PROVIDER: SystemEnvironmentProvider = { }; const OPTIONAL_INPUTS_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "optional-sandbox", displayName: "Optional sandbox", icon: "Container", @@ -1681,6 +1718,7 @@ const OPTIONAL_INPUTS_PROVIDER: SystemEnvironmentProvider = { }; const BRANCH_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "branchy", displayName: "New branch workspace", icon: "GitBranch", @@ -1699,6 +1737,7 @@ const BRANCH_PROVIDER: SystemEnvironmentProvider = { }; const HOST_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "hosted", displayName: "Machine sandbox", icon: "Server", @@ -1730,6 +1769,8 @@ describe("NewThreadComposer environment providers", () => { mocks.sidebarNavigationSettled = true; mocks.sidebarNavigationReplayed = false; mocks.extraProjects = []; + mocks.plugins = []; + mocks.serverAccessReady = true; mocks.environmentProviders = [CHECKOUT_PROVIDER]; resetPluginSlotStoreForTest(); registerCheckoutInputsControl(); @@ -1771,10 +1812,11 @@ describe("NewThreadComposer environment providers", () => { environmentProviderInputs: [ { environmentProviderId: "container", - component: ({ onChange }) => ( + component: ({ target, onChange }) => ( + } + /> + ) : setupRequiredProvider === null ? null : ( + + navigate( + getPluginConfigurationRoutePath({ + pluginId: setupRequiredProvider.pluginId, + }), + ) + } + > + Configure {setupRequiredProvider.displayName} + + } + /> + )), header: options.header, }} project={{ @@ -1546,6 +1709,7 @@ export function NewThreadComposer({ projectOptions, projectSources, promptActions, + promptBoxFocusRequest, promptDraft, promptHistoryDrafts, promptMentions, @@ -1563,8 +1727,12 @@ export function NewThreadComposer({ supportsPermissionModeSelection, supportsServiceTier, submitDisabledReason, + machineServerAccessReason, + setupRequiredProvider, + environmentSetupRequiredReason, + navigate, environmentProviderInputsSlot, - environmentProvidersByHostId, + machineProviderInputs.control, inputsControlProviderIds, providerHostId, textEffects, @@ -1572,30 +1740,35 @@ export function NewThreadComposer({ ], ); - return children({ - projectId, - isProjectless, - projects, - sidebarNavigation: sidebarNavigationQuery.data, - sidebarNavigationError: sidebarNavigationQuery.isError, - currentProject, - projectSources, - connectedHostIds, - primaryHostId, - parsedEnvironment, - projectHostId, - panelThreadId, - selectedProviderId, - promptDraft, - promptBoxRef, - pluginComposerHost, - textEffects, - isSubmitting, - seedEnvironmentSelectionValue: setCreationEnvironmentSelectionValue, - setEnvironmentSelectionValue: changeEnvironment, - setProviderModelReasoning, - setPermissionMode, - setServiceTier, - renderPromptBox, - }); + return ( + + ); } diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx index abf39f0a82..99b956f76c 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx @@ -5,7 +5,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { Host } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { EnvironmentSlot, ProjectlessMachineSlot } from "./NewThreadPromptBox"; const host = makeHost({ @@ -26,6 +29,7 @@ describe("ProjectlessMachineSlot", () => { }; const personalWorkspaceProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", icon: "Folder", @@ -54,6 +58,7 @@ describe("ProjectlessMachineSlot", () => { localDaemonHostId: string | null; primaryHostId: string | null; } | null; + machineProviders?: readonly SystemMachineProvider[]; }) { return { value: "provider:personal-workspace", @@ -70,6 +75,7 @@ describe("ProjectlessMachineSlot", () => { primaryHostId: host.id, }, providers: [personalWorkspaceProvider], + machineProviders: overrides?.machineProviders, selectedProviderHostId: overrides?.selectedProviderHostId ?? host.id, onSelectProvider: overrides?.onSelectProvider ?? vi.fn(), }; @@ -102,25 +108,43 @@ describe("ProjectlessMachineSlot", () => { }); it("counts provider-made machines in the projectless machine chip", () => { + const modalHost = makeHost({ + id: "host_modal", + name: "Modal sandbox 3f9a", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }); render( , ); - expect(screen.getByRole("button", { name: "Machine" })).toBeTruthy(); + const chip = screen.getByRole("button", { name: "Machine" }); + expect(chip.querySelector('[data-icon="Cloud"]')).not.toBeNull(); + expect(chip.querySelector('[data-icon="Laptop"]')).toBeNull(); + expect(chip.textContent).toContain(modalHost.name); + expect(chip.textContent).not.toContain("Modal Sandbox"); }); it("names the selected machine in the chip", () => { @@ -168,6 +192,7 @@ describe("EnvironmentSlot", () => { }; const personalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", icon: "Folder", @@ -186,6 +211,7 @@ describe("EnvironmentSlot", () => { }; const sandboxProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "modal-sandbox", displayName: "Modal sandbox", icon: "Cloud", @@ -203,10 +229,23 @@ describe("EnvironmentSlot", () => { inputs: null, }; + const modalMachineProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + description: "Run a machine for development.", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + }; + function makeEnvironment(overrides: { isLoading?: boolean; value?: string; providers?: readonly SystemEnvironmentProvider[]; + machineProviders?: readonly SystemMachineProvider[]; onSelectProvider?: ( provider: SystemEnvironmentProvider, hostId: string | null, @@ -227,6 +266,7 @@ describe("EnvironmentSlot", () => { providers: overrides.providers ?? [personalProvider], selectedProviderHostId: host.id, onSelectProvider: overrides.onSelectProvider ?? vi.fn(), + machineProviders: overrides.machineProviders, }; } @@ -302,6 +342,21 @@ describe("EnvironmentSlot", () => { expect(screen.queryByText("Modal sandbox")).toBeNull(); }); + it("keeps the machine slot when only one environment is available", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Machine" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Environment" })).toBeNull(); + }); + it("shows the reused environment instead of the machine slot when a thread reuses one", () => { render( diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index e479a9ad7d..c7e91a8b71 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, + useEffect, useImperativeHandle, useMemo, useRef, @@ -10,7 +11,10 @@ import { type RefObject, } from "react"; import type { Host, ProjectSource, PromptTextMention } from "@bb/domain"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import type { ComposerView } from "@get-bb/plugin-sdk"; import type { ComposerTextEffectSource } from "@/lib/composer-text-effects"; import { ComposerBannersSlot } from "@/components/plugin/PluginComposerBanners"; @@ -56,11 +60,12 @@ import { type ReuseThreadOption, } from "@/components/pickers/ReuseEnvironmentPicker"; import { - selectPersistentHosts, + selectHosts, selectPrimaryHost, useHosts, } from "@/hooks/queries/host-queries"; import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { isPlanModePrompt, @@ -84,7 +89,7 @@ export interface NewThreadEnvironmentConfig { disabled?: boolean; isLoading?: boolean; providers?: readonly SystemEnvironmentProvider[]; - providersByHostId?: EnvironmentPickerUIProps["providersByHostId"]; + machineProviders?: readonly SystemMachineProvider[]; selectedProviderHostId?: string | null; inputsControlProviderIds?: ReadonlySet; onSelectProvider?: EnvironmentPickerUIProps["onSelectProvider"]; @@ -113,6 +118,7 @@ export interface NewThreadModeConfig { worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -125,6 +131,7 @@ interface NewThreadPromptBoxUIProps { onChange: (value: string, mentionRanges: PromptTextMention[]) => void; onSubmit: () => void; promptBoxRef?: Ref; + focusRequest?: string; isSubmitting: boolean; disabled: boolean; disabledReason?: string; @@ -158,6 +165,7 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ onChange, onSubmit, promptBoxRef: externalPromptBoxRef, + focusRequest, isSubmitting, disabled, disabledReason, @@ -175,6 +183,10 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ execution, }: NewThreadPromptBoxUIProps) { const promptBoxRef = useRef(null); + useEffect(() => { + if (focusRequest === undefined) return; + promptBoxRef.current?.focusEnd(); + }, [focusRequest]); const isFocusedPane = useOptionalPaneContext()?.isFocused ?? true; const focusDefault = useCallback(() => { promptBoxRef.current?.focusEnd(); @@ -376,6 +388,7 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ environmentProviderInputsSlot={ modeConfig.environmentProviderInputsSlot } + machineProviderInputsSlot={modeConfig.machineProviderInputsSlot} />
@@ -399,6 +412,7 @@ interface EnvironmentSlotProps { environment: NewThreadEnvironmentConfig; worktree: NewThreadWorktreeConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; } export function EnvironmentSlot({ @@ -406,6 +420,7 @@ export function EnvironmentSlot({ environment, worktree, environmentProviderInputsSlot, + machineProviderInputsSlot, }: EnvironmentSlotProps) { const providers = (environment.providers ?? []).filter( (provider) => provider.requires.projectless === projectless, @@ -427,6 +442,7 @@ export function EnvironmentSlot({ environment.isLoading || providers.length > 1 || showReuseEnvironmentPicker || + selectedProvider?.machineInputs != null || environmentPickerOpen; if (!showEnvironmentPicker) { return ; @@ -449,7 +465,6 @@ export function EnvironmentSlot({ disabled={environment.disabled} isLoading={environment.isLoading} providers={providers} - providersByHostId={environment.providersByHostId} selectedProviderHostId={environment.selectedProviderHostId} inputsControlProviderIds={environment.inputsControlProviderIds} onSelectProvider={environment.onSelectProvider} @@ -465,6 +480,10 @@ export function EnvironmentSlot({ disabled={worktree.disabled} /> ) : null} + {selectedProvider?.machineInputs !== undefined && + selectedProvider.machineInputs !== null + ? machineProviderInputsSlot + : null} {selectedProvider !== undefined && selectedProvider.inputs !== null ? environmentProviderInputsSlot : null} @@ -481,7 +500,7 @@ export function ProjectlessMachineSlot({ }: ProjectlessMachineSlotProps) { const machines = environment.machines ?? null; const availableHosts = useMemo( - () => selectPersistentHosts(machines?.hosts), + () => selectHosts(machines?.hosts), [machines?.hosts], ); const parsedEnvironment = useMemo( @@ -506,7 +525,11 @@ export function ProjectlessMachineSlot({ }, [handleSelectProvider, selectedProvider], ); - if (!machines || availableHosts.length <= 1) { + if ( + selectedProvider?.machineProviderId || + !machines || + availableHosts.length <= 1 + ) { return null; } return ( @@ -523,6 +546,7 @@ export function ProjectlessMachineSlot({ disabled={environment.disabled} className="shrink-0" muted + machineProviders={environment.machineProviders} /> ); } @@ -537,6 +561,7 @@ interface NewThreadConnectedModeConfig { worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -554,8 +579,9 @@ export function NewThreadPromptBox({ }: NewThreadPromptBoxProps) { const { data: hosts } = useHosts(); const systemConfigQuery = useSystemConfig(); + const { providers: machineProviders } = useSystemMachineProviders(); const primaryHostId = systemConfigQuery.data?.primaryHostId ?? null; - const availableHosts = useMemo(() => selectPersistentHosts(hosts), [hosts]); + const availableHosts = useMemo(() => selectHosts(hosts), [hosts]); const primaryHost = useMemo( () => selectPrimaryHost(availableHosts, primaryHostId), [availableHosts, primaryHostId], @@ -586,11 +612,19 @@ export function NewThreadPromptBox({ const uiEnvironment = useMemo( () => ({ ...threadConfig.environment, + machineProviders: + threadConfig.environment.machineProviders ?? machineProviders, host: selectedHost, isLocal: isLocalHost, machines, }), - [threadConfig.environment, selectedHost, isLocalHost, machines], + [ + threadConfig.environment, + machineProviders, + selectedHost, + isLocalHost, + machines, + ], ); return ( { + it("shows the laptop icon and host name for a persistent machine", () => { + const { container } = render( + + + , + ); + + expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect(container.querySelector('[data-icon="Laptop"]')).not.toBeNull(); + }); + + it("shows the provider icon and host name for an ephemeral machine", () => { + const { container } = render( + + + , + ); + + expect(screen.getByText("Modal sandbox ugxe6e")).toBeTruthy(); + expect(container.querySelector('[data-icon="Cloud"]')).not.toBeNull(); + expect(container.querySelector('[data-icon="Laptop"]')).toBeNull(); + }); + it("uses a host-free environment label in compact prompt boxes", () => { const { container } = render( diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx index f1fd8eb231..2db23bb1ed 100644 --- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx +++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx @@ -6,6 +6,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { cn } from "@bb/shared-ui/lib/utils"; import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; +import { + MachineLabel, + type MachineLabelHost, +} from "@/components/machines/MachineLabel"; +import type { MachineProviderPresentation } from "@/components/plugin/MachineProviderIcon"; const CHECKOUT_CHIP_BASE_CLASS_NAME = "flex min-w-0 flex-1 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted-foreground"; @@ -17,6 +22,8 @@ interface ThreadEnvironmentSummaryProps { environmentCompactLabel?: string; environmentIcon?: IconName; environmentTypeLabel?: string; + environmentHost?: MachineLabelHost; + environmentMachineProvider?: MachineProviderPresentation | null; environmentCheckout?: WorkspaceCheckoutDisplay; onCreateNewThreadInEnvironment?: () => void; } @@ -27,12 +34,15 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ environmentCompactLabel, environmentIcon, environmentTypeLabel, + environmentHost, + environmentMachineProvider, environmentCheckout, onCreateNewThreadInEnvironment, }: ThreadEnvironmentSummaryProps) { if ( !projectName && !environmentLabel && + !environmentHost && !environmentCheckout && !onCreateNewThreadInEnvironment ) { @@ -53,7 +63,14 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ muted /> ) : null} - {environmentLabel ? ( + {environmentHost ? ( + + ) : environmentLabel ? (
{environmentIcon && environmentTypeLabel ? ( diff --git a/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.tsx b/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.tsx index ed5bc3cd80..a09664c2d9 100644 --- a/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.tsx +++ b/apps/app/src/components/promptbox/banner/ProviderCliVersionBanner.tsx @@ -1,6 +1,6 @@ import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; -import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; +import { ProviderRequirementBanner } from "./ProviderRequirementBanner"; interface ProviderCliVersionBannerProps { displayName: string; @@ -36,30 +36,16 @@ export function ProviderCliVersionBanner({ onUpdate, }: ProviderCliVersionBannerProps) { return ( - -
- - - -
-

- {displayName} update required -

-

- Update {displayName} before starting a thread.{" "} - {versionRequirementCopy(currentVersion, minimumSupportedVersion)} -

-
- {canUpdate ? ( + + Update {displayName} before starting a thread.{" "} + {versionRequirementCopy(currentVersion, minimumSupportedVersion)} + + } + action={ + canUpdate ? (
-
+ ) : null + } + /> ); } diff --git a/apps/app/src/components/promptbox/banner/ProviderRequirementBanner.stories.tsx b/apps/app/src/components/promptbox/banner/ProviderRequirementBanner.stories.tsx new file mode 100644 index 0000000000..82f059ba6e --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ProviderRequirementBanner.stories.tsx @@ -0,0 +1,192 @@ +import type { ReactNode } from "react"; +import { Button } from "@bb/shared-ui/button"; +import { StoryCard, StoryRow } from "../../../../.ladle/story-card"; +import { ProviderCliVersionBanner } from "./ProviderCliVersionBanner"; +import { ProviderRequirementBanner } from "./ProviderRequirementBanner"; +import { + MACHINE_SERVER_ACCESS_TITLE, + machineServerAccessBlockedReason, +} from "@/components/machines/machine-server-access"; +import { CONNECT_UNPAIRED } from "../../../../.ladle/machine-story-fixtures"; + +export default { + title: "promptbox/banner/Provider Requirement", +}; + +const noop = () => {}; + +function Stage({ + children, + size, +}: { + children: ReactNode; + size: "desktop" | "mobile"; +}) { + return ( +
+ {children} +
+ ); +} + +function ResponsiveStage({ children }: { children: ReactNode }) { + return ( +
+ {children} + {children} +
+ ); +} + +function configureAction(displayName: string) { + return ( + + ); +} + +export function Requirements() { + return ( + + + + + + + + + + Set up machine access + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/promptbox/banner/ProviderRequirementBanner.tsx b/apps/app/src/components/promptbox/banner/ProviderRequirementBanner.tsx new file mode 100644 index 0000000000..f7cd91bc65 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ProviderRequirementBanner.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; +import { Icon } from "@bb/shared-ui/icon"; +import { PromptStackCard } from "./PromptStackCard"; + +export function ProviderRequirementBanner({ + title, + description, + action, +}: { + title: string; + description: ReactNode; + action: ReactNode; +}) { + return ( + +
+
+ +
+

{title}

+

+ {description} +

+
+
+ {action ? ( +
+ {action} +
+ ) : null} +
+
+ ); +} diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx index 56bb4113d0..c49d7bd20d 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx @@ -1837,7 +1837,7 @@ describe("queued row affordances", () => { waitingOn: { kind: "host-offline", hostName: "M4" }, }, ]); - expect(getByText("Waiting for M4 to reconnect")).toBeDefined(); + expect(getByText("Waiting for M4 to be ready")).toBeDefined(); expect(queryByLabelText("Send queued message 1 now")).toBeNull(); }); diff --git a/apps/app/src/components/promptbox/banner/ThreadMachineStatus.stories.tsx b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.stories.tsx new file mode 100644 index 0000000000..b346ed5fd3 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.stories.tsx @@ -0,0 +1,122 @@ +import type { SystemMachineProvider } from "@bb/server-contract"; +import modalLogoUrl from "../../../../../../plugins/environment-modal-sandbox/modal-logo.svg?url"; +import { useState, type ReactNode } from "react"; +import { StoryCard, StoryRow } from "../../../../.ladle/story-card"; +import { ThreadMachineStatusBanner } from "./ThreadMachineStatus"; + +export default { + title: "promptbox/banner/Machine Status", +}; + +const noop = () => {}; + +const modalProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal Sandbox", + description: "Run a machine for development.", + icon: "./modal-logo.svg", + logoUrl: modalLogoUrl, + pluginId: "environment-modal-sandbox", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, +}; + +function ResponsiveStage({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ {children} +
+
+ ); +} + +function PausedMachine({ hostName }: { hostName: string }) { + const [resuming, setResuming] = useState(false); + return ( + setResuming(true)} + /> + ); +} + +export function States() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx new file mode 100644 index 0000000000..4cb5b47d18 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ThreadMachineStatusBanner } from "./ThreadMachineStatus"; + +const provider = { + id: "modal-sandbox", + displayName: "Modal Sandbox", + description: "Run a machine for development.", + icon: "Cloud", + logoUrl: null, + pluginId: "environment-modal-sandbox", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, +}; + +describe("ThreadMachineStatusBanner", () => { + it("names the paused state generically and keeps the provider icon", () => { + const { container } = render( + , + ); + + expect(screen.getByRole("status").textContent).toBe("Machine is paused"); + expect(container.querySelector('[data-icon="Cloud"]')).not.toBeNull(); + expect(screen.queryByText("Modal sandbox ugxe6e")).toBeNull(); + expect(screen.queryByText(provider.displayName)).toBeNull(); + }); +}); diff --git a/apps/app/src/components/promptbox/banner/ThreadMachineStatus.tsx b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.tsx new file mode 100644 index 0000000000..6e00b6fbf2 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import { Icon } from "@bb/shared-ui/icon"; +import { AnimatedBody } from "./AnimatedBody"; +import { useHosts } from "@/hooks/queries/host-queries"; +import { useResumeHost } from "@/hooks/mutations/host-mutations"; +import type { SystemMachineProvider } from "@bb/server-contract"; +import { + MachineIcon, + type MachineLabelHost, +} from "@/components/machines/MachineLabel"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + BannerActionSlot, + PromptBannerActionButton, +} from "./prompt-banner-actions"; +import { + PromptStackCard, + PROMPT_STACK_CARD_ROW_HEIGHT, + PROMPT_STACK_INLAY_INSET_CLASS, + PROMPT_STACK_INLAY_SEGMENT_CLASS, +} from "./PromptStackCard"; +import { getMutationErrorMessage } from "@/lib/mutation-errors"; + +const MACHINE_STATUS_TOGGLE_ID = "machine-status-toggle"; +const MACHINE_STATUS_BODY_ID = "machine-status-body"; + +export function ThreadMachineStatus({ hostId }: { hostId: string }) { + const hosts = useHosts(); + const { providers } = useSystemMachineProviders(); + const resume = useResumeHost(); + const host = hosts.data?.find((candidate) => candidate.id === hostId); + if (!host || host.machineProviderId === null) return null; + const pausing = host.lifecycle.phase === "suspending"; + const paused = host.lifecycle.phase === "suspended"; + if (!pausing && !paused) return null; + return ( + provider.id === host.machineProviderId, + )} + phase={pausing ? "suspending" : "suspended"} + resuming={resume.isPending} + error={ + resume.error + ? getMutationErrorMessage({ + error: resume.error, + fallbackMessage: "Could not resume the machine.", + }) + : null + } + onResume={() => resume.mutate(hostId)} + /> + ); +} + +export function ThreadMachineStatusBanner({ + host, + provider, + phase, + resuming, + error, + onResume, +}: { + host: MachineLabelHost; + provider: SystemMachineProvider | undefined; + phase: "suspending" | "suspended"; + resuming: boolean; + error: string | null; + onResume: () => void; +}) { + const phaseWord = resuming + ? "resuming…" + : phase === "suspending" + ? "pausing…" + : "paused"; + const status = `Machine is ${phaseWord}`; + const detail = error && !resuming ? error : null; + const [isExpanded, setIsExpanded] = useState(false); + const expandable = detail !== null; + return ( + +
+ {expandable ? ( + + ) : ( +
+ + {status} +
+ )} + {phase === "suspended" ? ( + + + {expandable ? "Retry" : "Resume"} + + + ) : null} +
+ {expandable ? ( + +

+ {detail} +

+
+ ) : null} +
+ ); +} diff --git a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx index e3ae308c5a..ecfbf783a8 100644 --- a/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx +++ b/apps/app/src/components/promptbox/banner/ThreadPromptContextBanner.tsx @@ -39,6 +39,7 @@ import { import { PullRequestStatusPill } from "@/components/pull-request/PullRequestStatusPill"; import { AnimatedBody } from "@/components/promptbox/banner/AnimatedBody"; import { + BannerActionSlot, PROMPT_BANNER_ACTION_FILL_CLASS, PROMPT_BANNER_ACTION_SEGMENT_CLASS, PromptBannerActionButton, @@ -361,24 +362,6 @@ function ChildThreadsBody({ ); } -function BannerActionSlot({ - children, - hideInCompact = false, -}: { - children: ReactNode; - hideInCompact?: boolean; -}) { - return ( -
- {children} -
- ); -} - const PromptBannerActionGroup = ({ children }: { children: ReactNode }) => (
+ {children} +
+ ); +} diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx index a43110373c..118cedf74a 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.rows.stories.tsx @@ -144,7 +144,8 @@ export function Environment() { @@ -153,7 +154,8 @@ export function Environment() { diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx index fbc614190b..b25f608629 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx @@ -9,10 +9,17 @@ import { } from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router-dom"; -import type { Environment, Thread } from "@bb/domain"; +import type { Environment, Host, Thread } from "@bb/domain"; import type { EnvironmentDisplayHostContext } from "@bb/core-ui"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { systemEnvironmentProvidersQueryKey } from "@/hooks/queries/environment-provider-queries"; +import { + hostsQueryKey, + systemMachineProvidersQueryKey, +} from "@/hooks/queries/query-keys"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; @@ -20,6 +27,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { focusWithKeyboard } from "@/test/keyboard-focus"; import { makeEnvironment, + makeHost, makeThread as makeThreadFixture, } from "@bb/test-helpers/domain-fixtures"; import { @@ -38,8 +46,17 @@ const connectedLocalHost: EnvironmentDisplayHostContext = { function withQueryClient( children: ReactNode, registeredProviders?: readonly SystemEnvironmentProvider[], + machines?: { + hosts: readonly Host[]; + providers: readonly SystemMachineProvider[]; + }, ): ReactNode { const queryClient = new QueryClient(); + queryClient.setQueryData(hostsQueryKey(), machines?.hosts ?? []); + queryClient.setQueryData( + systemMachineProvidersQueryKey(), + machines?.providers ?? [], + ); if (registeredProviders !== undefined) { queryClient.setQueryData( systemEnvironmentProvidersQueryKey({}), @@ -52,6 +69,7 @@ function withQueryClient( } const worktreeProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", icon: "GitBranch", @@ -70,6 +88,7 @@ const worktreeProvider: SystemEnvironmentProvider = { }; const modalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "modal-sandbox", displayName: "Modal sandbox", icon: "Cloud", @@ -88,6 +107,7 @@ const modalProvider: SystemEnvironmentProvider = { }; const personalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", icon: "Folder", @@ -120,6 +140,10 @@ function renderEnvironmentRow( environment: Environment, registeredProviders?: readonly SystemEnvironmentProvider[], environmentDisplayHost: EnvironmentDisplayHostContext = localHost, + machines?: { + hosts: readonly Host[]; + providers: readonly SystemMachineProvider[]; + }, ): string { return renderToStaticMarkup( withQueryClient( @@ -133,6 +157,7 @@ function renderEnvironmentRow( , registeredProviders, + machines, ), ); } @@ -180,6 +205,45 @@ describe("EnvironmentRow", () => { expect(markup).toContain("retired-cloud (not installed)"); }); + it("shows the provider icon and host name without provider kind text", () => { + const environment = makeEnvironment({ hostId: "host_modal" }); + const markup = renderEnvironmentRow( + environment, + [], + { + locality: "remote", + identity: { name: "Modal sandbox abc123", connected: true }, + }, + { + hosts: [ + makeHost({ + id: "host_modal", + name: "Modal sandbox abc123", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }), + ], + providers: [ + { + id: "modal-sandbox", + displayName: "Modal machine", + description: "Run a machine for development.", + icon: "Cloud", + logoUrl: null, + pluginId: "environment-modal-sandbox", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + }, + ], + }, + ); + + expect(markup).toContain("Modal sandbox abc123"); + expect(markup).toContain('data-icon="Cloud"'); + expect(markup).not.toContain("Modal machine"); + }); + it("shows the create-thread action for a ready environment", () => { expect(renderEnvironmentRow(makeEnvironment())).toContain( 'aria-label="New thread in this environment"', @@ -262,7 +326,7 @@ describe("EnvironmentRow", () => { ); expect(markup).toContain(">Personal workspace<"); - expect(markup).toContain("· Michael-M4"); + expect(markup).toContain("Michael-M4"); expect(markup).toContain('data-icon="Folder"'); }); @@ -274,7 +338,7 @@ describe("EnvironmentRow", () => { ); expect(markup).toContain("Design system polish"); - expect(markup).toContain("· Michael-M4"); + expect(markup).toContain("Michael-M4"); expect(markup).not.toContain("· Worktree"); }); diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index 22742a10ad..78b05f247b 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -31,6 +31,9 @@ import { getEnvironmentWorkspaceInfoDisplay, } from "@/lib/environment-workspace-display"; import { useSystemEnvironmentProviders } from "@/hooks/queries/environment-provider-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; +import { useHosts } from "@/hooks/queries/host-queries"; +import { MachineLabel } from "@/components/machines/MachineLabel"; import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { Button } from "@bb/shared-ui/button"; import { @@ -242,7 +245,15 @@ export function EnvironmentRow({ environmentId: environment?.id ?? "", }); const { providers } = useSystemEnvironmentProviders(); + const { providers: machineProviders } = useSystemMachineProviders(); + const hosts = useHosts(); if (!environment) return null; + const environmentHost = hosts.data?.find( + (host) => host.id === environment.hostId, + ); + const machineProvider = machineProviders?.find( + (provider) => provider.id === environmentHost?.machineProviderId, + ); const providerLookup = findEnvironmentDisplayProvider( providers, environment.environmentProviderId, @@ -258,6 +269,12 @@ export function EnvironmentRow({ environmentName: environment.name, hostName: environmentDisplayHost.identity?.name ?? null, }); + const displayHost = environmentHost ?? { + name: + environmentDisplayHost.identity?.name ?? infoDisplay.machineName ?? "", + type: "persistent" as const, + machineProviderId: null, + }; const showCreateThreadButton = isReusableEnvironment(environment); return ( {infoDisplay.machineName !== null && environmentDisplayHost.identity ? ( - · {infoDisplay.machineName} - {environmentDisplayHost.identity.connected ? "" : " (offline)"} + · + + {environmentDisplayHost.identity.connected ? null : ( + (offline) + )} ) : null} {showCreateThreadButton ? ( diff --git a/apps/app/src/components/settings/MachineAccessSettings.stories.tsx b/apps/app/src/components/settings/MachineAccessSettings.stories.tsx new file mode 100644 index 0000000000..51b181d1cf --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.stories.tsx @@ -0,0 +1,161 @@ +import type { ServerAccessStatus } from "@bb/server-contract"; +import { + MachineAccessSettingsContent, + type MachineAccessState, +} from "./MachineAccessSettings"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; + +const noop = () => {}; +const noopAsync = async () => {}; +const direct = { + id: "direct", + displayName: "Manual", + description: "Use your own domain or network address.", + pluginId: null, +}; + +function access(): ServerAccessStatus { + return { + providers: [ + { + id: "relay", + displayName: "Managed relay", + description: "Use a managed relay address.", + pluginId: "relay-plugin", + }, + direct, + ], + defaultProviderId: "relay", + effectiveUrl: null, + urlSource: null, + }; +} + +function machineAccessState( + serverAccess: ServerAccessStatus, + overrides: Partial = {}, +): MachineAccessState { + const selected = overrides.selected ?? serverAccess.defaultProviderId; + return { + access: serverAccess, + disabled: false, + draft: null, + error: null, + configurationMessage: null, + effective: serverAccess.providers.find( + (provider) => provider.id === selected, + ), + saving: false, + selected, + value: + serverAccess.urlSource === "setting" + ? (serverAccess.effectiveUrl ?? "") + : "", + editDraft: noop, + selectProvider: noop, + commitUrl: noopAsync, + ...overrides, + }; +} + +const PROVIDER_SETUP_REQUIRED = access(); +const PROVIDER_READY = access(); +const PROVIDER_READY_WITHOUT_URL = access(); +const PROVIDER_UNAVAILABLE = access(); +const DIRECT_WITH_URL = { + ...PROVIDER_SETUP_REQUIRED, + defaultProviderId: "direct", + effectiveUrl: "https://bb.example.com", + urlSource: "setting" as const, +}; +const METHOD_NOT_INSTALLED = { + ...PROVIDER_SETUP_REQUIRED, + providers: [direct], + defaultProviderId: "missing", +}; + +export default { + title: "settings/Machine Access", +}; + +export function Section() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/settings/MachineAccessSettings.tsx b/apps/app/src/components/settings/MachineAccessSettings.tsx new file mode 100644 index 0000000000..f6d1b5efc5 --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.tsx @@ -0,0 +1,320 @@ +import type { ServerAccessStatus } from "@bb/server-contract"; +import { isLocalOnlyUrl } from "@/lib/loopback-hostname"; +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Button } from "@bb/shared-ui/button"; +import { getPluginConfigurationRoutePath } from "@/lib/route-paths"; +import { Icon } from "@bb/shared-ui/icon"; +import { Input } from "@bb/shared-ui/input"; +import { COARSE_POINTER_INPUT_HEIGHT_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { OptionPicker } from "@/components/pickers/OptionPicker"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations"; +import { getMutationErrorMessage } from "@/lib/mutation-errors"; +import { + SettingsSection, + SettingsWithControl, +} from "@/components/ui/settings-section"; +import { usePluginList } from "@/hooks/queries/plugin-settings-queries"; + +function parseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +export interface MachineAccessState { + access: ServerAccessStatus | undefined; + disabled: boolean; + draft: string | null; + error: string | null; + effective: ServerAccessStatus["providers"][number] | undefined; + configurationMessage: string | null; + saving: boolean; + selected: string; + value: string; + editDraft: (next: string) => void; + selectProvider: (providerId: string) => void; + commitUrl: () => Promise; +} + +function useMachineAccess(): MachineAccessState { + const config = useSystemConfig(); + const update = useUpdateGeneralSettings(); + const pluginList = usePluginList({ enabled: true }); + const settings = config.data?.generalSettings; + const access = config.data?.serverAccess; + const value = settings?.machineServerUrl ?? ""; + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const disabled = !settings || update.isPending; + const savedProviderId = access?.defaultProviderId ?? "direct"; + const [selectedProviderId, setSelectedProviderId] = useState( + null, + ); + useEffect(() => { + if (selectedProviderId === savedProviderId) setSelectedProviderId(null); + }, [savedProviderId, selectedProviderId]); + const selected = selectedProviderId ?? savedProviderId; + const effective = access?.providers.find( + (provider) => provider.id === selected, + ); + const effectivePlugin = pluginList.data?.plugins.find( + (plugin) => plugin.id === effective?.pluginId, + ); + return { + access, + disabled, + draft, + error, + effective, + configurationMessage: + effectivePlugin?.status === "needs-configuration" + ? (effectivePlugin.statusDetail ?? "This provider needs configuration.") + : null, + saving: update.isPending, + selected, + value, + editDraft: (next: string) => { + setDraft(next); + setError(null); + }, + selectProvider: (providerId: string) => { + if (!settings || providerId === selected) return; + setSelectedProviderId(providerId); + update.mutate( + { ...settings, defaultMachineAccess: providerId }, + { onError: () => setSelectedProviderId(null) }, + ); + }, + commitUrl: async () => { + if (!settings || draft === null) return; + const url = draft.trim(); + if (url) { + const parsed = parseUrl(url); + if ( + parsed === null || + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password + ) { + setError("Enter a valid HTTP or HTTPS URL without credentials"); + return; + } + if (isLocalOnlyUrl(url)) { + setError( + "Other machines cannot reach localhost. Use a domain or shared-network address.", + ); + return; + } + } + try { + await update.mutateAsync({ + ...settings, + machineServerUrl: url || null, + }); + } catch (saveError) { + setError( + getMutationErrorMessage({ + error: saveError, + fallbackMessage: "Couldn't save the address.", + }), + ); + return; + } + setDraft(null); + setError(null); + }, + }; +} + +export function MachineAccessSettings() { + const machineAccess = useMachineAccess(); + return ; +} + +export function MachineAccessSettingsContent({ + machineAccess, +}: { + machineAccess: MachineAccessState; +}) { + return ( + } + bodyClassName="space-y-3" + > + + + ); +} + +export function MachineAccessControls({ + onNavigate, +}: { + onNavigate?: () => void; +}) { + const machineAccess = useMachineAccess(); + return ( + + ); +} + +export function MachineAccessControlsContent({ + machineAccess, + onNavigate, +}: { + machineAccess: MachineAccessState; + onNavigate?: () => void; +}) { + return ( +
+
+ + Connection method + + +
+ +
+ ); +} + +function MachineAccessMethodPicker({ + machineAccess, +}: { + machineAccess: MachineAccessState; +}) { + const { access, disabled, selected } = machineAccess; + return ( + ({ + value: provider.id, + label: provider.displayName, + description: provider.description, + }))} + onChange={machineAccess.selectProvider} + /> + ); +} + +function MachineAccessDetails({ + machineAccess, + onNavigate, +}: { + machineAccess: MachineAccessState; + onNavigate?: () => void; +}) { + const { + access, + configurationMessage, + disabled, + draft, + effective, + error, + saving, + selected, + value, + } = machineAccess; + const ready = configurationMessage === null; + return ( + <> + {selected !== "direct" && effective !== undefined && ( +
+
+
+

+ {ready ? ( +

+

+ {configurationMessage ?? "Ready to add machines."} +

+
+ {effective.pluginId !== null && ( + + )} +
+
+ )} + {selected !== "direct" && effective === undefined && ( +

+ This connection method is not installed. +

+ )} + {selected === "direct" && ( + + {error} + + ) + } + controlPlacement="below" + > +
+ machineAccess.editDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void machineAccess.commitUrl(); + }} + /> + +
+
+ )} + + ); +} diff --git a/apps/app/src/components/settings/MachineEnvironmentSettings.stories.tsx b/apps/app/src/components/settings/MachineEnvironmentSettings.stories.tsx new file mode 100644 index 0000000000..e5d3b8e4f6 --- /dev/null +++ b/apps/app/src/components/settings/MachineEnvironmentSettings.stories.tsx @@ -0,0 +1,120 @@ +import type { MachineEnvironmentList } from "@bb/server-contract"; +import { MachineEnvironmentSettingsContent } from "./MachineEnvironmentSettings"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; + +export default { + title: "settings/Machine Environment", +}; + +const noop = () => {}; +const noopSave = async () => {}; + +function secret( + name: string, + note: string | null = null, +): MachineEnvironmentList["variables"][number] { + return { name, value: null, secret: true, note }; +} + +const LOGGED_IN: MachineEnvironmentList = { + builtInGit: { status: "logged in", statusMessage: "gh is authenticated" }, + variables: [], +}; + +const NOT_LOGGED_IN: MachineEnvironmentList = { + builtInGit: { status: "not logged in", statusMessage: "gh is signed out" }, + variables: [], +}; + +const GIT_DISABLED: MachineEnvironmentList = { + builtInGit: { status: "disabled", statusMessage: "Automatic token is off" }, + variables: [], +}; + +const OVERRIDDEN: MachineEnvironmentList = { + builtInGit: { + status: "overridden", + statusMessage: "A GH_TOKEN variable takes precedence", + }, + variables: [secret("GH_TOKEN")], +}; + +const SEVERAL: MachineEnvironmentList = { + builtInGit: { status: "logged in", statusMessage: "gh is authenticated" }, + variables: [ + secret("ANTHROPIC_API_KEY"), + secret("DATABASE_URL", "Points at the staging replica, not production."), + secret("SENTRY_DSN"), + ], +}; + +function Stage({ + environment, + loadFailed = false, + gitCredentialsEnabled = true, +}: { + environment: MachineEnvironmentList | null; + loadFailed?: boolean; + gitCredentialsEnabled?: boolean; +}) { + return ( +
+ +
+ ); +} + +export function Section() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/settings/MachineEnvironmentSettings.test.tsx b/apps/app/src/components/settings/MachineEnvironmentSettings.test.tsx new file mode 100644 index 0000000000..792edc144f --- /dev/null +++ b/apps/app/src/components/settings/MachineEnvironmentSettings.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { MachineEnvironmentList } from "@bb/server-contract"; +import { MachineEnvironmentSettings } from "./MachineEnvironmentSettings"; + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + replace: vi.fn(), +})); +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: { generalSettings: { machineGitCredentialsEnabled: true } }, + }), +})); +vi.mock("@/hooks/mutations/settings-mutations", () => ({ + useUpdateGeneralSettings: () => ({ isPending: false, mutate: vi.fn() }), +})); +vi.mock("@/lib/sdk", () => ({ + sdk: { + system: { + machineEnvironment: mocks.list, + replaceMachineEnvironment: mocks.replace, + }, + }, +})); +vi.mock("@/hooks/cache-owners/system-cache-effects", () => ({ + invalidateSystemConfig: vi.fn(), +})); +afterEach(cleanup); +beforeEach(() => vi.resetAllMocks()); + +async function show( + status: MachineEnvironmentList["builtInGit"]["status"] = "logged in", +) { + mocks.list.mockResolvedValue({ + builtInGit: { status, statusMessage: "Git status" }, + variables: [{ name: "API_KEY", value: null, secret: true, note: null }], + }); + render( + + + , + ); + await screen.findByDisplayValue("API_KEY"); +} + +it("stages additions and preserves an unchanged saved secret", async () => { + await show(); + fireEvent.click(screen.getByRole("button", { name: "Add variable" })); + fireEvent.change(screen.getByLabelText("Variable name 2"), { + target: { value: "NEW_VALUE" }, + }); + fireEvent.change(screen.getByLabelText("Value for NEW_VALUE"), { + target: { value: "example" }, + }); + expect(mocks.replace).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Save variables" })); + await waitFor(() => + expect(mocks.replace).toHaveBeenCalledExactlyOnceWith({ + variables: [ + { name: "API_KEY", value: null, note: null }, + { name: "NEW_VALUE", value: "example", note: null }, + ], + }), + ); +}); + +it("stages removal and lets users discard it without deleting", async () => { + await show(); + fireEvent.click(screen.getByRole("button", { name: "Remove API_KEY" })); + expect(screen.queryByDisplayValue("API_KEY")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Discard changes" })); + expect(screen.getByDisplayValue("API_KEY")).toBeTruthy(); + expect(mocks.replace).not.toHaveBeenCalled(); +}); + +it("retains a secret replacement when saving fails", async () => { + await show(); + mocks.replace.mockRejectedValue(new Error("offline")); + fireEvent.change(screen.getByLabelText("Value for API_KEY"), { + target: { value: "replacement" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save variables" })); + await screen.findByText(/Some changes could not be saved/); + expect(screen.getByDisplayValue("replacement")).toBeTruthy(); +}); diff --git a/apps/app/src/components/settings/MachineEnvironmentSettings.tsx b/apps/app/src/components/settings/MachineEnvironmentSettings.tsx new file mode 100644 index 0000000000..428afede5a --- /dev/null +++ b/apps/app/src/components/settings/MachineEnvironmentSettings.tsx @@ -0,0 +1,348 @@ +import { Icon } from "@bb/shared-ui/icon"; +import { useState } from "react"; +import { Switch } from "@bb/shared-ui/switch"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + machineEnvironmentSetSchema, + type MachineEnvironmentList, + type MachineEnvironmentVariable, +} from "@bb/server-contract"; +import { Button } from "@bb/shared-ui/button"; +import { Input } from "@bb/shared-ui/input"; +import { sdk } from "@/lib/sdk"; +import { + SettingsBadge, + SettingsSection, +} from "@/components/ui/settings-section"; +import { invalidateSystemConfig } from "@/hooks/cache-owners/system-cache-effects"; +import { machineEnvironmentQueryKey } from "@/hooks/queries/query-keys"; + +type DraftRow = Omit & { + id: string; + existing: boolean; + value: string | null; +}; + +export function MachineEnvironmentSettings() { + const queryClient = useQueryClient(); + const settings = useSystemConfig().data?.generalSettings; + const updateSettings = useUpdateGeneralSettings(); + const query = useQuery({ + queryKey: machineEnvironmentQueryKey(), + queryFn: () => sdk.system.machineEnvironment(), + }); + const save = async (rows: readonly DraftRow[]) => { + await sdk.system.replaceMachineEnvironment({ + variables: rows.map((row) => ({ + name: row.name, + value: row.value, + note: row.note, + })), + }); + }; + return ( + { + await query.refetch(); + invalidateSystemConfig({ queryClient }); + }} + onSaveFailed={() => void query.refetch()} + onSetGitCredentials={(enabled) => { + if (!settings) return; + updateSettings.mutate( + { ...settings, machineGitCredentialsEnabled: enabled }, + { onSuccess: () => void query.refetch() }, + ); + }} + /> + ); +} + +export function MachineEnvironmentSettingsContent({ + environment, + loadFailed = false, + gitCredentialsEnabled, + gitSwitchDisabled = false, + onSave, + onSaved, + onSaveFailed, + onSetGitCredentials, +}: { + environment: MachineEnvironmentList | null; + loadFailed?: boolean; + gitCredentialsEnabled: boolean; + gitSwitchDisabled?: boolean; + onSave: (rows: readonly DraftRow[]) => Promise; + onSaved?: () => void | Promise; + onSaveFailed?: () => void; + onSetGitCredentials: (enabled: boolean) => void; +}) { + const [draft, setDraft] = useState(null); + const [visible, setVisible] = useState>(new Set()); + const [touched, setTouched] = useState>(new Set()); + const [error, setError] = useState(null); + const rows = + draft ?? + (environment?.variables ?? []).map((row) => ({ + ...row, + id: row.name, + existing: true, + })); + const issues = rows.map((row) => { + if (rows.filter((other) => other.name === row.name).length > 1) + return "Variable name already exists."; + const result = machineEnvironmentSetSchema.safeParse({ + name: row.name, + value: row.value ?? "", + note: row.note, + }); + return result.success + ? null + : !row.name + ? "Enter a variable name." + : "Use uppercase letters, numbers, and underscores; start with a letter or underscore."; + }); + const mutation = useMutation({ + mutationFn: () => onSave(rows), + onSuccess: async () => { + await onSaved?.(); + setDraft(null); + setVisible(new Set()); + setError(null); + }, + onError: () => { + onSaveFailed?.(); + setError( + "Some changes could not be saved. Your edits are retained; try saving again.", + ); + }, + }); + const disabled = environment === null || mutation.isPending; + const change = (id: string, patch: Partial) => { + setDraft(rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); + setError(null); + }; + const hasOverride = rows.some((row) => row.name === "GH_TOKEN"); + const git = environment?.builtInGit; + const gitDisabled = !gitCredentialsEnabled; + const gitMissing = git?.status === "not logged in"; + return ( + + setDraft([ + ...rows, + { + id: crypto.randomUUID(), + existing: false, + name: "", + value: "", + secret: true, + note: null, + }, + ]) + } + > + Add variable + + } + > +
+ {!hasOverride && ( +
+
+ + +
+ +
+
+

+ Automatic + + {gitMissing ? ( + "GitHub is not logged in. Run gh auth login on the server, or add your own GH_TOKEN." + ) : git?.status === "logged in" ? ( + <> + Generated using{" "} + gh auth token --hostname github.com. + + ) : git?.status === "disabled" ? ( + "Disabled — no automatic GitHub credentials are sent to machines." + ) : git?.status === "overridden" ? ( + "The server’s GitHub login will be used after saving." + ) : ( + "Checking the server’s GitHub login…" + )} + +

+
+ )} + {rows.map((row, index) => ( +
+
+ + setTouched((current) => new Set(current).add(row.id)) + } + onChange={(event) => + change(row.id, { + name: event.target.value, + }) + } + /> +
+ + change(row.id, { value: event.target.value }) + } + /> + +
+ +
+ {row.name === "GH_TOKEN" && ( +

+ Overrides the automatic token from the server’s GitHub login. +

+ )} + {row.note && ( +

{row.note}

+ )} + {touched.has(row.id) && issues[index] && ( +

+ {issues[index]} +

+ )} +
+ ))} +
+ {loadFailed && ( +

+ Could not load machine variables. Try refreshing this page. +

+ )} + {error && ( +

+ {error} +

+ )} +
+ {draft !== null && ( + + )} + +
+
+ ); +} diff --git a/apps/app/src/components/settings/MachinesSettingsSection.stories.tsx b/apps/app/src/components/settings/MachinesSettingsSection.stories.tsx new file mode 100644 index 0000000000..c895ea1a90 --- /dev/null +++ b/apps/app/src/components/settings/MachinesSettingsSection.stories.tsx @@ -0,0 +1,177 @@ +import type { Host, MachineLifecycle } from "@bb/domain"; +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { MachineRowContent } from "./MachinesSettingsSection"; +import { SettingsRowList } from "@/components/ui/settings-section"; +import { + MANUAL_MACHINE_PROVIDER, + MODAL_MACHINE_PROVIDER, +} from "../../../.ladle/machine-story-fixtures"; +import { StoryCard, StoryRow } from "../../../.ladle/story-card"; + +export default { + title: "settings/Machines", +}; + +const noop = () => {}; +const now = Date.parse("2026-09-09T12:00:00Z"); + +function lifecycle( + overrides: Partial = {}, +): MachineLifecycle { + return { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + ...overrides, + }; +} + +function sandbox(overrides: Partial = {}): Host { + return makeHost({ + id: "host_sandbox", + name: "Modal sandbox 3f9a", + type: "ephemeral", + machineProviderId: MODAL_MACHINE_PROVIDER.id, + ...overrides, + }); +} + +function Row({ + host, + machineProvider = MODAL_MACHINE_PROVIDER, + ...overrides +}: { + host: Host; + machineProvider?: typeof MODAL_MACHINE_PROVIDER | null; +} & Partial[0]>) { + return ( +
+ + + +
+ ); +} + +export function Rows() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx index 663469ac29..3fd6fc305f 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx @@ -11,7 +11,10 @@ import type { Host } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { RETRY_ACTION_ICON } from "@bb/domain/update-state"; import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; -import type { SystemConfigResponse } from "@bb/server-contract"; +import type { + SystemConfigResponse, + SystemMachineProvider, +} from "@bb/server-contract"; import { MemoryRouter, useLocation } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; @@ -25,7 +28,11 @@ vi.mock("@/lib/sdk", () => ({ hosts: { delete: vi.fn(), list: vi.fn(), + experimental_listProviders: vi.fn(), + experimental_resume: vi.fn(), + experimental_retryCleanup: vi.fn(), retryUpdate: vi.fn(), + experimental_suspend: vi.fn(), update: vi.fn(), }, system: { config: vi.fn() }, @@ -64,6 +71,23 @@ const offlineHost = host({ status: "disconnected", lastSeenAt: NOW - 2 * 60 * 60 * 1000, }); +const sandboxHost = host({ + id: "host_sandbox", + name: "Modal sandbox 3f9a", + type: "ephemeral", + machineProviderId: "modal-sandbox", +}); +const modalMachineProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal Sandbox", + description: "Create a sandbox in your Modal account.", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, +}; function systemConfig(): SystemConfigResponse { return makeSystemConfig({ @@ -126,6 +150,7 @@ async function openHostMenu(hostName: string): Promise { beforeEach(() => { hostDaemon.localDaemonHostId = "host_primary"; hostDaemon.platform = "darwin"; + vi.mocked(sdk.hosts.experimental_listProviders).mockResolvedValue([]); }); afterEach(() => { @@ -135,6 +160,57 @@ afterEach(() => { }); describe("MachinesSettingsSection", () => { + it("reveals sandboxes in the machine list behind Show all machines", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, sandboxHost]); + vi.mocked(sdk.hosts.experimental_listProviders).mockResolvedValue([ + modalMachineProvider, + ]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const persistentName = await screen.findByText(primaryHost.name); + expect( + persistentName.parentElement?.querySelector('[data-icon="Laptop"]'), + ).not.toBeNull(); + expect(screen.queryByText(sandboxHost.name)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Show all machines" })); + + const sandboxName = screen.getByText(sandboxHost.name); + expect(sandboxName.parentElement?.querySelector("svg")).not.toBeNull(); + expect( + sandboxName.parentElement?.querySelector('[data-icon="Laptop"]'), + ).toBeNull(); + expect(screen.queryByText(modalMachineProvider.displayName)).toBeNull(); + await openHostMenu(sandboxHost.name); + fireEvent.click(await screen.findByRole("menuitem", { name: "Suspend" })); + await waitFor(() => { + expect(vi.mocked(sdk.hosts.experimental_suspend)).toHaveBeenCalledWith({ + hostId: sandboxHost.id, + }); + }); + + fireEvent.click( + screen.getByRole("button", { name: "Show fewer machines" }), + ); + expect(screen.queryByText(sandboxHost.name)).toBeNull(); + }); + + it("offers no reveal when every machine is already listed", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost]); + stubSidebarBootstrapFetch(); + + renderSection(); + + await screen.findByText(primaryHost.name); + expect( + screen.queryByRole("button", { name: "Show all machines" }), + ).toBeNull(); + }); + it("renders machine status, project, and permission metadata as visible text", async () => { vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); vi.mocked(sdk.hosts.list).mockResolvedValue([primaryHost, offlineHost]); @@ -155,8 +231,8 @@ describe("MachinesSettingsSection", () => { expect( screen .getByRole("link", { name: "Open MacBook Pro" }) - .querySelector("[data-icon]"), - ).toBeNull(); + .querySelector('[data-icon="Laptop"]'), + ).not.toBeNull(); }); it("distinguishes the client-local daemon from the primary machine", async () => { @@ -170,12 +246,18 @@ describe("MachinesSettingsSection", () => { const primaryName = await screen.findByText("MacBook Pro"); const localName = screen.getByText("dev-vm"); - expect(primaryName.parentElement?.textContent).toContain("primary"); - expect(primaryName.parentElement?.textContent).not.toContain( + expect(primaryName.parentElement?.parentElement?.textContent).toContain( + "primary", + ); + expect(primaryName.parentElement?.parentElement?.textContent).not.toContain( + "this machine", + ); + expect(localName.parentElement?.parentElement?.textContent).toContain( "this machine", ); - expect(localName.parentElement?.textContent).toContain("this machine"); - expect(localName.parentElement?.textContent).not.toContain("primary"); + expect(localName.parentElement?.parentElement?.textContent).not.toContain( + "primary", + ); expect(screen.getByText("Linux")).toBeDefined(); }); @@ -299,7 +381,12 @@ describe("MachinesSettingsSection", () => { expect(action?.parentElement?.className).toContain("sm:flex-row"); fireEvent.click(addMachine); expect( - await screen.findByRole("heading", { name: "Add a machine" }), + await screen.findByRole("heading", { name: "Set up machine access" }), + ).toBeDefined(); + expect( + screen.getByText( + "A new machine has to reach this server over the network. Choose the address it should use.", + ), ).toBeDefined(); }); diff --git a/apps/app/src/components/settings/MachinesSettingsSection.tsx b/apps/app/src/components/settings/MachinesSettingsSection.tsx index 1d956cbbb0..e7a3dba04a 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.tsx @@ -1,15 +1,10 @@ import { useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import type { Host, PermissionMode } from "@bb/domain"; +import type { SystemMachineProvider } from "@bb/server-contract"; import { RETRY_ACTION_ICON } from "@bb/domain/update-state"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { Button } from "@bb/shared-ui/button"; -import { - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@bb/shared-ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -29,10 +24,16 @@ import { TooltipTrigger, } from "@bb/shared-ui/tooltip"; import { AddMachineDialog } from "@/components/dialogs/AddMachineDialog"; -import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog"; import { appToast } from "@/components/ui/app-toast"; +import { MachineLifecycleActions } from "@/components/machines/MachineLifecycleActions"; +import { MachineRemoveDialog } from "@/components/machines/MachineRemoveDialog"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; +import { + machineStatusLabel, + machineStatusTone, +} from "@/components/machines/machine-status"; import { MachineRenameDialog } from "@/components/settings/MachineRenameDialog"; +import { MachineLabel } from "@/components/machines/MachineLabel"; import { SettingsBadge, SettingsRow, @@ -40,18 +41,20 @@ import { SettingsSection, } from "@/components/ui/settings-section"; import { - useRemoveHost, useRenameHost, + useResumeHost, + useRetryHostCleanup, useRetryHostUpdate, + useSuspendHost, } from "@/hooks/mutations/host-mutations"; import { useHosts } from "@/hooks/queries/host-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { getSettingsMachineRoutePath } from "@/lib/route-paths"; import { PERMISSION_MODE_OPTIONS } from "@/lib/permission-mode-options"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -import { formatRelativeTime } from "@/lib/relative-time"; import { formatHostUpdateStatus, hostCanRetryUpdate, @@ -89,10 +92,15 @@ interface MachineRowProps { onRename: () => void; onRemove: () => void; onRetryUpdate: () => void; + onSuspend: () => void; + onResume: () => void; + onRetryCleanup: () => void; + lifecycleActionPending: boolean; retryUpdatePending: boolean; + machineProvider: SystemMachineProvider | null; } -function MachineRow({ +export function MachineRowContent({ host, isPrimary, isThisMachine, @@ -103,18 +111,18 @@ function MachineRow({ onRename, onRemove, onRetryUpdate, + onSuspend, + onResume, + onRetryCleanup, + lifecycleActionPending, retryUpdatePending, + machineProvider, }: MachineRowProps) { const navigate = useNavigate(); const detailPath = getSettingsMachineRoutePath(host.id); const permission = PERMISSION_MODE_PRESENTATION[host.maxPermissionMode]; const projectLabel = `${projectCount} ${projectCount === 1 ? "project" : "projects"}`; - const connectionLabel = - host.status === "connected" - ? "Online" - : host.lastSeenAt === null - ? "Offline" - : `Offline · last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`; + const connectionLabel = machineStatusLabel({ host, now }); const updateStatus = formatHostUpdateStatus(host); const removeItem = ( -
{ - if (targetsResourceAction(event.target)) return; - navigate(detailPath); - }} - > - +
{ + if (targetsResourceAction(event.target)) return; + navigate(detailPath); + }} > -
-
- - {host.name} - - {isThisMachine ? ( - this machine - ) : null} - {showPrimaryBadge ? primary : null} -
-
- - - {connectionLabel} - - {platformLabel === null ? null : ( - {platformLabel} - )} - {projectLabel} - +
+
+ + {isThisMachine ? ( + this machine + ) : null} + {showPrimaryBadge ? ( + primary + ) : null} +
+
+ + + {connectionLabel} + + {platformLabel === null ? null : ( + {platformLabel} )} - > - {permission.label} - - {updateStatus === null ? null : ( - - {updateStatus} + {projectLabel} + + {permission.label} - )} + {updateStatus === null ? null : ( + + {updateStatus} + + )} +
-
- -
- - - - - - - - - Rename - - {hostCanRetryUpdate(host) ? ( + +
+ + + + + + - - - {retryUpdatePending ? "Retrying update…" : "Retry update"} - + + Rename - ) : null} - {isPrimary ? ( - - {removeItem} - - {PRIMARY_REMOVE_DISABLED_REASON} - - - ) : ( - removeItem - )} - - - - + {hostCanRetryUpdate(host) ? ( + + + + {retryUpdatePending + ? "Retrying update…" + : "Retry update"} + + + ) : null} + + {isPrimary ? ( + + {removeItem} + + {PRIMARY_REMOVE_DISABLED_REASON} + + + ) : ( + removeItem + )} + + + + +
@@ -242,13 +267,17 @@ function MachineRow({ export function MachinesSettingsSection() { const systemConfig = useSystemConfig(); - const hostsQuery = useHosts(); + const hostsQuery = useHosts({ includeCreating: true }); + const { providers: machineProviders } = useSystemMachineProviders(); const { localDaemonHostId, platform: localDaemonPlatform } = useHostDaemon(); const sidebarNavigationQuery = useSidebarNavigation(); const renameHost = useRenameHost(); - const removeHost = useRemoveHost(); const retryHostUpdate = useRetryHostUpdate(); + const suspendHost = useSuspendHost(); + const resumeHost = useResumeHost(); + const retryHostCleanup = useRetryHostCleanup(); const [addDialogOpen, setAddDialogOpen] = useState(false); + const [showAllMachines, setShowAllMachines] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); @@ -268,8 +297,92 @@ export function MachinesSettingsSection() { const now = Date.now(); const primaryHostPlatform = systemConfig.data?.primaryHostPlatform ?? null; - const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; - const hasMachineRows = hosts !== undefined && hosts.length > 0; + const persistentHosts = hosts?.filter((host) => host.type === "persistent"); + const sandboxHosts = hosts?.filter((host) => host.type === "ephemeral"); + const visibleHosts = + showAllMachines && sandboxHosts !== undefined + ? [...(persistentHosts ?? []), ...sandboxHosts] + : (persistentHosts ?? []); + const showMachineIdentityBadges = (persistentHosts?.length ?? 0) > 1; + const hasMachineRows = + persistentHosts !== undefined && persistentHosts.length > 0; + const machineProviderById = useMemo( + () => + new Map( + (machineProviders ?? []).map((provider) => [provider.id, provider]), + ), + [machineProviders], + ); + const renderMachineRows = (rows: readonly Host[]) => ( + + {rows.map((host) => ( + { + renameHost.reset(); + setRenameTarget(host); + }} + onRemove={() => { + setRemoveTarget(host); + }} + onRetryUpdate={() => + retryHostUpdate.mutate(host.id, { + onSuccess: () => { + appToast.success(`Update retry requested for ${host.name}`); + }, + }) + } + retryUpdatePending={ + retryHostUpdate.isPending && retryHostUpdate.variables === host.id + } + onSuspend={() => + suspendHost.mutate(host.id, { + onSuccess: () => appToast.success(`${host.name} suspended`), + }) + } + onResume={() => + resumeHost.mutate(host.id, { + onSuccess: () => appToast.success(`${host.name} resumed`), + }) + } + onRetryCleanup={() => + retryHostCleanup.mutate(host.id, { + onSuccess: () => + appToast.success(`Cleanup retried for ${host.name}`), + }) + } + lifecycleActionPending={ + (suspendHost.isPending && suspendHost.variables === host.id) || + (resumeHost.isPending && resumeHost.variables === host.id) || + (retryHostCleanup.isPending && + retryHostCleanup.variables === host.id) + } + machineProvider={ + host.machineProviderId === null + ? null + : (machineProviderById.get(host.machineProviderId) ?? null) + } + /> + ))} + + ); return ( <> @@ -290,63 +403,34 @@ export function MachinesSettingsSection() { > {hosts === undefined ? (

Loading…

- ) : hosts.length === 0 ? ( + ) : visibleHosts.length === 0 ? (

No machines yet.

) : ( - - {hosts.map((host) => ( - { - renameHost.reset(); - setRenameTarget(host); - }} - onRemove={() => { - removeHost.reset(); - setRemoveTarget(host); - }} - onRetryUpdate={() => - retryHostUpdate.mutate(host.id, { - onSuccess: () => { - appToast.success( - `Update retry requested for ${host.name}`, - ); - }, - }) - } - retryUpdatePending={ - retryHostUpdate.isPending && - retryHostUpdate.variables === host.id - } - /> - ))} - + renderMachineRows(visibleHosts) )} + {sandboxHosts !== undefined && sandboxHosts.length > 0 ? ( + + ) : null} - + - { - if (!open && !removeHost.isPending) setRemoveTarget(null); + if (!open) setRemoveTarget(null); }} - > - {removeTarget ? ( - <> - - Remove {removeTarget.name}? - - This revokes {removeTarget.name}'s access to this server. - Project checkouts stay on its disk, but its environments become - read-only history and it can't run new work until it's paired - again. - - - {removeHost.isError ? ( -

- {getMutationErrorMessage({ - error: removeHost.error, - fallbackMessage: `Couldn't remove ${removeTarget.name}.`, - })} -

- ) : null} - - - - - ) : null} -
+ /> ); } diff --git a/apps/app/src/components/settings/ProjectsSettingsSection.tsx b/apps/app/src/components/settings/ProjectsSettingsSection.tsx index e274e04b60..d7c32932fd 100644 --- a/apps/app/src/components/settings/ProjectsSettingsSection.tsx +++ b/apps/app/src/components/settings/ProjectsSettingsSection.tsx @@ -48,7 +48,7 @@ import { useReorderProject, useUpdateProject, } from "@/hooks/mutations/project-mutations"; -import { selectPersistentHosts, useHosts } from "@/hooks/queries/host-queries"; +import { selectHosts, useHosts } from "@/hooks/queries/host-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useQuickCreateProject } from "@/hooks/useQuickCreateProject"; import { getSettingsProjectRoutePath } from "@/lib/route-paths"; @@ -289,7 +289,7 @@ export function ProjectsSettingsSection() { [projects], ); const hosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), + () => selectHosts(hostsQuery.data), [hostsQuery.data], ); const hostById = useMemo( diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index 8d562d9e3c..40694aef3d 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -187,6 +187,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "allHostQueryKeyPrefix", "allProjectPathsQueryKeyPrefix", "allSystemExecutionOptionsQueryKeyPrefix", + "allSystemMachineProvidersQueryKeyPrefix", "allSystemProvidersQueryKeyPrefix", "allSystemThemesQueryKeyPrefix", "allTerminalsQueryKeyPrefix", diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index a8dc396144..af2d52e7e7 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -9,6 +9,7 @@ import { allHostQueryKeyPrefix, allProjectPathsQueryKeyPrefix, allSystemExecutionOptionsQueryKeyPrefix, + allSystemMachineProvidersQueryKeyPrefix, allSystemProvidersQueryKeyPrefix, allSystemThemesQueryKeyPrefix, allTerminalsQueryKeyPrefix, @@ -113,6 +114,14 @@ export function invalidateSystemProviders({ }); } +export function invalidateMachineProviders({ + queryClient, +}: QueryClientArg): Promise { + return queryClient.invalidateQueries({ + queryKey: allSystemMachineProvidersQueryKeyPrefix(), + }); +} + export function invalidateSystemExecutionOptions({ hostId, queryClient, diff --git a/apps/app/src/hooks/mutations/host-mutations.ts b/apps/app/src/hooks/mutations/host-mutations.ts index 1d72c9a1ed..a483dae918 100644 --- a/apps/app/src/hooks/mutations/host-mutations.ts +++ b/apps/app/src/hooks/mutations/host-mutations.ts @@ -74,3 +74,33 @@ export function useRetryHostUpdate() { mutationFn: (hostId: string) => sdk.hosts.retryUpdate({ hostId }), }); } + +function useHostLifecycleMutation( + mutationFn: (hostId: string) => Promise, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: () => { + invalidateHostListQueries({ queryClient }); + }, + }); +} + +export function useSuspendHost() { + return useHostLifecycleMutation((hostId) => + sdk.hosts.experimental_suspend({ hostId }), + ); +} + +export function useResumeHost() { + return useHostLifecycleMutation((hostId) => + sdk.hosts.experimental_resume({ hostId }), + ); +} + +export function useRetryHostCleanup() { + return useHostLifecycleMutation((hostId) => + sdk.hosts.experimental_retryCleanup({ hostId }), + ); +} diff --git a/apps/app/src/hooks/queries/environment-provider-queries.test.tsx b/apps/app/src/hooks/queries/environment-provider-queries.test.tsx index f61d1e3680..4651eebe31 100644 --- a/apps/app/src/hooks/queries/environment-provider-queries.test.tsx +++ b/apps/app/src/hooks/queries/environment-provider-queries.test.tsx @@ -17,6 +17,7 @@ vi.mock("@/lib/sdk", () => ({ })); const WORKTREE_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", icon: "GitBranch", diff --git a/apps/app/src/hooks/queries/host-queries.test.ts b/apps/app/src/hooks/queries/host-queries.test.ts index 2398b21419..425e2da689 100644 --- a/apps/app/src/hooks/queries/host-queries.test.ts +++ b/apps/app/src/hooks/queries/host-queries.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Host } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; -import { selectPrimaryHost } from "./host-queries"; +import { selectHosts, selectPrimaryHost } from "./host-queries"; function host(overrides: Partial & Pick): Host { return makeHost({ @@ -32,8 +32,30 @@ describe("selectPrimaryHost", () => { expect(selectPrimaryHost([hosts[0]], null)?.id).toBe("host_stale"); }); + it("allows a provider-made host to be selected as primary", () => { + const sandbox = host({ + id: "host_modal", + machineProviderId: "modal-sandbox", + }); + const laptop = host({ id: "host_laptop", status: "disconnected" }); + expect(selectPrimaryHost([sandbox], null)?.id).toBe(sandbox.id); + expect(selectPrimaryHost([sandbox], sandbox.id)?.id).toBe(sandbox.id); + expect(selectPrimaryHost([sandbox, laptop], null)?.id).toBe(sandbox.id); + }); + it("returns null for an empty or missing host list", () => { expect(selectPrimaryHost(undefined, "host_a")).toBeNull(); expect(selectPrimaryHost([], null)).toBeNull(); }); }); + +describe("selectHosts", () => { + it("keeps user-enrolled and provider-made machines in menus", () => { + expect( + selectHosts([ + host({ id: "host_local" }), + host({ id: "host_modal", machineProviderId: "modal-sandbox" }), + ]).map((candidate) => candidate.id), + ).toEqual(["host_local", "host_modal"]); + }); +}); diff --git a/apps/app/src/hooks/queries/host-queries.ts b/apps/app/src/hooks/queries/host-queries.ts index 05a09dab95..689584114e 100644 --- a/apps/app/src/hooks/queries/host-queries.ts +++ b/apps/app/src/hooks/queries/host-queries.ts @@ -12,21 +12,22 @@ import { } from "./query-keys"; import type { QueryOptions } from "./query-helpers"; -export function useHosts(options?: QueryOptions) { +export function useHosts( + options?: QueryOptions & { includeCreating?: boolean }, +) { const enabled = options?.enabled ?? true; + const includeCreating = options?.includeCreating ?? false; useHostListRealtimeSubscription({ enabled }); return useQuery({ - queryKey: hostsQueryKey(), - queryFn: ({ signal }) => sdk.hosts.list({ signal }), + queryKey: hostsQueryKey(includeCreating), + queryFn: ({ signal }) => sdk.hosts.list({ signal, includeCreating }), enabled, staleTime: 60_000, }); } -export function selectPersistentHosts( - hosts: readonly Host[] | undefined, -): Host[] { +export function selectHosts(hosts: readonly Host[] | undefined): Host[] { return hosts ? [...hosts] : []; } @@ -34,11 +35,16 @@ export function selectPrimaryHost( hosts: readonly Host[] | undefined, primaryHostId: string | null, ): Host | null { - if (!hosts || hosts.length === 0) return null; + const availableHosts = selectHosts(hosts); + if (availableHosts.length === 0) return null; if (primaryHostId !== null) { - return hosts.find((host) => host.id === primaryHostId) ?? null; + return availableHosts.find((host) => host.id === primaryHostId) ?? null; } - return hosts.find((host) => host.status === "connected") ?? hosts[0] ?? null; + return ( + availableHosts.find((host) => host.status === "connected") ?? + availableHosts[0] ?? + null + ); } export function usePrimaryHost(options?: QueryOptions): Host | null { diff --git a/apps/app/src/hooks/queries/machine-provider-queries.ts b/apps/app/src/hooks/queries/machine-provider-queries.ts new file mode 100644 index 0000000000..cd180d7531 --- /dev/null +++ b/apps/app/src/hooks/queries/machine-provider-queries.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import type { SystemMachineProvider } from "@bb/server-contract"; +import { sdk } from "@/lib/sdk"; +import { SERVER_SESSION_QUERY_POLICY } from "./query-policies"; +import { systemMachineProvidersQueryKey } from "./query-keys"; + +const NO_MACHINE_PROVIDERS: readonly SystemMachineProvider[] = []; + +export function useSystemMachineProviders(): { + providers: readonly SystemMachineProvider[] | undefined; +} { + const result = useQuery({ + queryKey: systemMachineProvidersQueryKey(), + queryFn: () => sdk.hosts.experimental_listProviders(), + ...SERVER_SESSION_QUERY_POLICY, + }); + return { + providers: result.isError ? NO_MACHINE_PROVIDERS : result.data, + }; +} diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 2e25d3b64f..f21c8b0936 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -56,6 +56,8 @@ const THREAD_CONVERSATION_OUTLINE_QUERY_KEY = "threadConversationOutline"; const THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY = "threadTimelineTurnSummaryDetails"; const SYSTEM_PROVIDERS_QUERY_KEY = "systemProviders"; +const SYSTEM_MACHINE_PROVIDERS_QUERY_KEY = "systemMachineProviders"; +const MACHINE_ENVIRONMENT_QUERY_KEY = "machine-environment"; const SYSTEM_CONFIG_QUERY_KEY = "systemConfig"; const UI_PREFERENCES_QUERY_KEY = "uiPreferences"; const SYSTEM_THEME_QUERY_KEY = "systemTheme"; @@ -101,7 +103,9 @@ export interface ArchivedThreadsListFilters { export const ARCHIVED_THREADS_LIST_KIND = "archivedList"; -type HostsQueryKey = readonly [typeof HOSTS_QUERY_KEY]; +type HostsQueryKey = + | readonly [typeof HOSTS_QUERY_KEY] + | readonly [typeof HOSTS_QUERY_KEY, true]; type HostQueryId = string | null | undefined; type HostQueryKey = readonly [typeof HOST_QUERY_KEY, HostQueryId]; type AllHostQueryKeyPrefix = readonly [typeof HOST_QUERY_KEY]; @@ -446,6 +450,15 @@ type SystemProvidersQueryKey = readonly [ type AllSystemProvidersQueryKeyPrefix = readonly [ typeof SYSTEM_PROVIDERS_QUERY_KEY, ]; +type SystemMachineProvidersQueryKey = readonly [ + typeof SYSTEM_MACHINE_PROVIDERS_QUERY_KEY, +]; +type AllSystemMachineProvidersQueryKeyPrefix = readonly [ + typeof SYSTEM_MACHINE_PROVIDERS_QUERY_KEY, +]; +type MachineEnvironmentQueryKey = readonly [ + typeof MACHINE_ENVIRONMENT_QUERY_KEY, +]; type SystemConfigQueryKey = readonly [typeof SYSTEM_CONFIG_QUERY_KEY]; type UiPreferencesQueryKey = readonly [typeof UI_PREFERENCES_QUERY_KEY]; type SystemThemeQueryKey = readonly [typeof SYSTEM_THEME_QUERY_KEY, string]; @@ -491,8 +504,8 @@ interface ProjectDefaultExecutionOptionsQueryKeyArgs { projectId: string; } -export function hostsQueryKey(): HostsQueryKey { - return [HOSTS_QUERY_KEY]; +export function hostsQueryKey(includeCreating = false): HostsQueryKey { + return includeCreating ? [HOSTS_QUERY_KEY, true] : [HOSTS_QUERY_KEY]; } export function hostQueryKey(hostId: HostQueryId): HostQueryKey { @@ -1075,6 +1088,18 @@ export function allSystemProvidersQueryKeyPrefix(): AllSystemProvidersQueryKeyPr return [SYSTEM_PROVIDERS_QUERY_KEY]; } +export function systemMachineProvidersQueryKey(): SystemMachineProvidersQueryKey { + return [SYSTEM_MACHINE_PROVIDERS_QUERY_KEY]; +} + +export function allSystemMachineProvidersQueryKeyPrefix(): AllSystemMachineProvidersQueryKeyPrefix { + return [SYSTEM_MACHINE_PROVIDERS_QUERY_KEY]; +} + +export function machineEnvironmentQueryKey(): MachineEnvironmentQueryKey { + return [MACHINE_ENVIRONMENT_QUERY_KEY]; +} + export function systemCliSkillsQueryKey(): SystemCliSkillsQueryKey { return [SYSTEM_CLI_SKILLS_QUERY_KEY]; } diff --git a/apps/app/src/hooks/useLocalPathPicker.tsx b/apps/app/src/hooks/useLocalPathPicker.tsx index aba734cdd4..377826a94e 100644 --- a/apps/app/src/hooks/useLocalPathPicker.tsx +++ b/apps/app/src/hooks/useLocalPathPicker.tsx @@ -4,7 +4,7 @@ import type { HostPlatform } from "@bb/host-daemon-contract"; import { useDialogState } from "@/hooks/useDialogState"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { - selectPersistentHosts, + selectHosts, useHosts, usePrimaryHost, } from "@/hooks/queries/host-queries"; @@ -75,7 +75,7 @@ export function useLocalPathPicker({ usePathPickerHost(); const hostsQuery = useHosts(); const isLoadingHosts = hostsQuery.isPending; - const connectedHostCount = selectPersistentHosts(hostsQuery.data).filter( + const connectedHostCount = selectHosts(hostsQuery.data).filter( (host) => host.status === "connected", ).length; const projectPathDialog = useDialogState(); diff --git a/apps/app/src/hooks/useQuickCreateProject.test.tsx b/apps/app/src/hooks/useQuickCreateProject.test.tsx index 2aff247879..0ce3c035c7 100644 --- a/apps/app/src/hooks/useQuickCreateProject.test.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.test.tsx @@ -28,9 +28,8 @@ vi.mock("@/hooks/mutations/project-mutations", () => ({ useCreateProject: () => ({ isPending: false, mutate: mocks.mutate }), })); -vi.mock("@/hooks/queries/host-queries", () => ({ - selectPersistentHosts: (hosts: readonly Host[] | undefined) => - hosts ? [...hosts] : [], +vi.mock("@/hooks/queries/host-queries", async (importOriginal) => ({ + ...(await importOriginal()), useHosts: () => ({ data: mocks.hosts, isPending: mocks.isLoadingHosts }), })); @@ -88,13 +87,22 @@ describe("useQuickCreateProject", () => { expect(mocks.openPathEntry).toHaveBeenCalledWith({ kind: "create" }); }); - it("exposes the machine list for the dialog's picker", () => { - mocks.hosts = [host("host_atum", "atum"), host("host_thoth", "Thoth")]; + it("exposes every machine for the dialog's picker", () => { + mocks.hosts = [ + host("host_atum", "atum"), + host("host_thoth", "Thoth"), + makeHost({ + id: "host_sandbox", + name: "Sandbox", + machineProviderId: "modal-sandbox", + }), + ]; const { result } = renderHook(() => useQuickCreateProject()); expect(result.current.hosts.map((item) => item.id)).toEqual([ "host_atum", "host_thoth", + "host_sandbox", ]); }); }); diff --git a/apps/app/src/hooks/useQuickCreateProject.tsx b/apps/app/src/hooks/useQuickCreateProject.tsx index 0c1845b47b..f73e31115e 100644 --- a/apps/app/src/hooks/useQuickCreateProject.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.tsx @@ -9,7 +9,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { deriveProjectNameFromPath, type Host } from "@bb/domain"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { useCreateProject } from "@/hooks/mutations/project-mutations"; -import { selectPersistentHosts, useHosts } from "@/hooks/queries/host-queries"; +import { selectHosts, useHosts } from "@/hooks/queries/host-queries"; import { useLocalPathPicker, type LocalPathSubmitParams, @@ -48,10 +48,7 @@ const quickCreateProjectContext = export function useQuickCreateProject(): QuickCreateProjectController { const { mutate, isPending } = useCreateProject(); const hostsQuery = useHosts(); - const hosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), - [hostsQuery.data], - ); + const hosts = useMemo(() => selectHosts(hostsQuery.data), [hostsQuery.data]); const navigate = useNavigate(); const location = useLocation(); const setRootComposeProjectId = useSetRootComposeProjectId(); diff --git a/apps/app/src/hooks/useThreadCreationOptions.test.tsx b/apps/app/src/hooks/useThreadCreationOptions.test.tsx index d59aae5129..3b159ea23d 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.test.tsx +++ b/apps/app/src/hooks/useThreadCreationOptions.test.tsx @@ -862,7 +862,6 @@ describe("useThreadCreationOptions", () => { { id: "capped-host", name: "capped", - type: "persistent", status: "connected", maxPermissionMode: "accept-edits", lastSeenAt: null, diff --git a/apps/app/src/hooks/useUpdateInventory.test.ts b/apps/app/src/hooks/useUpdateInventory.test.ts index d0ec036bd7..d187291dfb 100644 --- a/apps/app/src/hooks/useUpdateInventory.test.ts +++ b/apps/app/src/hooks/useUpdateInventory.test.ts @@ -3,7 +3,11 @@ import type { ProviderCliStatus, ProviderCliStatusResponse, } from "@bb/host-daemon-contract"; -import { buildUpdateInventoryProviderIssues } from "./useUpdateInventory"; +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { + buildUpdateInventoryProviderIssues, + updateInventoryHosts, +} from "./useUpdateInventory"; function providerStatus( displayName: string, @@ -46,3 +50,21 @@ describe("buildUpdateInventoryProviderIssues", () => { ]); }); }); + +describe("updateInventoryHosts", () => { + it("omits machines from ephemeral providers", () => { + const modal = makeHost({ + id: "host_modal", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }); + const persistent = makeHost({ + id: "host_persistent", + machineProviderId: "persistent-cloud", + }); + const manual = makeHost({ id: "host_manual" }); + expect(updateInventoryHosts([modal, persistent, manual])).toEqual( + [persistent, manual], + ); + }); +}); diff --git a/apps/app/src/hooks/useUpdateInventory.ts b/apps/app/src/hooks/useUpdateInventory.ts index d666102dca..82baab2e58 100644 --- a/apps/app/src/hooks/useUpdateInventory.ts +++ b/apps/app/src/hooks/useUpdateInventory.ts @@ -60,6 +60,10 @@ export function buildUpdateInventoryProviderIssues( .filter(isProviderCliIssue); } +export function updateInventoryHosts(hosts: readonly Host[]): Host[] { + return hosts.filter((host) => host.type !== "ephemeral"); +} + export function useUpdateInventory( options?: UseUpdateInventoryOptions, ): UpdateInventory { @@ -73,9 +77,9 @@ export function useUpdateInventory( ).length; const hosts = useMemo(() => hostsQuery.data ?? [], [hostsQuery.data]); - const connectedHosts = useMemo( - () => hosts.filter((host) => host.status === "connected"), - [hosts], + const updateHosts = updateInventoryHosts(hosts); + const connectedHosts = updateHosts.filter( + (host) => host.status === "connected", ); const primaryHostId = selectPrimaryHost(hosts, systemConfigQuery.data?.primaryHostId ?? null) @@ -101,7 +105,7 @@ export function useUpdateInventory( } }); - const machines: UpdateInventoryMachine[] = hosts.map((host) => { + const machines: UpdateInventoryMachine[] = updateHosts.map((host) => { const statusQuery = providerStatusByHostId.get(host.id); const providerStatus = statusQuery?.data ?? null; const issues = diff --git a/apps/app/src/lib/environment-workspace-display.test.ts b/apps/app/src/lib/environment-workspace-display.test.ts index 8a77dc609f..f27ef19bdb 100644 --- a/apps/app/src/lib/environment-workspace-display.test.ts +++ b/apps/app/src/lib/environment-workspace-display.test.ts @@ -17,6 +17,7 @@ describe("shouldShowEnvironmentHostIdentity", () => { }); const worktreeProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", icon: "FolderGit", @@ -35,6 +36,7 @@ const worktreeProvider: SystemEnvironmentProvider = { }; const personalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", icon: "Folder", @@ -53,6 +55,7 @@ const personalProvider: SystemEnvironmentProvider = { }; const machineContainerProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "container", displayName: "Container", icon: "Box", diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index 9d56b2b842..c887878ce3 100644 --- a/apps/app/src/lib/plugin-slots.ts +++ b/apps/app/src/lib/plugin-slots.ts @@ -4,6 +4,7 @@ import type { ExperimentalAppOverlayRegistration, PluginDiffRendererRegistration, PluginEnvironmentProviderInputsRegistration, + PluginMachineProviderInputsRegistration, PluginPendingInteractionRegistration, PluginFileOpenerRegistration, PluginHomepageSectionRegistration, @@ -53,6 +54,7 @@ export interface PluginRegistrationSet { providerIcons?: readonly PluginProviderIconRegistration[]; timelineRenderers?: readonly PluginTimelineRendererRegistration[]; environmentProviderInputs?: readonly PluginEnvironmentProviderInputsRegistration[]; + machineProviderInputs?: readonly PluginMachineProviderInputsRegistration[]; } interface PluginSlotBase { @@ -102,6 +104,8 @@ export interface PluginTimelineRendererSlot extends PluginTimelineRendererRegistration, PluginSlotBase {} export interface PluginEnvironmentProviderInputsSlot extends PluginEnvironmentProviderInputsRegistration, PluginSlotBase {} +export interface PluginMachineProviderInputsSlot + extends PluginMachineProviderInputsRegistration, PluginSlotBase {} export interface PluginSlotSnapshot { homepageSections: readonly PluginHomepageSectionSlot[]; @@ -125,6 +129,7 @@ export interface PluginSlotSnapshot { providerIcons: readonly PluginProviderIconSlot[]; timelineRenderers: readonly PluginTimelineRendererSlot[]; environmentProviderInputs: readonly PluginEnvironmentProviderInputsSlot[]; + machineProviderInputs: readonly PluginMachineProviderInputsSlot[]; } export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { @@ -149,6 +154,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { providerIcons: [], timelineRenderers: [], environmentProviderInputs: [], + machineProviderInputs: [], }; const registrationsByPluginId = new Map(); @@ -180,6 +186,7 @@ const SLOT_KINDS: readonly SlotKind[] = [ "providerIcons", "timelineRenderers", "environmentProviderInputs", + "machineProviderInputs", ]; type FlattenedPluginSlots = { @@ -235,6 +242,7 @@ function flattenRegistrations( providerIcons: stamp(set.providerIcons), timelineRenderers: stamp(set.timelineRenderers), environmentProviderInputs: stamp(set.environmentProviderInputs), + machineProviderInputs: stamp(set.machineProviderInputs), }; } diff --git a/apps/app/src/lib/queued-message-wait.test.ts b/apps/app/src/lib/queued-message-wait.test.ts index 14a0a3315c..12a8191501 100644 --- a/apps/app/src/lib/queued-message-wait.test.ts +++ b/apps/app/src/lib/queued-message-wait.test.ts @@ -60,7 +60,7 @@ describe("describeQueuedMessageWait", () => { it("names the absent machine a host-offline row is waiting on", () => { expect(describeWait({ kind: "host-offline", hostName: "M4" })).toBe( - "Waiting for M4 to reconnect", + "Waiting for M4 to be ready", ); }); diff --git a/apps/app/src/lib/queued-message-wait.ts b/apps/app/src/lib/queued-message-wait.ts index b7988d7c17..da23280194 100644 --- a/apps/app/src/lib/queued-message-wait.ts +++ b/apps/app/src/lib/queued-message-wait.ts @@ -132,7 +132,7 @@ export function describeQueuedMessageWait( case "provisioning": return "Waiting for workspace"; case "host-offline": - return `Waiting for ${args.waitingOn.hostName} to reconnect`; + return `Waiting for ${args.waitingOn.hostName} to be ready`; case "interaction": return "Waiting for your reply"; case "plugin": diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts index 9eae4606de..f6ddbffbc6 100644 --- a/apps/app/src/lib/system-config-atoms.ts +++ b/apps/app/src/lib/system-config-atoms.ts @@ -18,6 +18,12 @@ import { import { wsManager } from "./ws"; const unavailableSystemConfig: SystemConfigResponse = { + serverAccess: { + providers: [], + defaultProviderId: "direct", + effectiveUrl: null, + urlSource: null, + }, generalSettings: defaultAppSettings, keybindings: [], defaultKeybindings: [], diff --git a/apps/app/src/test/fixtures/system-config.ts b/apps/app/src/test/fixtures/system-config.ts index e53a535809..5c7f70dae6 100644 --- a/apps/app/src/test/fixtures/system-config.ts +++ b/apps/app/src/test/fixtures/system-config.ts @@ -11,6 +11,12 @@ export function makeSystemConfig( overrides: Partial = {}, ): SystemConfigResponse { return { + serverAccess: { + providers: [], + defaultProviderId: "direct", + effectiveUrl: null, + urlSource: null, + }, generalSettings: defaultAppSettings, keybindings: [], defaultKeybindings: [], diff --git a/apps/app/src/views/MachineSettingsView.stories.tsx b/apps/app/src/views/MachineSettingsView.stories.tsx new file mode 100644 index 0000000000..f6907e0e5f --- /dev/null +++ b/apps/app/src/views/MachineSettingsView.stories.tsx @@ -0,0 +1,193 @@ +import type { Host, MachineLifecycle } from "@bb/domain"; +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { MachineSettingsHeader } from "./MachineSettingsView"; +import { + MANUAL_MACHINE_PROVIDER, + MODAL_MACHINE_PROVIDER, +} from "../../.ladle/machine-story-fixtures"; +import { StoryCard, StoryRow } from "../../.ladle/story-card"; + +export default { + title: "settings/Machine page", +}; + +const noop = () => {}; +const now = Date.parse("2026-09-09T12:00:00Z"); +const WEEKS_AGO = now - 3 * 7 * 24 * 60 * 60_000; + +function lifecycle( + overrides: Partial = {}, +): MachineLifecycle { + return { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + ...overrides, + }; +} + +function sandbox(overrides: Partial = {}): Host { + return makeHost({ + id: "host_sandbox", + name: "Modal sandbox 3f9a", + machineProviderId: MODAL_MACHINE_PROVIDER.id, + createdAt: now - 4 * 24 * 60 * 60_000, + ...overrides, + }); +} + +function Row({ + host, + machineProvider = MODAL_MACHINE_PROVIDER, + ...overrides +}: { + host: Host; + machineProvider?: typeof MODAL_MACHINE_PROVIDER | null; +} & Partial[0]>) { + return ( +
+ +
+ ); +} + +export function Header() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/views/MachineSettingsView.test.tsx b/apps/app/src/views/MachineSettingsView.test.tsx index 696547c2f5..98b45c0bd5 100644 --- a/apps/app/src/views/MachineSettingsView.test.tsx +++ b/apps/app/src/views/MachineSettingsView.test.tsx @@ -28,8 +28,12 @@ vi.mock("@/lib/sdk", () => ({ hosts: { delete: vi.fn(), list: vi.fn(), + experimental_listProviders: vi.fn(), providerCliStatus: vi.fn(), + experimental_resume: vi.fn(), + experimental_retryCleanup: vi.fn(), retryUpdate: vi.fn(), + experimental_suspend: vi.fn(), update: vi.fn(), }, providers: { list: vi.fn() }, @@ -123,6 +127,7 @@ function renderView() { } function stubSupportingFetches(): void { + vi.mocked(sdk.hosts.experimental_listProviders).mockResolvedValue([]); vi.mocked(sdk.hosts.providerCliStatus).mockResolvedValue( providerCliStatusResponse(), ); @@ -195,7 +200,7 @@ describe("MachineSettingsView", () => { screen .getByRole("heading", { name: /dev-vm/u }) .querySelector("[data-icon]"), - ).toBeNull(); + ).not.toBeNull(); expect( screen .getByRole("heading", { name: "Machine information" }) @@ -359,6 +364,38 @@ describe("MachineSettingsView", () => { ).toBeDefined(); }); + it("describes ephemeral compute and snapshot deletion in the danger zone", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + host({ type: "ephemeral", machineProviderId: "modal-sandbox" }), + ]); + stubSupportingFetches(); + vi.mocked(sdk.hosts.experimental_listProviders).mockResolvedValue([ + { + id: "modal-sandbox", + displayName: "Modal Sandbox", + description: "Run a machine for development.", + icon: "Cloud", + logoUrl: null, + pluginId: "environment-modal-sandbox", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + }, + ]); + renderView(); + + expect( + await screen.findByText( + "Revokes dev-vm's access to this server. The compute and its saved snapshots are deleted. Its environments remain as read-only history.", + ), + ).toBeDefined(); + const heading = await screen.findByRole("heading", { name: "dev-vm" }); + expect(heading.querySelector('[data-icon="Cloud"]')).not.toBeNull(); + expect(heading.querySelector('[data-icon="Laptop"]')).toBeNull(); + expect(screen.queryByText("Modal Sandbox")).toBeNull(); + }); + it("shows client-local identity only when several machines need disambiguation", async () => { hostDaemon.localDaemonHostId = HOST_ID; hostDaemon.platform = "linux"; diff --git a/apps/app/src/views/MachineSettingsView.tsx b/apps/app/src/views/MachineSettingsView.tsx index 6d8a68499d..232fb84253 100644 --- a/apps/app/src/views/MachineSettingsView.tsx +++ b/apps/app/src/views/MachineSettingsView.tsx @@ -1,16 +1,25 @@ -import { useMemo, useState, type ReactNode } from "react"; +import { MachineLifecycleNoticeContent } from "@/components/machines/MachineLifecycleNotice"; +import { useMemo, useState, type ComponentProps, type ReactNode } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import type { Host, PermissionMode } from "@bb/domain"; +import type { SystemMachineProvider } from "@bb/server-contract"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { Button } from "@bb/shared-ui/button"; -import { DialogFooter, DialogHeader, DialogTitle } from "@bb/shared-ui/dialog"; -import { DialogDescription } from "@bb/shared-ui/dialog"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { Pill } from "@bb/shared-ui/pill"; import { ResourceOverflowMenu } from "@bb/shared-ui/resource-list"; -import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog"; +import { MachineLifecycleActions } from "@/components/machines/MachineLifecycleActions"; +import { + MachineRemoveDialog, + machineRemovalConsequences, +} from "@/components/machines/MachineRemoveDialog"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; +import { + machineStatusLabel, + machineStatusTone, +} from "@/components/machines/machine-status"; +import { MachineLabel } from "@/components/machines/MachineLabel"; import { PageShell } from "@/components/ui/page-shell.js"; import { SettingsBadge, @@ -21,12 +30,15 @@ import { import { appToast } from "@/components/ui/app-toast"; import { MachineRenameDialog } from "@/components/settings/MachineRenameDialog"; import { - useRemoveHost, useRenameHost, + useResumeHost, + useRetryHostCleanup, useRetryHostUpdate, + useSuspendHost, useUpdateHostPermissionCeiling, } from "@/hooks/mutations/host-mutations"; import { useHosts } from "@/hooks/queries/host-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useSystemConfig, @@ -78,12 +90,7 @@ function headerMeta({ platformLabel: string | null; now: number; }): string { - const parts: string[] = [host.status === "connected" ? "Online" : "Offline"]; - if (host.status !== "connected" && host.lastSeenAt !== null) { - parts.push( - `last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`, - ); - } + const parts: string[] = [machineStatusLabel({ host, now })]; if (platformLabel !== null) parts.push(platformLabel); parts.push( `paired ${formatRelativeTime({ timestamp: host.createdAt, now })}`, @@ -167,28 +174,119 @@ function DetailRow({ label, children }: DetailRowProps) { ); } +export function MachineSettingsHeader({ + host, + machineProvider, + platformLabel, + now, + isPrimary, + isThisMachine, + showPrimaryBadge, + lifecycleNotice, + lifecycleActionPending, + onSuspend, + onResume, + onRetryCleanup, + onRename, +}: { + host: Host; + machineProvider: SystemMachineProvider | null; + platformLabel: string | null; + now: number; + isPrimary: boolean; + isThisMachine: boolean; + showPrimaryBadge: boolean; + lifecycleNotice: ComponentProps< + typeof MachineLifecycleNoticeContent + >["notice"]; + lifecycleActionPending: boolean; + onSuspend: () => void; + onResume: () => void; + onRetryCleanup: () => void; + onRename: () => void; +}) { + return ( +
+ + + Machines + +
+
+
+

+ +

+ {isThisMachine ? This machine : null} + {showPrimaryBadge ? Primary : null} +
+
+ +

+ {headerMeta({ host, platformLabel, now })} +

+
+ +
+
+ + +
+
+
+ ); +} + export function MachineSettingsView() { const { hostId } = useParams<{ hostId: string }>(); const navigate = useNavigate(); const hostsQuery = useHosts(); + const { providers: machineProviders } = useSystemMachineProviders(); const systemConfig = useSystemConfig(); const { localDaemonHostId, platform: localDaemonPlatform } = useHostDaemon(); const sidebarNavigationQuery = useSidebarNavigation(); const updateInventory = useUpdateInventory(); const renameHost = useRenameHost(); - const removeHost = useRemoveHost(); const retryHostUpdate = useRetryHostUpdate(); + const suspendHost = useSuspendHost(); + const resumeHost = useResumeHost(); + const retryHostCleanup = useRetryHostCleanup(); const updatePermissionCeiling = useUpdateHostPermissionCeiling(); const [renameOpen, setRenameOpen] = useState(false); const [removeOpen, setRemoveOpen] = useState(false); const hosts = hostsQuery.data; const host = hosts?.find((candidate) => candidate.id === hostId) ?? null; + const lifecycleMessage = host?.lifecycle.message ?? null; + const lifecycleNotice = + host === null + ? null + : { phase: host.lifecycle.phase, message: lifecycleMessage }; const primaryHostId = systemConfig.data?.primaryHostId ?? null; const isPrimary = host !== null && host.id === primaryHostId; const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; const isThisMachine = showMachineIdentityBadges && host !== null && host.id === localDaemonHostId; + const machineProvider = + host?.machineProviderId === null || host?.machineProviderId === undefined + ? null + : (machineProviders?.find( + (provider) => provider.id === host.machineProviderId, + ) ?? null); const projects: MachineProject[] = useMemo(() => { const navigation = sidebarNavigationQuery.data?.projects ?? []; @@ -269,49 +367,28 @@ export function MachineSettingsView() { return (
-
- - - Machines - -
-
-
-

- {host.name} -

- {isThisMachine ? ( - This machine - ) : null} - {showMachineIdentityBadges && isPrimary ? ( - Primary - ) : null} -
-
- -

- {headerMeta({ host, platformLabel, now })} -

-
-
- { - renameHost.reset(); - setRenameOpen(true); - }, - }, - ]} - /> -
-
+ suspendHost.mutate(host.id)} + onResume={() => resumeHost.mutate(host.id)} + onRetryCleanup={() => retryHostCleanup.mutate(host.id)} + onRename={() => { + renameHost.reset(); + setRenameOpen(true); + }} + /> @@ -463,10 +540,7 @@ export function MachineSettingsView() { variant="destructive" size="sm" disabled={isPrimary} - onClick={() => { - removeHost.reset(); - setRemoveOpen(true); - }} + onClick={() => setRemoveOpen(true)} > Remove machine @@ -497,46 +571,11 @@ export function MachineSettingsView() { } /> - { - if (!open && !removeHost.isPending) setRemoveOpen(false); - }} - > - - Remove {host.name}? - - This revokes {host.name}'s access to this server. Project checkouts - stay on its disk, but its environments become read-only history and - it can't run new work until it's paired again. - - - {removeHost.isError ? ( -

- {getMutationErrorMessage({ - error: removeHost.error, - fallbackMessage: `Couldn't remove ${host.name}.`, - })} -

- ) : null} - - - -
+ navigate(getSettingsRoutePath("machines"))} + /> ); } diff --git a/apps/app/src/views/ProjectDetailSettingsView.tsx b/apps/app/src/views/ProjectDetailSettingsView.tsx index 30e0050a5b..764ca00467 100644 --- a/apps/app/src/views/ProjectDetailSettingsView.tsx +++ b/apps/app/src/views/ProjectDetailSettingsView.tsx @@ -51,7 +51,7 @@ import { isHostPathMissing, useHostPathExistence, } from "@/hooks/queries/host-path-queries"; -import { selectPersistentHosts, useHosts } from "@/hooks/queries/host-queries"; +import { selectHosts, useHosts } from "@/hooks/queries/host-queries"; import { useProjectDefaultExecutionOptions } from "@/hooks/queries/project-default-execution-options-query"; import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query"; import { useSystemConfig } from "@/hooks/queries/system-queries"; @@ -232,7 +232,7 @@ export function ProjectDetailSettingsView() { const sources = useMemo(() => projectSources ?? [], [projectSources]); const projectName = project?.name ?? ""; const hosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), + () => selectHosts(hostsQuery.data), [hostsQuery.data], ); const primaryHostId = systemConfig.data?.primaryHostId ?? null; diff --git a/apps/app/src/views/RootComposeMobileRecents.test.tsx b/apps/app/src/views/RootComposeMobileRecents.test.tsx index 211b72e127..7832849c21 100644 --- a/apps/app/src/views/RootComposeMobileRecents.test.tsx +++ b/apps/app/src/views/RootComposeMobileRecents.test.tsx @@ -18,6 +18,7 @@ import { import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; const personalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", icon: "Folder", diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts index 28e1774ba4..16192c494f 100644 --- a/apps/app/src/views/RootComposeView.test.ts +++ b/apps/app/src/views/RootComposeView.test.ts @@ -157,6 +157,7 @@ describe("resolveNewThreadProjectDefaultsState", () => { describe("resolveNewThreadSubmitDisabledReason", () => { const readyState = { environmentProviderInputsBlocker: null, + environmentSetupRequiredReason: null, isCopyingAttachments: false, isLoadingModels: false, isSubmitting: false, @@ -171,6 +172,18 @@ describe("resolveNewThreadSubmitDisabledReason", () => { submissionEnvironmentUnavailable: false, } satisfies ResolveNewThreadSubmitDisabledReasonArgs; + it("blocks the send while the selected environment needs setting up", () => { + expect( + resolveNewThreadSubmitDisabledReason({ + ...readyState, + environmentSetupRequiredReason: + "Modal Sandbox is not configured: set tokenId, tokenSecret in the plugin's settings.", + }), + ).toBe( + "Modal Sandbox is not configured: set tokenId, tokenSecret in the plugin's settings.", + ); + }); + it.each< [ label: string, @@ -275,6 +288,7 @@ function makeProjectSource(hostId = "host_1"): ProjectSource { function makeProjectProvider(id: string): SystemEnvironmentProvider { return { + machineProviderId: null, id, displayName: id, icon: null, @@ -298,6 +312,7 @@ function makeProjectlessProvider( projectless: boolean, ): SystemEnvironmentProvider { return { + machineProviderId: null, id, displayName: id, icon: null, diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index d9f33b1c53..1126832e9d 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -677,7 +677,7 @@ function RootComposeSurface({ panelThreadId: rootPanelThreadId, selectedProviderId, promptDraft, - promptBoxRef, + focusPromptBox, pluginComposerHost: sharedPluginComposerHost, textEffects: promptTextEffects, isSubmitting, @@ -708,17 +708,17 @@ function RootComposeSurface({ () => subscribeComposerFocusRequests(promptDraft.storageKey, () => { setStartedComposing(true); - window.requestAnimationFrame(() => promptBoxRef.current?.focusEnd()); + window.requestAnimationFrame(focusPromptBox); }), - [promptBoxRef, promptDraft.storageKey, setStartedComposing], + [focusPromptBox, promptDraft.storageKey, setStartedComposing], ); const handleRootPanelSelectionAddToChat = useCallback( (text: string, attachments?: readonly PromptDraftAttachment[]) => { promptDraft.addQuote(text, attachments); setStartedComposing(true); - window.requestAnimationFrame(() => promptBoxRef.current?.focusEnd()); + window.requestAnimationFrame(focusPromptBox); }, - [promptBoxRef, promptDraft, setStartedComposing], + [focusPromptBox, promptDraft, setStartedComposing], ); const setPromptDraft = promptDraft.setDraft; @@ -831,11 +831,9 @@ function RootComposeSurface({ location.state.focusPrompt === true; useEffect(() => { if (!shouldFocusPrompt || isPointerCoarse) return; - const handle = window.requestAnimationFrame(() => { - promptBoxRef.current?.focusEnd(); - }); + const handle = window.requestAnimationFrame(focusPromptBox); return () => window.cancelAnimationFrame(handle); - }, [isPointerCoarse, location.key, promptBoxRef, shouldFocusPrompt]); + }, [focusPromptBox, isPointerCoarse, location.key, shouldFocusPrompt]); const mobileRecentThreads = useMemo( () => buildMobileRecentThreads({ sidebarNavigation }), @@ -1817,14 +1815,12 @@ function RootComposeSurface({ if (!startedComposing) return; if (isProviderCliVersionBlocked) return; if (isPointerCoarse) return; - const handle = window.requestAnimationFrame(() => { - promptBoxRef.current?.focusEnd(); - }); + const handle = window.requestAnimationFrame(focusPromptBox); return () => window.cancelAnimationFrame(handle); }, [ isProviderCliVersionBlocked, isPointerCoarse, - promptBoxRef, + focusPromptBox, startedComposing, ]); const [machineSetupTarget, setMachineSetupTarget] = @@ -1863,10 +1859,8 @@ function RootComposeSurface({ ); const handleCancelForkDraft = useCallback(() => { setForkSeed(null); - window.requestAnimationFrame(() => { - promptBoxRef.current?.focusEnd(); - }); - }, [promptBoxRef, setForkSeed]); + window.requestAnimationFrame(focusPromptBox); + }, [focusPromptBox, setForkSeed]); const promptHeader = useMemo(() => { if (forkSeed === null) { diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index 9049efc54e..97beb704b1 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -1,3 +1,5 @@ +import { MachineEnvironmentSettings } from "@/components/settings/MachineEnvironmentSettings"; +import { MachineAccessSettings } from "@/components/settings/MachineAccessSettings"; import { useMemo, useRef, useState, type ReactNode } from "react"; import { Navigate, @@ -1203,7 +1205,26 @@ export function SettingsView() { } else if (activeSection === "projects") { content = ; } else if (activeSection === "machines") { - content = ; + content = ( + <> + + +
+ + Advanced settings + + + +
+ + ); } else if (activeSection === "updates") { content = ( { + it("submits a composition without a host or machine selector", () => { + const modal = { + ...environmentProviders[1], + id: "modal-sandbox", + machineProviderId: "modal-sandbox", + }; + expect( + resolveRootComposeThreadEnvironment({ + projectId, + environmentValue: "provider:modal-sandbox", + environmentProviders: [modal], + providerHostId: null, + providerMachine: null, + }), + ).toEqual({ + type: "provider", + environmentProviderId: "modal-sandbox", + inputs: null, + }); + }); + it("carries a provider's inputs verbatim with the picked machine", () => { expect( resolveRootComposeThreadEnvironment({ diff --git a/apps/app/src/views/root-compose-thread-environment.ts b/apps/app/src/views/root-compose-thread-environment.ts index af7f4e21b3..9790986d46 100644 --- a/apps/app/src/views/root-compose-thread-environment.ts +++ b/apps/app/src/views/root-compose-thread-environment.ts @@ -31,14 +31,14 @@ export function resolveRootComposeThreadEnvironment( (args.providerHostId === undefined || args.providerHostId === null ? null : { type: "existing" as const, hostId: args.providerHostId }); - if (machine === null) return null; + if (machine === null && !provider.machineProviderId) return null; const inputs = provider.inputs === null ? null : (args.providerInputs ?? null); if (provider.inputs !== null && inputs === null) return null; return { type: "provider", environmentProviderId: provider.id, - machine, + ...(machine === null ? {} : { machine }), inputs, }; } diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx index 559a1cb002..b07c01ac7e 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // @vitest-environment jsdom import type { @@ -700,6 +701,8 @@ interface RenderPromptAreaOptions { thread?: ThreadWithRuntime; } +let testQueryClient: QueryClient; + function buildPromptAreaElement({ activePromptMode = null, activeWorkflows = [], @@ -713,40 +716,42 @@ function buildPromptAreaElement({ thread = makeThread(), }: RenderPromptAreaOptions = {}) { return ( - null} - sendMessage={{ - isPending: false, - mutateAsync: vi.fn(), - }} - sentMessageEdit={sentMessageEdit} - steerActiveThreadOnEnter={false} - thread={thread} - workspaceChangedFilesSection={null} - workspaceStatusPending={false} - /> + + null} + sendMessage={{ + isPending: false, + mutateAsync: vi.fn(), + }} + sentMessageEdit={sentMessageEdit} + steerActiveThreadOnEnter={false} + thread={thread} + workspaceChangedFilesSection={null} + workspaceStatusPending={false} + /> + ); } @@ -755,6 +760,9 @@ function renderPromptArea(options: RenderPromptAreaOptions = {}) { } beforeEach(() => { + testQueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); mocks.defaultExecutionOptions = null; mocks.pluginComposerHost = null; mocks.promptDraft.text = ""; diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 3ea1490aa0..f28e88f15b 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1,3 +1,4 @@ +import { ThreadMachineStatus } from "@/components/promptbox/banner/ThreadMachineStatus"; import { useCallback, useEffect, @@ -64,6 +65,8 @@ import { type QueuedMessageInlineEditor, } from "@/components/promptbox/banner/QueuedMessagesList"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; +import type { MachineLabelHost } from "@/components/machines/MachineLabel"; +import type { MachineProviderPresentation } from "@/components/plugin/MachineProviderIcon"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { useComposerTextEffects } from "@/lib/composer-text-effects"; import { useLatestRef } from "@/hooks/useLatestRef"; @@ -152,6 +155,8 @@ interface ThreadDetailPromptAreaProps { environmentCompactLabel?: string; environmentGoneStatus: "destroyed" | null; environmentHostId?: string; + environmentHost?: MachineLabelHost; + environmentMachineProvider?: MachineProviderPresentation | null; environmentIcon?: IconName; environmentLabel?: string; environmentTypeLabel?: string; @@ -345,6 +350,8 @@ export function ThreadDetailPromptArea({ environmentCompactLabel, environmentGoneStatus, environmentHostId, + environmentHost, + environmentMachineProvider, environmentIcon, environmentLabel, environmentTypeLabel, @@ -1235,8 +1242,10 @@ export function ThreadDetailPromptArea({ projectName={projectName} environmentLabel={environmentLabel} environmentCompactLabel={environmentCompactLabel} + environmentHost={environmentHost} environmentIcon={environmentIcon} environmentTypeLabel={environmentTypeLabel} + environmentMachineProvider={environmentMachineProvider} environmentCheckout={environmentCheckout} onCreateNewThreadInEnvironment={onCreateNewThreadInEnvironment} /> @@ -1244,8 +1253,10 @@ export function ThreadDetailPromptArea({ [ environmentCheckout, environmentCompactLabel, + environmentHost, environmentIcon, environmentLabel, + environmentMachineProvider, environmentTypeLabel, onCreateNewThreadInEnvironment, projectName, @@ -1553,6 +1564,11 @@ export function ThreadDetailPromptArea({ isExpanded={isTodoExpanded} onToggle={() => setIsTodoExpanded((value) => !value)} /> + {environmentHostId && + thread.archivedAt === null && + environmentGoneStatus === null ? ( + + ) : null} host.id === environmentHostId) ?? null; }, [environment?.hostId, hostsQuery.data]); - const hasMultipleMachines = selectPersistentHosts(hostsQuery.data).length > 1; + const hasMultipleMachines = selectHosts(hostsQuery.data).length > 1; const threadEnvironmentHost = shouldShowEnvironmentHostIdentity( hasMultipleMachines, thread?.projectId === PERSONAL_PROJECT_ID, @@ -1229,6 +1230,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { }); const { providers: registeredEnvironmentProviders } = useSystemEnvironmentProviders(); + const { providers: registeredMachineProviders } = useSystemMachineProviders(); const environmentMergeBaseBranch = resolveEnvironmentMergeBaseBranch(environment); const { @@ -2407,6 +2409,15 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { isProjectless: thread.projectId === PERSONAL_PROJECT_ID, }) : undefined; + const composerEnvironmentHost = + resolvedThreadEnvironmentHost !== null && + environment?.name === null && + composerEnvironmentSummary?.label === resolvedThreadEnvironmentHost?.name + ? resolvedThreadEnvironmentHost + : undefined; + const composerEnvironmentMachineProvider = registeredMachineProviders?.find( + (provider) => provider.id === composerEnvironmentHost?.machineProviderId, + ); const isThreadOnReusableEnvironment = environment !== undefined && environment.status === "ready" && @@ -2528,8 +2539,10 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { contextWindowUsage={contextWindowUsage} environmentCheckout={threadCheckoutDisplay} environmentCompactLabel={composerEnvironmentSummary?.compactLabel} + environmentHost={composerEnvironmentHost} environmentIcon={composerEnvironmentSummary?.icon} environmentLabel={composerEnvironmentSummary?.label} + environmentMachineProvider={composerEnvironmentMachineProvider} environmentTypeLabel={composerEnvironmentSummary?.typeLabel} environmentGoneStatus={threadEnvironmentGoneStatus} environmentHostId={environment?.hostId} diff --git a/apps/cli/src/__tests__/command-output/machine-environment.test.ts b/apps/cli/src/__tests__/command-output/machine-environment.test.ts new file mode 100644 index 0000000000..9606a6ece5 --- /dev/null +++ b/apps/cli/src/__tests__/command-output/machine-environment.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerMachineCommands } from "../../commands/machine.js"; +import { + collectLogPayloads, + runCommand, + setupCommandOutputTestEnvironment, +} from "../helpers/command-output-harness.js"; + +describe("machine env commands", () => { + setupCommandOutputTestEnvironment(); + const result = { + builtInGit: { status: "overridden", statusMessage: "User override" }, + variables: [{ name: "GH_TOKEN", secret: true, value: null, note: null }], + }; + const register = (program: import("commander").Command) => + registerMachineCommands(program, () => "http://server"); + it("sends a secret from stdin, lists metadata, and unsets through SDK routes", async () => { + const requests: Request[] = []; + vi.mocked(fetch).mockImplementation(async (input, init) => { + requests.push(new Request(input, init)); + return new Response(JSON.stringify(result), { + headers: { "Content-Type": "application/json" }, + }); + }); + const stdin = vi + .spyOn(process.stdin, Symbol.asyncIterator) + .mockImplementation(async function* () { + yield Buffer.from("cli-secret\n"); + }); + const wasTty = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + try { + await runCommand( + ["machine", "env", "set", "GH_TOKEN", "--json"], + register, + ); + expect(collectLogPayloads(vi.mocked(console.error))).toEqual([]); + expect(await requests[1].json()).toEqual({ + variables: [{ name: "GH_TOKEN", value: "cli-secret", note: null }], + }); + expect(requests[1].method).toBe("PUT"); + await runCommand(["machine", "env", "list", "--json"], register); + await runCommand( + ["machine", "env", "unset", "GH_TOKEN", "--json"], + register, + ); + expect(requests.map((request) => request.method)).toEqual([ + "GET", + "PUT", + "GET", + "GET", + "PUT", + ]); + expect(await requests[4].json()).toEqual({ variables: [] }); + expect(requests[4].url).toBe( + "http://server/api/v1/settings/machine-environment", + ); + expect( + collectLogPayloads(vi.mocked(console.log)).join("\n"), + ).not.toContain("cli-secret"); + } finally { + stdin.mockRestore(); + Object.defineProperty(process.stdin, "isTTY", { + value: wasTty, + configurable: true, + }); + } + }); +}); diff --git a/apps/cli/src/__tests__/command-output/machine.test.ts b/apps/cli/src/__tests__/command-output/machine.test.ts index 2ede6ff7e2..75a86f38d2 100644 --- a/apps/cli/src/__tests__/command-output/machine.test.ts +++ b/apps/cli/src/__tests__/command-output/machine.test.ts @@ -19,6 +19,14 @@ const hosts: Host[] = [ name: "workstation", type: "persistent", status: "connected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: 1_700_000_000_000, lastRejectedProtocolVersion: null, @@ -30,6 +38,14 @@ const hosts: Host[] = [ name: "laptop", type: "persistent", status: "disconnected", + machineProviderId: "ssh", + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, @@ -38,12 +54,101 @@ const hosts: Host[] = [ }, ]; +const creating: Host = { + ...hosts[1]!, + lifecycle: { + ...hosts[1]!.lifecycle, + phase: "creating", + message: "Creating SSH machine…", + }, +}; + describe("bb machine command output", () => { setupCommandOutputTestEnvironment(); const register: CommandRegistrar = (program) => registerMachineCommands(program, () => "http://server"); + it("polls the creating host until it becomes active", async () => { + const get = vi.fn(async () => hosts[1]); + stubServerApi({ + "v1.hosts.$post": vi.fn(async () => creating), + "v1.hosts.:id.enrollment-command.$get": vi.fn(async () => null), + "v1.hosts.:id.$get": get, + }); + await runCommand(["machine", "create", "--provider", "ssh"], register); + expect(get).toHaveBeenCalledOnce(); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "Machine laptop created", + ]); + }); + + it("prints the host from --no-wait without polling", async () => { + const get = vi.fn(async () => hosts[1]); + stubServerApi({ + "v1.hosts.$post": vi.fn(async () => creating), + "v1.hosts.:id.$get": get, + }); + await runCommand( + ["machine", "create", "--provider", "ssh", "--no-wait", "--json"], + register, + ); + expect(get).not.toHaveBeenCalled(); + expect(JSON.parse(collectLogPayloads(vi.mocked(console.log))[0])).toEqual( + creating, + ); + }); + + it("prints the manual enrollment command while following", async () => { + const command = "curl -fsSL https://machine.example/install.sh | sh"; + stubServerApi({ + "v1.hosts.$post": vi.fn(async () => creating), + "v1.hosts.:id.enrollment-command.$get": vi.fn(async () => ({ + command, + expiresAt: Date.now() + 60_000, + })), + "v1.hosts.:id.$get": vi.fn(async () => hosts[1]), + }); + await runCommand(["machine", "create", "--provider", "manual"], register); + expect(collectLogPayloads(vi.mocked(console.error))).toContain(command); + }); + + it("rejects malformed JSON without submitting inputs", async () => { + const create = vi.fn(async () => creating); + stubServerApi({ "v1.hosts.$post": create }); + await expect( + runCommand( + [ + "machine", + "create", + "--provider", + "ssh", + "--inputs", + '{"credential":"secret"', + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + expect(create).not.toHaveBeenCalled(); + }); + + it("aborts the create request on SIGINT", async () => { + const create = vi.fn( + async (_request: object, options: { init: { signal: AbortSignal } }) => { + process.emit("SIGINT"); + expect(options.init.signal.aborted).toBe(true); + throw new Error("aborted"); + }, + ); + stubServerApi({ "v1.hosts.$post": create }); + await expect( + runCommand(["machine", "create", "--provider", "ssh"], register), + ).rejects.toThrow("process.exit:130"); + expect(collectLogPayloads(vi.mocked(console.error))).toEqual([ + "Error: Stopped following; creation continues. Use bb machine remove to cancel.", + ]); + }); + it("bb machine list --json prints the raw host list", async () => { stubServerApi({ "v1.hosts.$get": vi.fn(async () => hosts) }); @@ -62,7 +167,7 @@ describe("bb machine command output", () => { expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ "", - "Name ID Status Last seen\n----------- ------------ ------------ ---------\nworkstation host-primary connected 2m ago\n----------- ------------ ------------ ---------\nlaptop host-remote disconnected never", + "Name ID Status Provider Last seen\n----------- ------------ ------------ ------------- ---------\nworkstation host-primary connected user-enrolled 2m ago\n----------- ------------ ------------ ------------- ---------\nlaptop host-remote disconnected ssh never", "", ]); }); @@ -81,6 +186,86 @@ describe("bb machine command output", () => { "Machine host-remote update retry requested", ]); }); + + it.each([ + ["suspend", "v1.hosts.:id.suspend.$post", "suspended"], + ["resume", "v1.hosts.:id.resume.$post", "resumed"], + ["retry-cleanup", "v1.hosts.:id.retry-cleanup.$post", "cleanup retried"], + ] as const)( + "bb machine %s resolves the machine and invokes the lifecycle action", + async (command, route, message) => { + const lifecycleAction = vi.fn(async () => + command === "retry-cleanup" + ? { ok: true as const } + : { + ...hosts[1]!, + lifecycle: { + ...hosts[1]!.lifecycle, + phase: + command === "suspend" + ? ("suspended" as const) + : ("active" as const), + }, + }, + ); + stubServerApi({ + "v1.hosts.$get": vi.fn(async () => hosts), + [route]: lifecycleAction, + }); + + await runCommand(["machine", command, "laptop"], register); + + expect(lifecycleAction).toHaveBeenCalledWith({ + param: { id: "host-remote" }, + }); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + `Machine host-remote ${message}`, + ]); + }, + ); + + it("polls the host phase before reporting lifecycle completion", async () => { + vi.useFakeTimers(); + const suspend = vi.fn(async () => ({ + ...hosts[1]!, + lifecycle: { ...hosts[1]!.lifecycle, phase: "suspending" as const }, + })); + const get = vi.fn(async () => ({ + ...hosts[1]!, + lifecycle: { ...hosts[1]!.lifecycle, phase: "suspended" as const }, + })); + stubServerApi({ + "v1.hosts.$get": vi.fn(async () => hosts), + "v1.hosts.:id.$get": get, + "v1.hosts.:id.suspend.$post": suspend, + }); + + const command = runCommand(["machine", "suspend", "laptop"], register); + await vi.waitFor(() => expect(suspend).toHaveBeenCalledOnce()); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([]); + await vi.advanceTimersByTimeAsync(500); + await command; + + expect(get).toHaveBeenCalledWith({ param: { id: "host-remote" } }); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "Machine host-remote suspended", + ]); + }); + + it("bb machine remove resolves and removes a provider machine", async () => { + const remove = vi.fn(async () => undefined); + stubServerApi({ + "v1.hosts.$get": vi.fn(async () => hosts), + "v1.hosts.:id.$delete": remove, + }); + + await runCommand(["machine", "remove", "laptop", "--yes"], register); + + expect(remove).toHaveBeenCalledWith({ param: { id: "host-remote" } }); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "Machine host-remote removed", + ]); + }); }); describe("machine selection", () => { diff --git a/apps/cli/src/__tests__/command-output/project.test.ts b/apps/cli/src/__tests__/command-output/project.test.ts index ad1692695b..2e196bd3ac 100644 --- a/apps/cli/src/__tests__/command-output/project.test.ts +++ b/apps/cli/src/__tests__/command-output/project.test.ts @@ -285,7 +285,6 @@ describe("bb project command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -430,7 +429,6 @@ describe("bb project command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -500,7 +498,6 @@ describe("bb project command output", () => { { id: "host-primary", name: "workstation", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -536,7 +533,6 @@ describe("bb project command output", () => { { id: "host-builder-1", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -545,7 +541,6 @@ describe("bb project command output", () => { { id: "host-builder-2", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -580,7 +575,6 @@ describe("bb project command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "disconnected", lastSeenAt: 1, createdAt: 1, @@ -645,7 +639,6 @@ describe("bb project command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -696,7 +689,6 @@ describe("bb project command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, diff --git a/apps/cli/src/__tests__/command-output/provider.test.ts b/apps/cli/src/__tests__/command-output/provider.test.ts index 1846252a52..1e06ff9054 100644 --- a/apps/cli/src/__tests__/command-output/provider.test.ts +++ b/apps/cli/src/__tests__/command-output/provider.test.ts @@ -44,7 +44,6 @@ describe("bb provider command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, lastRejectedProtocolVersion: null, diff --git a/apps/cli/src/__tests__/command-output/settings.test.ts b/apps/cli/src/__tests__/command-output/settings.test.ts index 79192748d3..20d1a53e19 100644 --- a/apps/cli/src/__tests__/command-output/settings.test.ts +++ b/apps/cli/src/__tests__/command-output/settings.test.ts @@ -34,6 +34,27 @@ describe("bb settings commands", () => { }); }); + it("disables automatic machine Git credentials despite the legacy response alias", async () => { + const put = vi.fn(async ({ json }) => json); + stubServerApi({ + "v1.system.config.$get": vi.fn(async () => ({ + generalSettings: { + ...defaultAppSettings, + showUnhandledProviderEvents: false, + }, + experiments: defaultExperiments, + })), + "v1.settings.general.$put": put, + }); + await runCommand( + ["settings", "general", "machineGitCredentialsEnabled", "false"], + register, + ); + expect(put).toHaveBeenCalledWith({ + json: { ...defaultAppSettings, machineGitCredentialsEnabled: false }, + }); + }); + it("rejects an unknown general setting key", async () => { stubServerApi({ "v1.system.config.$get": vi.fn(async () => ({ @@ -139,7 +160,6 @@ describe("bb settings commands", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, lastRejectedProtocolVersion: null, diff --git a/apps/cli/src/__tests__/command-output/terminal.test.ts b/apps/cli/src/__tests__/command-output/terminal.test.ts index 506de973df..96dd83609e 100644 --- a/apps/cli/src/__tests__/command-output/terminal.test.ts +++ b/apps/cli/src/__tests__/command-output/terminal.test.ts @@ -40,7 +40,6 @@ function makeHost(overrides: Record = {}) { return { id: "host-1", name: "laptop", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, diff --git a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts index e1c6bc9aaf..00c0a582f5 100644 --- a/apps/cli/src/__tests__/command-output/thread-spawn.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-spawn.test.ts @@ -885,7 +885,6 @@ describe("bb thread spawn command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -934,7 +933,6 @@ describe("bb thread spawn command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, @@ -984,7 +982,6 @@ describe("bb thread spawn command output", () => { { id: "host-remote", name: "builder", - type: "persistent", status: "connected", lastSeenAt: 1, createdAt: 1, diff --git a/apps/cli/src/__tests__/command-output/updates.test.ts b/apps/cli/src/__tests__/command-output/updates.test.ts index 2692b97a66..c72e55570d 100644 --- a/apps/cli/src/__tests__/command-output/updates.test.ts +++ b/apps/cli/src/__tests__/command-output/updates.test.ts @@ -14,8 +14,16 @@ const hosts: Host[] = [ { id: "host-primary", name: "workstation", - status: "connected", type: "persistent", + status: "connected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: 1_700_000_000_000, lastRejectedProtocolVersion: null, @@ -25,8 +33,16 @@ const hosts: Host[] = [ { id: "host-remote", name: "laptop", - status: "disconnected", type: "persistent", + status: "disconnected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/apps/cli/src/commands/machine-enrollment.test.ts b/apps/cli/src/commands/machine-enrollment.test.ts new file mode 100644 index 0000000000..4e80dd0017 --- /dev/null +++ b/apps/cli/src/commands/machine-enrollment.test.ts @@ -0,0 +1,178 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { enrollMachine } from "./machine-enrollment.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); +const bundle = () => ({ + hostId: "host_test", + serverUrl: "https://server.example", + credential: "private-bootstrap", + expiresAt: Date.now() + 60_000, +}); +async function harness() { + const dir = await mkdtemp(join(tmpdir(), "bb-machine-enrollment-test-")); + directories.push(dir); + const fetchFn = vi.fn(async () => + Response.json( + { hostId: "host_test", hostKey: "private-durable" }, + { status: 201 }, + ), + ); + const env: NodeJS.ProcessEnv = { + BB_DATA_DIR: dir, + BB_ENROLLMENT: JSON.stringify(bundle()), + PATH: "/nonexistent", + }; + return { + dir, + fetchFn, + env, + run: () => + enrollMachine( + { bootstrapEnv: "BB_ENROLLMENT" }, + { env, fetchFn, homeDir: dir }, + ), + }; +} + +describe("machine enroll", () => { + it("retries a lost exchange with replacement bootstrap while preserving the reserved identity", async () => { + const h = await harness(); + h.fetchFn.mockRejectedValueOnce(new Error("Response lost")); + await expect(h.run()).rejects.toThrow("Could not exchange"); + await expect(readFile(join(h.dir, "auth.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + h.env.BB_ENROLLMENT = JSON.stringify({ + ...bundle(), + credential: "replacement-bootstrap", + }); + await expect(h.run()).resolves.toEqual({ hostId: "host_test" }); + expect(h.fetchFn).toHaveBeenCalledTimes(2); + expect(h.fetchFn.mock.calls[1]?.[1]?.headers).toMatchObject({ + authorization: "Bearer replacement-bootstrap", + }); + expect((await stat(join(h.dir, "auth.json"))).mode & 0o777).toBe(0o600); + }); + + it("exchanges through authorization, persists private credentials, and no-ops on same identity with expired material", async () => { + const h = await harness(); + expect(await h.run()).toEqual({ hostId: "host_test" }); + expect(h.env.BB_ENROLLMENT).toBeUndefined(); + expect(h.fetchFn.mock.calls[0]?.[1]?.headers).toMatchObject({ + authorization: "Bearer private-bootstrap", + }); + expect((await stat(join(h.dir, "auth.json"))).mode & 0o777).toBe(0o600); + const port = Number( + (await readFile(join(h.dir, "host-daemon-port"), "utf8")).trim(), + ); + expect(port).toBeGreaterThan(0); + expect(port).toBeLessThanOrEqual(65535); + h.env.BB_ENROLLMENT = JSON.stringify({ ...bundle(), expiresAt: 1 }); + expect(await h.run()).toEqual({ hostId: "host_test" }); + expect(h.fetchFn).toHaveBeenCalledOnce(); + }); + + it("refuses a different host or server before exchanging credentials", async () => { + const h = await harness(); + await writeFile( + join(h.dir, "auth.json"), + JSON.stringify({ hostId: "host_other", hostKey: "existing" }), + ); + await expect(h.run()).rejects.toThrow("different machine identity"); + expect(h.fetchFn).not.toHaveBeenCalled(); + expect(await readFile(join(h.dir, "auth.json"), "utf8")).toContain( + "existing", + ); + }); + + it("rejects invalid and expired bundles without exposing their input", async () => { + const h = await harness(); + h.env.BB_ENROLLMENT = '{"credential":"do-not-echo"'; + await expect(h.run()).rejects.toThrow( + /^Invalid machine enrollment bootstrap$/, + ); + h.env.BB_ENROLLMENT = JSON.stringify({ ...bundle(), version: 2 }); + await expect(h.run()).rejects.toThrow( + /^Invalid machine enrollment bootstrap$/, + ); + h.env.BB_ENROLLMENT = JSON.stringify({ ...bundle(), expiresAt: 1 }); + await expect(h.run()).rejects.toThrow("expired"); + expect(h.fetchFn).not.toHaveBeenCalled(); + }); + + it("suppresses secret-bearing remote errors and permits a retry", async () => { + const h = await harness(); + h.fetchFn.mockRejectedValueOnce(new Error("private-bootstrap")); + await expect(h.run()).rejects.toThrow( + /^Could not exchange machine enrollment credential$/, + ); + h.env.BB_ENROLLMENT = JSON.stringify(bundle()); + await expect(h.run()).resolves.toEqual({ hostId: "host_test" }); + }); + + it("persists provider headers and sends them directly on enrollment without redemption", async () => { + const h = await harness(); + const headers = { "x-access-token": "private-provider-header" }; + h.env.BB_ENROLLMENT = JSON.stringify({ ...bundle(), headers }); + await h.run(); + expect(h.fetchFn).toHaveBeenCalledOnce(); + expect(String(h.fetchFn.mock.calls[0]?.[0])).toBe( + "https://server.example/internal/hosts/enroll", + ); + expect(h.fetchFn.mock.calls[0]?.[1]?.headers).toMatchObject(headers); + expect( + JSON.parse(await readFile(join(h.dir, "config.json"), "utf8")), + ).toMatchObject({ serverHeaders: headers }); + }); +}); + +it("accepts delivered direct and Connect bundles from file and environment", async () => { + for (const kind of ["direct", "connect"] as const) { + for (const source of ["file", "env"]) { + const h = await harness(); + const headers = + kind === "connect" + ? { "x-bb-connect-machine": "private-connect" } + : undefined; + const value = { + ...bundle(), + serverUrl: "https://test.getbb.app", + headers, + }; + const path = join(h.dir, "bootstrap.json"); + await writeFile(path, JSON.stringify(value)); + h.env.BB_ENROLLMENT = JSON.stringify(value); + h.fetchFn.mockImplementation(async () => + Response.json( + { hostId: "host_test", hostKey: "private-durable" }, + { status: 201 }, + ), + ); + await expect( + enrollMachine( + source === "file" + ? { bootstrapFile: path } + : { bootstrapEnv: "BB_ENROLLMENT" }, + { env: h.env, homeDir: h.dir, fetchFn: h.fetchFn }, + ), + ).resolves.toEqual({ hostId: "host_test" }); + const enroll = h.fetchFn.mock.calls.find(([url]) => + String(url).includes("/internal/hosts/enroll"), + ); + expect( + new Headers(enroll?.[1]?.headers).get("x-bb-connect-machine"), + ).toBe(kind === "connect" ? "private-connect" : null); + expect(h.fetchFn).toHaveBeenCalledOnce(); + } + } +}); diff --git a/apps/cli/src/commands/machine-enrollment.ts b/apps/cli/src/commands/machine-enrollment.ts new file mode 100644 index 0000000000..303df08d1a --- /dev/null +++ b/apps/cli/src/commands/machine-enrollment.ts @@ -0,0 +1,249 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { + mkdir, + readFile, + rename, + rm, + writeFile, + symlink, + access, +} from "node:fs/promises"; +import { homedir, hostname } from "node:os"; +import { join, resolve } from "node:path"; +import { createServer } from "node:net"; +import { z } from "zod"; + +const serverUrlSchema = z + .string() + .url() + .refine((value) => { + const url = new URL(value); + return ( + ["http:", "https:"].includes(url.protocol) && + !url.username && + !url.password && + !url.search && + !url.hash + ); + }); +const bootstrapSchema = z.strictObject({ + hostId: z.string().min(1), + serverUrl: serverUrlSchema, + headers: z.record(z.string(), z.string()).optional(), + credential: z.string().min(1), + expiresAt: z.number().finite().positive(), +}); +const configSchema = z.looseObject({ + serverUrl: serverUrlSchema.optional(), + serverHeaders: z.record(z.string(), z.string()).optional(), +}); +const authSchema = z.object({ + hostId: z.string().min(1), + hostKey: z.string().min(1), +}); + +function normalizeUrl(value: string): string { + const url = new URL(value); + if (url.hostname === "localhost") url.hostname = "127.0.0.1"; + return url.href.replace(/\/+$/u, ""); +} + +async function readOptional(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return null; + throw new Error("Could not read machine identity state"); + } +} + +async function atomicWrite(path: string, value: string): Promise { + const temporary = `${path}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, value, { mode: 0o600, flag: "wx" }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +async function reservePort(dataDir: string): Promise { + const path = join(dataDir, "host-daemon-port"); + if ((await readOptional(path)) !== null) return; + const server = createServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Could not choose a machine daemon port"); + await atomicWrite(path, `${address.port}\n`); + } finally { + if (server.listening) + await new Promise((resolve) => server.close(() => resolve())); + } +} + +export interface MachineEnrollmentOptions { + bootstrapFile?: string; + bootstrapEnv?: string; +} + +export async function enrollMachine( + options: MachineEnrollmentOptions, + runtime: { + env?: NodeJS.ProcessEnv; + homeDir?: string; + fetchFn?: typeof fetch; + } = {}, +): Promise<{ hostId: string }> { + const env = runtime.env ?? process.env; + if (Boolean(options.bootstrapFile) === Boolean(options.bootstrapEnv)) + throw new Error( + "Specify exactly one of --bootstrap-file or --bootstrap-env", + ); + let input: string; + if (options.bootstrapEnv) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(options.bootstrapEnv)) + throw new Error("Invalid bootstrap environment variable name"); + const value = env[options.bootstrapEnv]; + if (!value) throw new Error("Bootstrap environment variable is empty"); + input = value; + delete env[options.bootstrapEnv]; + } else { + const value = await readOptional(options.bootstrapFile!); + if (value === null) throw new Error("Bootstrap file was not found"); + input = value; + } + let bootstrap: z.infer; + try { + bootstrap = bootstrapSchema.parse(JSON.parse(input)); + } catch { + throw new Error("Invalid machine enrollment bootstrap"); + } + const home = runtime.homeDir ?? homedir(); + const serverUrl = normalizeUrl(bootstrap.serverUrl); + const dataDir = resolve( + env.BB_DATA_DIR ?? + join( + home, + ".bb-machines", + new URL(bootstrap.serverUrl).host.replace(/[^a-zA-Z0-9.-]/gu, "-"), + ), + ); + if (dataDir === resolve(home, ".bb")) + throw new Error( + "Machine enrollment cannot use the default BB data directory", + ); + const existingAuth = await readOptional(join(dataDir, "auth.json")); + if (existingAuth !== null) { + let auth: z.infer; + let config: z.infer; + try { + auth = authSchema.parse(JSON.parse(existingAuth)); + config = configSchema.parse( + JSON.parse((await readOptional(join(dataDir, "config.json"))) ?? "{}"), + ); + } catch { + throw new Error("Invalid persisted machine identity"); + } + const persistedId = (await readOptional(join(dataDir, "host-id")))?.trim(); + if ( + auth.hostId !== bootstrap.hostId || + (persistedId && persistedId !== bootstrap.hostId) || + (config.serverUrl && normalizeUrl(config.serverUrl) !== serverUrl) + ) + throw new Error("Refusing to overwrite a different machine identity"); + if (!config.serverUrl) + throw new Error("Persisted machine server identity is missing"); + return { hostId: auth.hostId }; + } + await mkdir(dataDir, { recursive: true, mode: 0o700 }); + let config: z.infer; + let auth: z.infer | null; + try { + config = configSchema.parse( + JSON.parse((await readOptional(join(dataDir, "config.json"))) ?? "{}"), + ); + const rawAuth = await readOptional(join(dataDir, "auth.json")); + auth = rawAuth === null ? null : authSchema.parse(JSON.parse(rawAuth)); + } catch { + throw new Error("Invalid persisted machine identity"); + } + const persistedId = (await readOptional(join(dataDir, "host-id")))?.trim(); + if ( + (auth && auth.hostId !== bootstrap.hostId) || + (persistedId && persistedId !== bootstrap.hostId) || + (config.serverUrl && normalizeUrl(config.serverUrl) !== serverUrl) + ) + throw new Error("Refusing to overwrite a different machine identity"); + async function prepareRuntime(): Promise { + await reservePort(dataDir); + const launcher = join(dataDir, "npm", "bin", "bb-app"); + try { + await access(launcher); + } catch { + const result = await promisify(execFile)( + "sh", + ["-c", "command -v bb-app"], + { env }, + ).catch(() => null); + if (result?.stdout.trim()) { + await mkdir(join(dataDir, "npm", "bin"), { recursive: true }); + await symlink(result.stdout.trim(), launcher); + } + } + } + if (auth) { + if (!config.serverUrl) + throw new Error("Persisted machine server identity is missing"); + await prepareRuntime(); + return { hostId: auth.hostId }; + } + if (bootstrap.expiresAt <= Date.now()) + throw new Error("Machine enrollment bootstrap has expired"); + const fetchFn = runtime.fetchFn ?? fetch; + const signal = AbortSignal.timeout(60_000); + config = { ...config, serverUrl, serverHeaders: bootstrap.headers }; + await atomicWrite( + join(dataDir, "config.json"), + `${JSON.stringify(config)}\n`, + ); + await atomicWrite(join(dataDir, "host-id"), `${bootstrap.hostId}\n`); + await prepareRuntime(); + let enrolled: z.infer; + try { + const response = await fetchFn( + new URL("/internal/hosts/enroll", serverUrl), + { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${bootstrap.credential}`, + ...config.serverHeaders, + }, + body: JSON.stringify({ + hostId: bootstrap.hostId, + hostName: hostname(), + }), + signal, + }, + ); + if (response.status !== 201) throw new Error(); + enrolled = authSchema.parse(await response.json()); + } catch { + throw new Error("Could not exchange machine enrollment credential"); + } + if (enrolled.hostId !== bootstrap.hostId) + throw new Error("Enrollment returned a different machine identity"); + await atomicWrite( + join(dataDir, "auth.json"), + `${JSON.stringify(enrolled)}\n`, + ); + return { hostId: enrolled.hostId }; +} diff --git a/apps/cli/src/commands/machine-environment.ts b/apps/cli/src/commands/machine-environment.ts new file mode 100644 index 0000000000..5f9686b6b4 --- /dev/null +++ b/apps/cli/src/commands/machine-environment.ts @@ -0,0 +1,104 @@ +import type { Command } from "commander"; +import type { MachineEnvironmentList } from "@bb/server-contract"; +import { action } from "../action.js"; +import { createCliBbSdk } from "../client.js"; +import { outputJson } from "./helpers.js"; + +function printEnvironment( + result: MachineEnvironmentList, + options: { json?: boolean }, +): void { + if (outputJson(options, result)) return; + console.log( + `Built-in GitHub: ${result.builtInGit.status} — ${result.builtInGit.statusMessage}`, + ); + for (const row of result.variables) + console.log( + `${row.name}=${row.secret ? "[secret]" : row.value}${row.note ? ` (${row.note})` : ""}`, + ); +} + +async function readValue(): Promise { + if (process.stdin.isTTY) + throw new Error( + "Pipe the value to stdin; environment values are never accepted in command arguments.", + ); + let value = ""; + for await (const chunk of process.stdin) { + value += String(chunk); + if (Buffer.byteLength(value) > 65536) + throw new Error("Environment value exceeds 65536 bytes."); + } + return value.replace(/\r?\n$/u, ""); +} + +export function registerMachineEnvironmentCommands( + machine: Command, + getUrl: () => string, +): void { + const env = machine + .command("env") + .description("Configure the global environment for machine hosts"); + env + .command("list") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (options: { json?: boolean }) => { + printEnvironment( + await createCliBbSdk(getUrl()).system.machineEnvironment(), + options, + ); + }), + ); + env + .command("set ") + .description("Read a value from stdin; remove one trailing newline") + .option("--note ", "Describe this variable") + .option("--json", "Print machine-readable JSON output") + .action( + action( + async (name: string, options: { note?: string; json?: boolean }) => { + const system = createCliBbSdk(getUrl()).system; + const current = await system.machineEnvironment(); + const result = await system.replaceMachineEnvironment({ + variables: [ + ...current.variables + .filter((variable) => variable.name !== name) + .map((variable) => ({ + name: variable.name, + value: null, + note: variable.note, + })), + { + name, + value: await readValue(), + note: options.note ?? null, + }, + ], + }); + printEnvironment(result, options); + }, + ), + ); + env + .command("unset ") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (name: string, options: { json?: boolean }) => { + const system = createCliBbSdk(getUrl()).system; + const current = await system.machineEnvironment(); + printEnvironment( + await system.replaceMachineEnvironment({ + variables: current.variables + .filter((variable) => variable.name !== name) + .map((variable) => ({ + name: variable.name, + value: null, + note: variable.note, + })), + }), + options, + ); + }), + ); +} diff --git a/apps/cli/src/commands/machine.ts b/apps/cli/src/commands/machine.ts index 7cf8e8b438..b3c5a640fd 100644 --- a/apps/cli/src/commands/machine.ts +++ b/apps/cli/src/commands/machine.ts @@ -1,15 +1,32 @@ +import { registerMachineEnvironmentCommands } from "./machine-environment.js"; +import { + enrollMachine, + type MachineEnrollmentOptions, +} from "./machine-enrollment.js"; import { Command } from "commander"; -import type { Host } from "@bb/domain"; -import { action } from "../action.js"; +import { jsonValueSchema, type Host, type JsonValue } from "@bb/domain"; +import { action, CliExitError } from "../action.js"; import { createCliBbSdk } from "../client.js"; import { renderBorderlessTable } from "../table.js"; import { outputJson } from "./helpers.js"; import { confirmDestructiveAction } from "./helpers.js"; +function enrollmentExpiryNotice(expiresAt: number | null): string { + if (expiresAt === null) return "This command expires once it is used."; + return `This command expires at ${new Date(expiresAt).toLocaleTimeString()}.`; +} + interface MachineListCommandOptions { json?: boolean; } +interface MachineCreateCommandOptions extends MachineListCommandOptions { + provider: string; + wait: boolean; + key?: string; + inputs?: string; +} + interface MachineMutationCommandOptions extends MachineListCommandOptions { yes?: boolean; } @@ -18,6 +35,46 @@ interface MachineProviderInstallOptions extends MachineListCommandOptions { action?: "install" | "update"; } +const MACHINE_LIFECYCLE_TIMEOUT_MS = 15 * 60_000; +const MACHINE_LIFECYCLE_POLL_MS = 500; + +async function waitForMachineLifecycle(args: { + host: Host; + targetPhase: "active" | "suspended"; + getHost: () => Promise; +}): Promise { + let host = args.host; + const deadline = Date.now() + MACHINE_LIFECYCLE_TIMEOUT_MS; + while (host.lifecycle.phase !== args.targetPhase) { + const message = host.lifecycle.message; + if ( + message?.startsWith("Machine suspension failed:") || + message?.startsWith("Machine resume failed:") + ) { + throw new Error(message); + } + if ( + host.lifecycle.phase === "removing" || + host.lifecycle.phase === "destroyed" + ) { + throw new Error( + host.lifecycle.message ?? + `Machine entered the ${host.lifecycle.phase} phase`, + ); + } + if (Date.now() >= deadline) { + throw new Error( + `Timed out after ${MACHINE_LIFECYCLE_TIMEOUT_MS / 1000} seconds waiting for machine ${host.id} to become ${args.targetPhase}`, + ); + } + await new Promise((resolve) => + setTimeout(resolve, MACHINE_LIFECYCLE_POLL_MS), + ); + host = await args.getHost(); + } + return host; +} + function parseProviderCliKey(value: string): string { const providerId = value.trim(); if (providerId.length === 0) @@ -125,6 +182,119 @@ export function registerMachineCommands( .command("machine") .description("Inspect execution machines"); + registerMachineEnvironmentCommands(machine, getUrl); + + machine + .command("enroll") + .description("Enroll this machine using a private bootstrap bundle") + .option("--bootstrap-file ", "Read the bootstrap bundle from a file") + .option( + "--bootstrap-env ", + "Consume the bootstrap bundle from an environment variable", + ) + .option("--json", "Print machine-readable JSON output") + .action( + action(async (options: MachineEnrollmentOptions & { json?: boolean }) => { + const result = await enrollMachine(options); + if (!outputJson(options, result)) + console.log(`Machine ${result.hostId} enrolled`); + }), + ); + + machine + .command("create") + .description("Create a machine using an installed provider") + .option("--no-wait", "Return the creating host ID immediately") + .requiredOption("--provider ", "Machine provider ID") + .option( + "--key ", + "Reuse a stable key when retrying creation", + ) + .option("--inputs ", "Provider inputs as JSON") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (opts: MachineCreateCommandOptions) => { + const machineProviderId = parseProviderCliKey(opts.provider); + const key = opts.key?.trim(); + if (key === "") throw new Error("Creation key must not be empty."); + let inputs: JsonValue = null; + if (opts.inputs !== undefined) { + try { + inputs = jsonValueSchema.parse(JSON.parse(opts.inputs)); + } catch { + throw new Error("--inputs must be valid JSON."); + } + } + const controller = new AbortController(); + const cancel = () => controller.abort(); + process.once("SIGINT", cancel); + try { + const sdk = createCliBbSdk(getUrl()); + controller.signal.throwIfAborted(); + let host = await sdk.hosts.experimental_create({ + machineProviderId, + inputs, + ...(key === undefined ? {} : { key }), + wait: false, + signal: controller.signal, + }); + if (!opts.wait) { + if (!outputJson(opts, host)) console.log(host.id); + return; + } + const enrollmentCommand = + await sdk.hosts.experimental_getEnrollmentCommand({ + hostId: host.id, + signal: controller.signal, + }); + if (enrollmentCommand !== null) { + console.error(enrollmentCommand.command); + console.error(enrollmentExpiryNotice(enrollmentCommand.expiresAt)); + } + console.error(`Following machine ${host.id}`); + host = await waitForMachineLifecycle({ + host, + targetPhase: "active", + getHost: () => + sdk.hosts.get({ hostId: host.id, signal: controller.signal }), + }); + if (!outputJson(opts, host)) + console.log(`Machine ${host.name} created`); + } catch (error) { + if (controller.signal.aborted) { + throw new CliExitError( + "Stopped following; creation continues. Use bb machine remove to cancel.", + 130, + ); + } + throw error; + } finally { + process.off("SIGINT", cancel); + } + }), + ); + + machine + .command("providers") + .description("List installed machine providers") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (opts: MachineListCommandOptions) => { + const providers = + await createCliBbSdk(getUrl()).hosts.experimental_listProviders(); + if (outputJson(opts, providers)) return; + if (providers.length === 0) { + console.log("No machine providers found"); + return; + } + console.log( + providers + .map((provider) => `${provider.id} ${provider.displayName}`) + .join("\n"), + ); + }), + ); + machine .command("list") .description("List execution machines") @@ -195,7 +365,8 @@ export function registerMachineCommands( .action( action(async (target: string, opts: MachineMutationCommandOptions) => { const sdk = createCliBbSdk(getUrl()); - const hostId = resolveMachineId(await sdk.hosts.list(), target); + const hosts = await sdk.hosts.list(); + const hostId = resolveMachineId(hosts, target); if ( !opts.yes && !(await confirmDestructiveAction(`Remove machine ${hostId}?`)) @@ -221,6 +392,58 @@ export function registerMachineCommands( }), ); + machine + .command("suspend ") + .description("Suspend a provider-managed execution machine") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (target: string, opts: MachineListCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + const requested = await sdk.hosts.experimental_suspend({ hostId }); + const result = await waitForMachineLifecycle({ + host: requested, + targetPhase: "suspended", + getHost: () => sdk.hosts.get({ hostId }), + }); + if (outputJson(opts, result)) return; + console.log(`Machine ${hostId} suspended`); + }), + ); + + machine + .command("resume ") + .description("Resume a suspended provider-managed execution machine") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (target: string, opts: MachineListCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + const requested = await sdk.hosts.experimental_resume({ hostId }); + const result = await waitForMachineLifecycle({ + host: requested, + targetPhase: "active", + getHost: () => sdk.hosts.get({ hostId }), + }); + if (outputJson(opts, result)) return; + console.log(`Machine ${hostId} resumed`); + }), + ); + + machine + .command("retry-cleanup ") + .description("Retry a failed provider teardown immediately") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (target: string, opts: MachineListCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + const result = await sdk.hosts.experimental_retryCleanup({ hostId }); + if (outputJson(opts, result)) return; + console.log(`Machine ${hostId} cleanup retried`); + }), + ); + const providerCli = machine .command("provider-cli") .description("Inspect and install provider CLIs on a machine"); @@ -272,19 +495,21 @@ function printMachineTable(hosts: Host[]): void { host.name, host.id, host.status, + host.machineProviderId ?? "user-enrolled", formatMachineLastSeen(host.lastSeenAt, now), ]); const widths = [ Math.max(4, ...rows.map((row) => row[0].length)), Math.max(2, ...rows.map((row) => row[1].length)), Math.max(6, ...rows.map((row) => row[2].length)), - Math.max(9, ...rows.map((row) => row[3].length)), + Math.max(8, ...rows.map((row) => row[3].length)), + Math.max(9, ...rows.map((row) => row[4].length)), ]; console.log(""); console.log( renderBorderlessTable( { - head: ["Name", "ID", "Status", "Last seen"], + head: ["Name", "ID", "Status", "Provider", "Last seen"], colWidths: widths, trimTrailingWhitespace: true, }, diff --git a/apps/cli/src/commands/settings.ts b/apps/cli/src/commands/settings.ts index 1a60284e75..e9619f2608 100644 --- a/apps/cli/src/commands/settings.ts +++ b/apps/cli/src/commands/settings.ts @@ -86,7 +86,7 @@ function updateGeneralSetting( } for (const candidate of generalSettingValueCandidates(value)) { - const updated = appSettingsSchema.safeParse({ + const updated = appSettingsSchema.strip().safeParse({ ...settings, [settingKey.data]: candidate, }); diff --git a/apps/cli/src/commands/thread/spawn.ts b/apps/cli/src/commands/thread/spawn.ts index f4b12a8990..747c0b7bdb 100644 --- a/apps/cli/src/commands/thread/spawn.ts +++ b/apps/cli/src/commands/thread/spawn.ts @@ -4,6 +4,7 @@ import { PERSONAL_PROJECT_ID, threadVisibilitySchema, type GitBranchSelection, + type EnvironmentMachineSelection, type Thread, type JsonValue, } from "@bb/domain"; @@ -57,6 +58,8 @@ interface ThreadSpawnCommandOptions { parentSelf?: boolean; machine?: string; host?: string; + newMachine?: string; + machineInputs?: string; file?: string[]; image?: string[]; section?: string; @@ -188,6 +191,17 @@ function parseEnvironmentInputs( return jsonValueSchema.parse(parsed); } +function parseMachineInputs(flagValue: string | undefined): JsonValue | null { + if (flagValue === undefined) return null; + let parsed: unknown; + try { + parsed = JSON.parse(flagValue); + } catch { + throw new Error("--machine-inputs must be valid JSON."); + } + return jsonValueSchema.parse(parsed); +} + async function buildProviderSpawnEnvironment(args: { serverUrl: string; environmentProvider: string; @@ -196,6 +210,9 @@ async function buildProviderSpawnEnvironment(args: { newEnvironmentKind: string | undefined; baseBranch: string | undefined; machineHostId: string | null; + machine: EnvironmentMachineSelection | null; + machineInputs: JsonValue | null; + machineInputsProvided: boolean; projectId: string; resolveDefaultHostId: () => Promise; }): Promise { @@ -235,7 +252,48 @@ async function buildProviderSpawnEnvironment(args: { `The '${match.id}' environment provider takes no --environment-inputs.`, ); } - const machine = { + if (match.machineProviderId) { + if (args.machine !== null || args.machineHostId !== null) + throw new Error( + "This environment provider chooses its own new machine; omit machine selectors.", + ); + let machineInputs = args.machineInputs; + if (match.machineInputs !== undefined) { + if (match.machineInputs !== null && machineInputs === null) { + if (match.machineAcceptsEmptyInputs) machineInputs = {}; + else { + throw new Error( + `The '${match.machineProviderId}' machine provider needs --machine-inputs ; \`bb environment providers --json\` shows its schema.`, + ); + } + } + if (match.machineInputs === null && machineInputs !== null) { + throw new Error( + `The '${match.machineProviderId}' machine provider takes no --machine-inputs.`, + ); + } + } + return { + type: "provider", + environmentProviderId: match.id, + ...(match.machineInputs === undefined || match.machineInputs === null + ? {} + : { + machine: { + type: "new" as const, + machineProviderId: match.machineProviderId, + inputs: machineInputs, + }, + }), + inputs, + }; + } + if (args.machineInputsProvided && args.machine === null) { + throw new Error( + "--machine-inputs requires --new-machine or a composed --environment-provider.", + ); + } + const machine = args.machine ?? { type: "existing" as const, hostId: requireHostId( args.machineHostId ?? (await args.resolveDefaultHostId()), @@ -278,6 +336,14 @@ export function registerSpawnCommand( "Execution machine ID or unambiguous name", ) .option("--host ", "Alias for --machine") + .option( + "--new-machine ", + "Create the thread on a new machine from this machine provider", + ) + .option( + "--machine-inputs ", + "Persisted non-secret inputs for --new-machine or a composed --environment-provider; store credentials in plugin settings", + ) .option("--parent-thread ", "Parent thread ID for worker thread links") .option("--parent-self", "Parent the new thread to BB_THREAD_ID") .option("--provider ", PROVIDER_HELP) @@ -335,12 +401,27 @@ export function registerSpawnCommand( throw new Error("Missing required option --project ."); } const environmentValue = resolveSpawnEnvironmentValue(opts.environment); - if (opts.environmentInputs !== undefined && !opts.environmentProvider) { + if ( + opts.environmentInputs !== undefined && + !opts.environmentProvider && + !opts.newMachine + ) { throw new Error( "--environment-inputs requires --environment-provider .", ); } const machineTarget = resolveMachineTargetOption(opts); + if (machineTarget && opts.newMachine) { + throw new Error( + "Cannot combine --new-machine with --machine or --host.", + ); + } + if (opts.machineInputs !== undefined && !opts.newMachine) { + if (!opts.environmentProvider) + throw new Error( + "--machine-inputs requires --new-machine or a composed --environment-provider.", + ); + } if ( machineTarget && environmentValue && @@ -350,9 +431,55 @@ export function registerSpawnCommand( "Cannot combine --machine or --host with an existing environment ID; that environment already selects its machine.", ); } + const machineProvider = opts.newMachine + ? ( + await createCliBbSdk(getUrl()).hosts.experimental_listProviders() + ).find((provider) => provider.id === opts.newMachine?.trim()) + : undefined; + if (opts.newMachine && machineProvider === undefined) { + throw new Error( + `Unknown machine provider '${opts.newMachine.trim()}'.`, + ); + } + let machineInputs = parseMachineInputs(opts.machineInputs); + if ( + machineProvider && + machineProvider.inputs !== null && + machineInputs === null + ) { + if (machineProvider.acceptsEmptyInputs) machineInputs = {}; + else { + throw new Error( + `The '${machineProvider?.id}' machine provider needs --machine-inputs ; \`bb machine providers --json\` shows its schema.`, + ); + } + } + if ( + machineProvider && + machineProvider.inputs === null && + machineInputs !== null + ) { + throw new Error( + `The '${machineProvider.id}' machine provider takes no --machine-inputs.`, + ); + } + const newMachineSelection = + machineProvider === undefined + ? null + : { + type: "new" as const, + machineProviderId: machineProvider.id, + inputs: machineInputs, + }; const selectedEnvironmentProvider = opts.environmentProvider; + if (machineProvider && selectedEnvironmentProvider === undefined) { + throw new Error( + `The '${machineProvider.id}' machine provider requires an environment provider; combine --new-machine with --environment-provider .`, + ); + } const needsHostId = !opts.environmentProvider && + !opts.newMachine && (Boolean(opts.newEnvironment) || (environmentValue !== undefined && looksLikePath(environmentValue))); @@ -373,6 +500,9 @@ export function registerSpawnCommand( newEnvironmentKind: opts.newEnvironment, baseBranch: opts.baseBranch, machineHostId: hostId, + machine: newMachineSelection, + machineInputs, + machineInputsProvided: opts.machineInputs !== undefined, projectId, resolveDefaultHostId: resolveLocalHostId, }) diff --git a/apps/demo-server/src/demo-world.ts b/apps/demo-server/src/demo-world.ts index 0d2c5484e0..3bde27e9c1 100644 --- a/apps/demo-server/src/demo-world.ts +++ b/apps/demo-server/src/demo-world.ts @@ -55,6 +55,19 @@ const SYSTEM_CONFIG = systemConfigResponseSchema.parse({ appearance: defaultAppTheme, featureFlags: defaultFeatureFlags, serverUrl: "https://demo.invalid", + serverAccess: { + effectiveUrl: "https://demo.invalid", + urlSource: "setting", + defaultProviderId: "direct", + providers: [ + { + id: "direct", + displayName: "Direct URL", + attention: null, + availability: { status: "available" }, + }, + ], + }, aiServices: { inference: "codex/gpt-5.5", inferenceFallback: "codex/gpt-5.5", diff --git a/apps/demo-server/src/fixtures/world.ts b/apps/demo-server/src/fixtures/world.ts index ed6efdc9e1..9ec532c6a3 100644 --- a/apps/demo-server/src/fixtures/world.ts +++ b/apps/demo-server/src/fixtures/world.ts @@ -164,8 +164,16 @@ export function hosts(now: number): Host[] { { id: DEMO_HOST_ID, name: "demo", - status: "connected", type: "persistent", + status: "connected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: now, lastRejectedProtocolVersion: null, diff --git a/apps/host-daemon/src/app.test.ts b/apps/host-daemon/src/app.test.ts index cd23452246..201c8a75ff 100644 --- a/apps/host-daemon/src/app.test.ts +++ b/apps/host-daemon/src/app.test.ts @@ -34,6 +34,7 @@ import type { import type { FetchFn } from "./server-client.js"; import type { CreateReconnectingWebSocket } from "./server-connection.js"; import type { ReconnectingWebSocketLike } from "./server-connection-support.js"; +import { MACHINE_SUSPENSION_MARKER } from "./suspension-marker.js"; interface RecordedFetchRequest { body: string | null; @@ -60,6 +61,7 @@ interface RuntimeOptionsRef { interface HostDaemonAppFixture { app: HostDaemonApp; + dataDir: string; fetchRecorder: FetchRecorder; logger: ReturnType; runtimeOptions: RuntimeOptionsRef; @@ -196,7 +198,9 @@ function createFetchRecorder( }; } -function createOpeningWebSocket(): CreateReconnectingWebSocket { +function createOpeningWebSocket( + onCreate?: (socket: ReconnectingWebSocketLike) => void, +): CreateReconnectingWebSocket { return (urlProvider) => { let readyState = 0; const socket: ReconnectingWebSocketLike = { @@ -225,6 +229,7 @@ function createOpeningWebSocket(): CreateReconnectingWebSocket { socket.onclose?.({ code: 1000, reason: "test-reconnect" }); void openSocket(); }); + onCreate?.(socket); void openSocket(); return socket; }; @@ -366,7 +371,11 @@ afterEach(async () => { async function createAppFixture( args: CreateFetchRecorderArgs = {}, - options: { closeMachineAuthProxy?: () => Promise } = {}, + options: { + closeMachineAuthProxy?: () => Promise; + createWebSocket?: CreateReconnectingWebSocket; + exitProcess?: (code: number) => void; + } = {}, ): Promise { const dataDir = await makeTempDir("bb-host-daemon-app-test-"); const fetchRecorder = createFetchRecorder(args); @@ -376,7 +385,6 @@ async function createAppFixture( dataDir, serverUrl: "http://127.0.0.1:3334", hostKey: "host-key-app-test", - hostType: "persistent", hostId: "host-app-test", hostName: "App Test Host", instanceId: "instance-app-test", @@ -388,7 +396,8 @@ async function createAppFixture( return createFakeRuntime(); }, fetchFn: fetchRecorder.fetchFn, - createWebSocket: createOpeningWebSocket(), + createWebSocket: options.createWebSocket ?? createOpeningWebSocket(), + ...(options.exitProcess ? { exitProcess: options.exitProcess } : {}), ...(options.closeMachineAuthProxy ? { closeMachineAuthProxy: options.closeMachineAuthProxy } : {}), @@ -396,6 +405,7 @@ async function createAppFixture( return { app, + dataDir, fetchRecorder, logger, runtimeOptions, @@ -403,6 +413,33 @@ async function createAppFixture( } describe("createHostDaemonApp", () => { + it("acknowledges machine shutdown, cleans up, and exits", async () => { + let socket: ReconnectingWebSocketLike | undefined; + const exitProcess = vi.fn(); + const { app, dataDir } = await createAppFixture( + {}, + { + createWebSocket: createOpeningWebSocket((created) => { + socket = created; + }), + exitProcess, + }, + ); + await app.daemon.start(); + if (socket === undefined) throw new Error("Expected daemon socket"); + + socket.onmessage?.({ data: JSON.stringify({ type: "machine.shutdown" }) }); + await app.daemon.waitUntilStopped(); + + await expect( + fs.access(path.join(dataDir, MACHINE_SUSPENSION_MARKER)), + ).resolves.toBeUndefined(); + expect(socket.send).toHaveBeenCalledWith( + JSON.stringify({ type: "machine.shutdown-ack" }), + ); + expect(exitProcess).toHaveBeenCalledWith(0); + }); + it("closes the machine authentication proxy during daemon shutdown", async () => { const closeMachineAuthProxy = vi.fn(async () => undefined); const { app } = await createAppFixture({}, { closeMachineAuthProxy }); @@ -438,7 +475,6 @@ describe("createHostDaemonApp", () => { dataDir, serverUrl: "http://127.0.0.1:3334", hostKey: "host-key-app-test", - hostType: "persistent", hostId: "host-app-test", hostName: "App Test Host", instanceId: "instance-app-test", @@ -540,7 +576,6 @@ describe("createHostDaemonApp", () => { dataDir, serverUrl: "http://127.0.0.1:3334", hostKey: "host-key-app-test", - hostType: "persistent", hostId: "host-app-test", hostName: "App Test Host", instanceId: "instance-app-test", @@ -770,7 +805,6 @@ describe("createHostDaemonApp", () => { dataDir, serverUrl: "http://127.0.0.1:3334", hostKey: "host-key-retired-env", - hostType: "persistent", hostId: "host-retired-env", hostName: "Retired Environment Host", instanceId: "instance-retired-env", diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts index f580443414..8344fde103 100644 --- a/apps/host-daemon/src/app.ts +++ b/apps/host-daemon/src/app.ts @@ -47,16 +47,13 @@ import { runtimeErrorLogFields, summarizeError } from "./error-utils.js"; import { ensureThreadStorageRoot } from "./thread-storage-root.js"; import type { AgentRuntimeOptions } from "@bb/agent-runtime"; import { createProtocolSelfUpdater } from "./protocol-self-update.js"; -import { - type HostType, - type ToolCallRequest, - type ToolCallResponse, -} from "@bb/domain"; +import { type ToolCallRequest, type ToolCallResponse } from "@bb/domain"; import { disposeParcelWatcherBackend, type HostWatcher, } from "@bb/host-watcher"; import { PluginHostManager } from "./plugin-host-manager.js"; +import { writeMachineSuspensionMarker } from "./suspension-marker.js"; interface SessionState { value: string | null; @@ -106,15 +103,13 @@ interface CreateHostDaemonAppOptions { serverUrl: string; hostKey: string; bridgeBundleDir?: string; - hostType: HostType; hostId: string; hostName: string; instanceId: string; appUrl?: string; devAppPort?: number; logger: HostDaemonLogger; - machineCredential?: string; - connectMachineId?: string; + serverHeaders?: Record; autoUpdate?: boolean; releaseLock: () => Promise; localApiConfig: HostDaemonLocalApiConfig | null; @@ -288,7 +283,7 @@ export async function createHostDaemonApp( serverUrl: options.serverUrl, hostKey: options.hostKey, logger: options.logger, - machineCredential: options.machineCredential, + serverHeaders: options.serverHeaders, getSessionId: () => { if (!sessionState.value) { throw new Error("Server session is not open"); @@ -467,7 +462,7 @@ export async function createHostDaemonApp( const connectTunnel = new ConnectTunnelClient({ serverUrl: options.serverUrl, hostName: options.hostName, - machineCredential: options.machineCredential, + machineCredential: options.serverHeaders?.["x-bb-connect-machine"], fetchFn: options.fetchFn, logger: options.logger, onIdentity: (identity) => { @@ -809,18 +804,17 @@ export async function createHostDaemonApp( }); let requestDaemonRestart = (): void => undefined; + let requestMachineShutdown = async (): Promise => undefined; const connection = new ServerConnection({ serverUrl: options.serverUrl, hostKey: options.hostKey, hostId: options.hostId, hostName: options.hostName, - hostType: options.hostType, dataDir: options.dataDir, instanceId: options.instanceId, localApiPort: options.localApiConfig?.port ?? null, logger: options.logger, - machineCredential: options.machineCredential, - connectMachineId: options.connectMachineId, + serverHeaders: options.serverHeaders, serverClient, protocolSelfUpdater: createProtocolSelfUpdater({ dataDir: options.dataDir, @@ -830,6 +824,7 @@ export async function createHostDaemonApp( serverUrl: options.serverUrl, }), onSelfUpdateInstalled: () => requestDaemonRestart(), + onMachineShutdown: () => requestMachineShutdown(), createWebSocket: options.createWebSocket, getActiveThreads: () => runtimeManager.listActiveThreads(), getLoadedEnvironments: () => runtimeManager.listLoadedEnvironments(), @@ -961,6 +956,11 @@ export async function createHostDaemonApp( options.logger.error({ err: error }, "Self-update shutdown failed"); }); }; + requestMachineShutdown = async () => { + await writeMachineSuspensionMarker(options.dataDir); + sendServerMessage({ type: "machine.shutdown-ack" }); + await daemon.shutdown("machine-shutdown", 0); + }; connection.setSessionCloseHandler((reason) => daemon.shutdown(`session-close:${reason}`, 0), ); diff --git a/apps/host-daemon/src/auth-state.test.ts b/apps/host-daemon/src/auth-state.test.ts index 9ae4a0c543..16bb613ff5 100644 --- a/apps/host-daemon/src/auth-state.test.ts +++ b/apps/host-daemon/src/auth-state.test.ts @@ -56,14 +56,12 @@ describe("auth state", () => { await writeHostAuthState(dataDir, { hostId: "host_auth_state", hostKey: "bbdh_test_key", - hostType: "persistent", }); const authState = await readHostAuthState(dataDir); expect(authState).toEqual({ hostId: "host_auth_state", hostKey: "bbdh_test_key", - hostType: "persistent", }); const authStatePath = path.join(dataDir, HOST_AUTH_FILE_NAME); @@ -83,7 +81,6 @@ describe("auth state", () => { { hostId: "host_auth_state", hostKey: "bbdh_test_key", - hostType: "persistent", serverUrl: "https://server.example.test/", }, null, @@ -95,7 +92,6 @@ describe("auth state", () => { await expect(readHostAuthState(dataDir)).resolves.toEqual({ hostId: "host_auth_state", hostKey: "bbdh_test_key", - hostType: "persistent", }); }); }); diff --git a/apps/host-daemon/src/auth-state.ts b/apps/host-daemon/src/auth-state.ts index d3825e7e06..189f3bbfe5 100644 --- a/apps/host-daemon/src/auth-state.ts +++ b/apps/host-daemon/src/auth-state.ts @@ -39,7 +39,6 @@ export async function writeHostAuthState( { hostId: authState.hostId, hostKey: authState.hostKey, - hostType: authState.hostType, }, null, 2, diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts index bb3a90099e..d4b9dc709a 100644 --- a/apps/host-daemon/src/command-dispatch.test.ts +++ b/apps/host-daemon/src/command-dispatch.test.ts @@ -823,7 +823,8 @@ describe("dispatchCommand", () => { .mockReturnValueOnce(newRuntime); const manager = new RuntimeManager({ createRuntime: createRuntimeSpy, - provisionWorkspace: async (args) => createWorkspace(args.path), + provisionWorkspace: async (args) => + createWorkspace(args.path), }); await manager.ensureEnvironment({ environmentId: "env-old", @@ -1148,7 +1149,8 @@ describe("dispatchCommand", () => { .mockReturnValueOnce(newRuntime); const manager = new RuntimeManager({ createRuntime: createRuntimeSpy, - provisionWorkspace: async (args) => createWorkspace(args.path), + provisionWorkspace: async (args) => + createWorkspace(args.path), }); await manager.ensureEnvironment({ environmentId: "env-old", @@ -1198,7 +1200,8 @@ describe("dispatchCommand", () => { .mockReturnValueOnce(newRuntime); const manager = new RuntimeManager({ createRuntime: createRuntimeSpy, - provisionWorkspace: async (args) => createWorkspace(args.path), + provisionWorkspace: async (args) => + createWorkspace(args.path), }); await manager.ensureEnvironment({ environmentId: "env-old", diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts index 44df15ecf2..787a1d54d8 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -1,3 +1,4 @@ +import { operationEnvironment } from "./operation-environment.js"; import { runEnvironmentHook, cancelEnvironmentHook, @@ -466,7 +467,22 @@ const commandHandlers: CommandHandlerMap = { cloneProject({ dataDir: options.dataDir, projectSlug: command.projectSlug, + env: operationEnvironment( + command.contributedEnv, + { + ...process.env, + ...options.runtimeManager.getShellEnv(), + }, + true, + ), + contributedEnv: command.contributedEnv, remoteUrl: command.remoteUrl, + onProgress: (text) => + options.emitEnvironmentHookProgress?.({ + type: "environment.hook.progress", + operationId: command.operationId, + entry: { type: "output", text, status: null }, + }), ...userExecutableProcessOptions(options.runtimeManager.getShellEnv()), ...(command.targetPath !== undefined ? { targetPath: command.targetPath } diff --git a/apps/host-daemon/src/command-handlers/environment-hook.ts b/apps/host-daemon/src/command-handlers/environment-hook.ts index 8289a7b172..91d228f4e9 100644 --- a/apps/host-daemon/src/command-handlers/environment-hook.ts +++ b/apps/host-daemon/src/command-handlers/environment-hook.ts @@ -48,22 +48,28 @@ export async function runEnvironmentHook( const done = Promise.resolve().then( async (): Promise> => { const run = command.kind === "setup" ? runSetupScript : runTeardownScript; - await run({ - workspacePath: command.path, - timeoutMs: command.timeoutMs, - shellPath: options.runtimeManager.getShellEnv().PATH, - signal: controller.signal, - onProgress: (entry) => - options.emitEnvironmentHookProgress?.({ - type: "environment.hook.progress", - operationId: command.operationId, - entry: { - type: entry.type, - text: entry.text, - status: entry.status ?? null, - }, - }), - }); + try { + await run({ + workspacePath: command.path, + contributedEnv: command.contributedEnv, + env: { ...process.env, ...options.runtimeManager.getShellEnv() }, + timeoutMs: command.timeoutMs, + shellPath: options.runtimeManager.getShellEnv().PATH, + signal: controller.signal, + onProgress: (entry) => + options.emitEnvironmentHookProgress?.({ + type: "environment.hook.progress", + operationId: command.operationId, + entry: { + type: entry.type, + text: entry.text, + status: entry.status ?? null, + }, + }), + }); + } catch (error) { + throw new Error(error instanceof Error ? error.message : String(error)); + } return {}; }, ); diff --git a/apps/host-daemon/src/command-handlers/project.test.ts b/apps/host-daemon/src/command-handlers/project.test.ts index 00638aac4b..bf62665929 100644 --- a/apps/host-daemon/src/command-handlers/project.test.ts +++ b/apps/host-daemon/src/command-handlers/project.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { runGit } from "@bb/host-workspace"; import { afterEach, describe, expect, it } from "vitest"; import { isExpectedCommandDispatchError } from "../command-dispatch-support.js"; @@ -49,19 +50,24 @@ describe("project.clone", () => { it("clones a real repository and reports the resolved path and origin", async () => { const root = await tempDir(); const remoteUrl = await createRemoteRepo(root); + const cloneUrl = pathToFileURL(remoteUrl).href; + const progress: string[] = []; const result = await cloneProject({ dataDir: path.join(root, "data"), projectSlug: "My Project", - remoteUrl, + remoteUrl: cloneUrl, + onProgress: (line) => progress.push(line), }); expect(result).toEqual({ path: path.join(root, "data", "checkouts", "my-project"), - gitRemoteUrl: remoteUrl, + gitRemoteUrl: cloneUrl, }); await expect( fs.readFile(path.join(result.path, "README.md"), "utf8"), ).resolves.toBe("hello\n"); + expect(progress.join("\n")).toContain("Cloning into"); + expect(progress.join("\n")).toContain("Receiving objects:"); }); it("refuses a non-empty target with a structured error", async () => { diff --git a/apps/host-daemon/src/command-handlers/project.ts b/apps/host-daemon/src/command-handlers/project.ts index ddefdb0ba4..cc34ac260c 100644 --- a/apps/host-daemon/src/command-handlers/project.ts +++ b/apps/host-daemon/src/command-handlers/project.ts @@ -1,3 +1,4 @@ +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; import fs from "node:fs/promises"; import path from "node:path"; import { @@ -71,8 +72,11 @@ export async function cloneProject(args: { dataDir: string; projectSlug: string; remoteUrl: string; + env?: NodeJS.ProcessEnv; + contributedEnv?: readonly HostDaemonContributedEnvEntry[]; targetPath?: string; shellPath?: string; + onProgress?: (line: string) => void; }): Promise<{ path: string; gitRemoteUrl: string | null }> { const targetPath = path.resolve( args.targetPath ?? @@ -80,17 +84,36 @@ export async function cloneProject(args: { ); await requireEmptyOrMissingTarget(targetPath); await fs.mkdir(path.dirname(targetPath), { recursive: true }); + let pendingProgress = ""; + const onStderr = + args.onProgress === undefined + ? undefined + : (chunk: string): void => { + pendingProgress += chunk; + for (;;) { + const match = /\r\n|\r|\n/u.exec(pendingProgress); + if (match === null) return; + args.onProgress?.(pendingProgress.slice(0, match.index)); + pendingProgress = pendingProgress.slice( + match.index + match[0].length, + ); + } + }; try { - await runGit(["clone", args.remoteUrl, targetPath], { + await runGit(["clone", "--progress", args.remoteUrl, targetPath], { cwd: path.dirname(targetPath), + env: args.env, ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}), timeoutMs: PROJECT_CLONE_TIMEOUT_MS, + ...(onStderr === undefined ? {} : { onStderr }), }); } catch (error) { if (error instanceof WorkspaceError) { throw new ExpectedCommandDispatchError(error.code, error.message); } - throw error; + throw new Error(error instanceof Error ? error.message : String(error)); + } finally { + if (pendingProgress.length > 0) args.onProgress?.(pendingProgress); } return inspectProjectPath( targetPath, diff --git a/apps/host-daemon/src/enroll.test.ts b/apps/host-daemon/src/enroll.test.ts new file mode 100644 index 0000000000..8bcb5427ce --- /dev/null +++ b/apps/host-daemon/src/enroll.test.ts @@ -0,0 +1,8 @@ +import { expect, it, vi } from "vitest"; +import { enrollDaemonHost } from "./enroll.js"; + +it("forwards arbitrary access headers on enrollment without a Cloud identity body", async () => { + const fetchFn = vi.fn(async () => Response.json({hostId:"host-test",hostKey:"durable-key"},{status:201})); + await enrollDaemonHost({fetchFn,hostId:"host-test",hostName:"test",serverUrl:"https://server.example",token:"bootstrap",serverHeaders:{"x-provider-token":"private"}}); + expect(fetchFn).toHaveBeenCalledWith("https://server.example/internal/hosts/enroll",expect.objectContaining({headers: {authorization:"Bearer bootstrap","content-type":"application/json","x-provider-token":"private"},body:JSON.stringify({hostId:"host-test",hostName:"test"})})); +}); diff --git a/apps/host-daemon/src/enroll.ts b/apps/host-daemon/src/enroll.ts index 55e98649b3..1eba063fe7 100644 --- a/apps/host-daemon/src/enroll.ts +++ b/apps/host-daemon/src/enroll.ts @@ -1,15 +1,10 @@ -import { - hostDaemonEnrollResponseSchema, - type HostDaemonEnrollRequest, -} from "@bb/host-daemon-contract"; +import { hostDaemonEnrollResponseSchema } from "@bb/host-daemon-contract"; interface EnrollHostArgs { fetchFn?: typeof fetch; hostId: string; hostName: string; - hostType: HostDaemonEnrollRequest["hostType"]; - connectMachineId?: string; - machineCredential?: string; + serverHeaders?: Record; serverUrl: string; token: string; } @@ -40,17 +35,11 @@ export async function enrollDaemonHost( headers: { authorization: `Bearer ${args.token}`, "content-type": "application/json", - ...(args.machineCredential !== undefined - ? { "x-bb-connect-machine": args.machineCredential } - : {}), + ...args.serverHeaders, }, body: JSON.stringify({ hostId: args.hostId, hostName: args.hostName, - hostType: args.hostType, - ...(args.connectMachineId !== undefined - ? { connectMachineId: args.connectMachineId } - : {}), }), }); diff --git a/apps/host-daemon/src/environment-lifecycle-script.ts b/apps/host-daemon/src/environment-lifecycle-script.ts index 19914fed94..24e3123ec6 100644 --- a/apps/host-daemon/src/environment-lifecycle-script.ts +++ b/apps/host-daemon/src/environment-lifecycle-script.ts @@ -1,8 +1,10 @@ +import { StringDecoder } from "node:string_decoder"; +import { operationEnvironment } from "./operation-environment.js"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; import { isProcessGroupAlive, killProcessGroup, - sanitizeInheritedChildProcessEnv, - spawnPortableOutputProcess, + spawnPortablePipedProcess, supportsProcessGroups, } from "@bb/process-utils"; import fs from "node:fs/promises"; @@ -25,6 +27,8 @@ export interface RunSetupScriptArgs { workspacePath: string; timeoutMs: number; shellPath?: string; + env?: NodeJS.ProcessEnv; + contributedEnv?: readonly HostDaemonContributedEnvEntry[]; onProgress?: ProgressCallback; signal?: AbortSignal; } @@ -121,11 +125,15 @@ async function runLifecycleScript( }); const { timeoutMs } = args; - const env = sanitizeInheritedChildProcessEnv({ - env: process.env, - ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}), - }); - const child = spawnPortableOutputProcess({ + const env = operationEnvironment( + args.contributedEnv ?? [], + { + ...(args.env ?? process.env), + ...(args.shellPath !== undefined ? { PATH: args.shellPath } : {}), + }, + true, + ); + const child = spawnPortablePipedProcess({ command: command.command, args: command.args, cwd: args.workspacePath, @@ -146,14 +154,15 @@ async function runLifecycleScript( } }; - const handleChunk = (chunk: Buffer) => { - const text = chunk.toString("utf8"); - outputChunks.push(text); - emitScriptOutputLines(outputLineReader.push(text)); - }; - - child.stdout.on("data", handleChunk); - child.stderr.on("data", handleChunk); + const readers = [child.stdout, child.stderr].map((stream) => { + const decoder = new StringDecoder("utf8"); + const emit = (text: string) => { + outputChunks.push(text); + emitScriptOutputLines(outputLineReader.push(text)); + }; + stream.on("data", (chunk: Buffer) => emit(decoder.write(chunk))); + return () => emit(decoder.end()); + }); const timeout = setTimeout(() => { timedOut = true; @@ -185,6 +194,7 @@ async function runLifecycleScript( if (abortRequested || timedOut) while (isProcessGroupAlive(child)) await delay(25); + for (const flush of readers) flush(); const output = outputChunks.join(""); emitScriptOutputLines(outputLineReader.flush()); const durationMs = Date.now() - startedAt; diff --git a/apps/host-daemon/src/index.ts b/apps/host-daemon/src/index.ts index 89ff0d79f6..f577375c5f 100644 --- a/apps/host-daemon/src/index.ts +++ b/apps/host-daemon/src/index.ts @@ -7,6 +7,7 @@ import { installSafeProcessDiagnostics, writeSafeProcessDiagnosticReport, } from "@bb/process-utils"; +import { hasMachineSuspensionMarker } from "./suspension-marker.js"; interface ReportStartupFailureArgs { diagnosticsLogsDir: string; @@ -48,19 +49,21 @@ function reportStartupFailure(args: ReportStartupFailureArgs): void { async function runHostDaemonEntrypoint(): Promise { const hostDaemonEntrypointConfig = loadHostDaemonEntrypointConfig(); + const hostDaemonStartConfig = loadHostDaemonStartConfig({}); + if (await hasMachineSuspensionMarker(hostDaemonStartConfig.dataDir)) { + return; + } const hostDaemonModule = await import("./start-host-daemon.js"); const daemon = await hostDaemonModule.startHostDaemon({ bbExecutableDirectory: hostDaemonEntrypointConfig.BB_CLI_DIR, bridgeBundleDir: hostDaemonEntrypointConfig.BB_BRIDGE_DIR ?? resolveEntrypointBridgeBundleDir(), - machineCredential: hostDaemonEntrypointConfig.BB_CONNECT_MACHINE_CREDENTIAL, - connectMachineId: hostDaemonEntrypointConfig.BB_CONNECT_MACHINE_ID, + serverHeaders: hostDaemonEntrypointConfig.BB_SERVER_HEADERS, autoUpdate: hostDaemonEntrypointConfig.BB_HOST_DAEMON_AUTO_UPDATE, enrollKey: hostDaemonEntrypointConfig.BB_HOST_ENROLL_KEY, hostId: hostDaemonEntrypointConfig.BB_HOST_ID, hostName: hostDaemonEntrypointConfig.BB_HOST_NAME, - hostType: hostDaemonEntrypointConfig.BB_HOST_TYPE, }); await daemon.waitUntilStopped(); } diff --git a/apps/host-daemon/src/machine-auth-proxy.test.ts b/apps/host-daemon/src/machine-auth-proxy.test.ts index 2ef9bcf95b..69864f99d0 100644 --- a/apps/host-daemon/src/machine-auth-proxy.test.ts +++ b/apps/host-daemon/src/machine-auth-proxy.test.ts @@ -46,7 +46,7 @@ describe("startMachineAuthProxy", () => { const upstreamConnected = once(upstream, "connection"); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -90,7 +90,7 @@ describe("startMachineAuthProxy", () => { }); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -151,7 +151,7 @@ describe("startMachineAuthProxy", () => { }); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_attachment_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_attachment_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -187,7 +187,7 @@ describe("startMachineAuthProxy", () => { }); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -216,7 +216,7 @@ describe("startMachineAuthProxy", () => { }); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -264,7 +264,7 @@ describe("startMachineAuthProxy", () => { }); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -300,7 +300,7 @@ describe("startMachineAuthProxy", () => { }); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -333,7 +333,7 @@ describe("startMachineAuthProxy", () => { }); const upstreamPort = await listen(upstream); const proxy = await startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: `http://127.0.0.1:${upstreamPort}`, }); proxies.push(proxy); @@ -375,7 +375,7 @@ describe("startMachineAuthProxy", () => { await expect( startMachineAuthProxy({ - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, port, serverUrl: "http://server.test", }), diff --git a/apps/host-daemon/src/machine-auth-proxy.ts b/apps/host-daemon/src/machine-auth-proxy.ts index 8f57f867f7..c42f05d2a6 100644 --- a/apps/host-daemon/src/machine-auth-proxy.ts +++ b/apps/host-daemon/src/machine-auth-proxy.ts @@ -8,10 +8,9 @@ import type { AddressInfo, Socket } from "node:net"; import type { Duplex } from "node:stream"; const LOOPBACK_HOST = "127.0.0.1"; -const MACHINE_HEADER = "x-bb-connect-machine"; interface StartMachineAuthProxyOptions { - machineCredential: string; + serverHeaders: Record; serverUrl: string; port?: number; } @@ -93,18 +92,18 @@ function writeRejectedSocket( function upstreamHeaders( headers: IncomingHttpHeaders, target: URL, - machineCredential: string, + serverHeaders: Record, ): IncomingHttpHeaders { return { ...headers, host: target.host, - [MACHINE_HEADER]: machineCredential, + ...serverHeaders, }; } function proxyRequest(args: { boundPort: number | null; - machineCredential: string; + serverHeaders: Record; request: IncomingMessage; response: ServerResponse; target: URL; @@ -134,7 +133,7 @@ function proxyRequest(args: { headers: upstreamHeaders( args.request.headers, args.target, - args.machineCredential, + args.serverHeaders, ), }, (upstreamResponse) => { @@ -159,7 +158,7 @@ function proxyUpgrade(args: { boundPort: number | null; clientSocket: Duplex; head: Buffer; - machineCredential: string; + serverHeaders: Record; request: IncomingMessage; target: URL; }): void { @@ -187,7 +186,7 @@ function proxyUpgrade(args: { headers: upstreamHeaders( args.request.headers, args.target, - args.machineCredential, + args.serverHeaders, ), }); upstreamRequest.on("upgrade", (response, upstreamSocket, upstreamHead) => { @@ -234,7 +233,7 @@ export async function startMachineAuthProxy( const server = http.createServer((request, response) => proxyRequest({ boundPort, - machineCredential: options.machineCredential, + serverHeaders: options.serverHeaders, request, response, target, @@ -246,7 +245,7 @@ export async function startMachineAuthProxy( boundPort, clientSocket: socket, head, - machineCredential: options.machineCredential, + serverHeaders: options.serverHeaders, request, target, }), diff --git a/apps/host-daemon/src/operation-environment.test.ts b/apps/host-daemon/src/operation-environment.test.ts new file mode 100644 index 0000000000..a5985e0d2c --- /dev/null +++ b/apps/host-daemon/src/operation-environment.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { operationEnvironment } from "./operation-environment.js"; + +describe("operation environment", () => { + it("resolves server-relative values without mutating the daemon environment", () => { + const base = { BB_SERVER_URL: "https://server.example" }; + expect( + operationEnvironment( + [ + { + name: "GH_TOKEN", + value: "secret", + source: { core: "machine-git" }, + reason: "Git", + }, + { + name: "PROXY", + value: { serverPath: "/proxy" }, + source: { core: "machine-git" }, + reason: "Proxy", + }, + ], + base, + ), + ).toEqual({ + ...base, + GH_TOKEN: "secret", + PROXY: "https://server.example/proxy", + }); + expect(base).not.toHaveProperty("GH_TOKEN"); + }); +}); diff --git a/apps/host-daemon/src/operation-environment.ts b/apps/host-daemon/src/operation-environment.ts new file mode 100644 index 0000000000..d2a089752c --- /dev/null +++ b/apps/host-daemon/src/operation-environment.ts @@ -0,0 +1,21 @@ +import { sanitizeInheritedChildProcessEnv } from "@bb/process-utils"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; + +export function operationEnvironment( + entries: readonly HostDaemonContributedEnvEntry[], + base: NodeJS.ProcessEnv, + inherited = false, +): NodeJS.ProcessEnv { + const env = inherited + ? sanitizeInheritedChildProcessEnv({ env: base }) + : { ...base }; + for (const entry of entries) { + if (typeof entry.value === "string") env[entry.name] = entry.value; + else { + if (!base.BB_SERVER_URL) + throw new Error("Host environment requires BB_SERVER_URL"); + env[entry.name] = `${base.BB_SERVER_URL}${entry.value.serverPath}`; + } + } + return env; +} diff --git a/apps/host-daemon/src/plugin-host-manager.test.ts b/apps/host-daemon/src/plugin-host-manager.test.ts index 311d037e9a..7f539a9227 100644 --- a/apps/host-daemon/src/plugin-host-manager.test.ts +++ b/apps/host-daemon/src/plugin-host-manager.test.ts @@ -30,6 +30,8 @@ let hangOnDispose = false; export default { experimental_apiVersion: 1, contract: { + environment: { input: anySchema, output: anySchema }, + secretProbe: { input: anySchema, output: anySchema }, echo: { input: anySchema, output: anySchema }, wait: { input: anySchema, output: anySchema }, crash: { input: anySchema, output: anySchema }, @@ -43,6 +45,24 @@ export default { }, experimental_signals: { changed: { payload: anySchema } }, handlers: { + async secretProbe(input, context) { + const secret = process.env.TEST_SECRET ?? null; + if (input.chunks) { + for (const chunk of input.chunks) { + await new Promise((resolve) => process.stderr.write(Buffer.from(chunk), resolve)); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } + if (input.fail) throw new Error(secret); + const payload = { type: secret, true: true, ok: true, nested: [{ text: secret }] }; + await context.experimental_emitSignal("changed", payload); + return payload; + }, + async environment(input) { + const before = process.env.GATE_VALUE; + await new Promise((resolve) => setTimeout(resolve, input.delay ?? 0)); + return { before: before ?? null, after: process.env.GATE_VALUE ?? null, token: process.env.GH_TOKEN ?? null }; + }, echo(input) { return { input, pid: process.pid }; }, wait(_input, context) { return new Promise((resolve) => { @@ -104,6 +124,7 @@ export default { function callCommand(overrides: Partial = {}): PluginCall { return { type: "plugin.host.call", + contributedEnv: [], pluginId: "fixture", generation: "generation-1", artifact: { @@ -168,6 +189,149 @@ describe("PluginHostManager", () => { expect(fetchArtifact).toHaveBeenCalledOnce(); }); + it("scopes setup env, waits for rotation, and returns worker output as-is", async () => { + const manager = await createManager({ + shellEnv: () => ({ npm_config_user_agent: "test" }), + }); + const contribution = (value: string) => [ + { + name: "GATE_VALUE", + value, + reason: "Gate", + source: { core: "machine-environment" as const }, + }, + { + name: "GH_TOKEN", + value: 'worker-secret\nwith"quotes', + reason: "Git", + source: { core: "machine-git" as const }, + }, + ]; + const [first, rotated] = await Promise.all([ + manager.call( + callCommand({ + method: "environment", + input: { delay: 100 }, + contributedEnv: contribution("first"), + }), + ), + manager.call( + callCommand({ + method: "environment", + input: {}, + contributedEnv: contribution("rotated"), + }), + ), + ]); + expect(first.output).toEqual({ + before: "first", + after: "first", + token: 'worker-secret\nwith"quotes', + }); + expect(rotated.output).toEqual({ + before: "rotated", + after: "rotated", + token: 'worker-secret\nwith"quotes', + }); + expect( + (await manager.call(callCommand({ method: "environment", input: {} }))) + .output, + ).toEqual({ before: null, after: null, token: null }); + }); + + it.each(["type", "true", "changed"])( + "preserves worker RPC structure and identifiers when the secret is %s", + async (secret) => { + const onSignal = vi.fn(); + const manager = await createManager({ onSignal }); + const command = callCommand({ + callId: secret, + method: "secretProbe", + input: {}, + contributedEnv: [ + { + name: "TEST_SECRET", + value: secret, + reason: "Probe", + source: { core: "machine-environment" }, + }, + ], + }); + const payload = { + type: secret, + true: true, + ok: true, + nested: [{ text: secret }], + }; + expect(await manager.call(command)).toEqual({ output: payload }); + expect(onSignal).toHaveBeenCalledWith({ + pluginId: "fixture", + generation: "generation-1", + signal: "changed", + payload, + }); + await expect( + manager.call({ ...command, input: { fail: true } }), + ).rejects.toThrow(secret); + }, + ); + + it.each([ + "first-line\nsecond-line", + "first-line\r\nsecond-line", + "π-first\nsecond-line", + ])("frames worker stderr as-is across byte chunks: %j", async (secret) => { + const warn = vi.fn(); + const manager = await createManager({ + logger: { debug: vi.fn(), info: vi.fn(), warn }, + }); + const command = callCommand({ + method: "secretProbe", + input: {}, + contributedEnv: [ + { + name: "TEST_SECRET", + value: secret, + reason: "Probe", + source: { core: "machine-environment" }, + }, + ], + }); + await manager.call(command); + const bytes = Buffer.from( + secret.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n") + "\n", + ); + await manager.call({ + ...command, + contributedEnv: [], + input: { chunks: [...bytes].map((byte) => [byte]) }, + }); + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith( + { + pluginId: "fixture", + origin: "host", + stderr: secret.replaceAll("\r\n", "\n").split("\n")[0], + }, + "Host plugin stderr", + ), + ); + const pendingPrefix = Buffer.from(secret.slice(0, 5)); + await manager.call({ + ...command, + contributedEnv: [], + input: { chunks: [[...pendingPrefix]] }, + }); + await manager.shutdown(); + const records = warn.mock.calls.filter( + ([, message]) => message === "Host plugin stderr", + ); + expect(records.map(([record]) => record.stderr)).toEqual([ + ...secret.replaceAll("\r\n", "\n").split("\n"), + secret.slice(0, 5), + ]); + }); + it("migrates a verified legacy host.js cache entry without downloading", async () => { const fetchArtifact = vi.fn(async () => artifactSource); const { dataDir, manager } = await createManagerFixture({ fetchArtifact }); diff --git a/apps/host-daemon/src/plugin-host-manager.ts b/apps/host-daemon/src/plugin-host-manager.ts index c9ffbcbc64..70a2f617e3 100644 --- a/apps/host-daemon/src/plugin-host-manager.ts +++ b/apps/host-daemon/src/plugin-host-manager.ts @@ -1,3 +1,4 @@ +import { operationEnvironment } from "./operation-environment.js"; import { fork, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; @@ -296,6 +297,10 @@ export class PluginHostManager { callId: command.callId, method: command.method, input: command.input, + envVars: operationEnvironment( + command.contributedEnv, + this.options.shellEnv?.() ?? {}, + ), }) ) { worker.pending.delete(command.callId); @@ -543,7 +548,11 @@ export class PluginHostManager { if (child.stderr !== null) { observeBoundedStderr(child.stderr, (line) => { this.options.logger.warn( - { pluginId: worker.pluginId, origin: "host", stderr: line }, + { + pluginId: worker.pluginId, + origin: "host", + stderr: line, + }, "Host plugin stderr", ); }); diff --git a/apps/host-daemon/src/plugin-host-worker.ts b/apps/host-daemon/src/plugin-host-worker.ts index df1d960072..07d4e23111 100644 --- a/apps/host-daemon/src/plugin-host-worker.ts +++ b/apps/host-daemon/src/plugin-host-worker.ts @@ -1,7 +1,45 @@ +import { z } from "zod"; import { isAbsolute } from "node:path"; import { pathToFileURL } from "node:url"; const HOST_WORKER_PROTOCOL_VERSION = 2; +function createOperationEnvironmentScope(target: NodeJS.ProcessEnv) { + let active = 0; + let current: Record = {}; + let previous: NodeJS.ProcessEnv = {}; + let waiters: Array<() => void> = []; + return async (env: Record): Promise<() => void> => { + const same = () => + Object.keys(current).length === Object.keys(env).length && + Object.entries(env).every(([key, value]) => current[key] === value); + while (active > 0 && !same()) + await new Promise((resolve) => waiters.push(resolve)); + if (active === 0) { + current = env; + previous = {}; + for (const [name, value] of Object.entries(env)) { + previous[name] = target[name]; + target[name] = value; + } + } + active += 1; + return () => { + active -= 1; + if (active !== 0) return; + for (const name of Object.keys(current)) { + if (previous[name] === undefined) delete target[name]; + else target[name] = previous[name]; + } + current = {}; + previous = {}; + const ready = waiters; + waiters = []; + for (const resolve of ready) resolve(); + }; + }; +} + +const acquireEnvironment = createOperationEnvironmentScope(process.env); const RESULT_MAX_BYTES = 8 * 1024 * 1024; const DEFAULT_DISPOSE_TIMEOUT_MS = 5_000; @@ -103,6 +141,7 @@ type ParentMessage = readonly callId: string; readonly method: string; readonly input: unknown; + readonly envVars: Record; } | { readonly type: "cancel"; readonly callId: string } | { readonly type: "dispose" } @@ -236,11 +275,16 @@ function parseParentMessage(value: unknown): ParentMessage | null { typeof value.callId === "string" && typeof value.method === "string" ) { + const envVars = z + .record(z.string().regex(/^[^=\x00]+$/u), z.string()) + .safeParse(value.envVars ?? {}); + if (!envVars.success) return null; return { type: "call", callId: value.callId, method: value.method, input: value.input, + envVars: envVars.data, }; } return null; @@ -492,7 +536,9 @@ async function handleCall( const controller = new AbortController(); activeCalls.set(message.callId, controller); let contextOpen = true; + const releaseEnvironment = await acquireEnvironment(message.envVars); try { + controller.signal.throwIfAborted(); const input = await validate(method.input, message.input); const result = await handler(input, { signal: controller.signal, @@ -548,6 +594,7 @@ async function handleCall( } finally { contextOpen = false; activeCalls.delete(message.callId); + releaseEnvironment(); } } diff --git a/apps/host-daemon/src/server-client.test.ts b/apps/host-daemon/src/server-client.test.ts index 5f3ee36611..d7c4a91a27 100644 --- a/apps/host-daemon/src/server-client.test.ts +++ b/apps/host-daemon/src/server-client.test.ts @@ -64,7 +64,6 @@ describe("createServerClient", () => { const result = client.openSession({ hostId: "host-1", hostName: "Host", - hostType: "persistent", dataDir: "/tmp/bb", instanceId: "instance-1", localApiPort: null, @@ -79,11 +78,14 @@ describe("createServerClient", () => { }); it.each([ - { machineCredential: "bbcm_machine", hasMachineCredential: true }, - { machineCredential: undefined, hasMachineCredential: false }, + { + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, + hasMachineCredential: true, + }, + { serverHeaders: undefined, hasMachineCredential: false }, ])( "reports live machine-credential capability as $hasMachineCredential", - async ({ machineCredential, hasMachineCredential }) => { + async ({ serverHeaders, hasMachineCredential }) => { const fetchFn = vi.fn(async (_input, init) => { expect(JSON.parse(String(init?.body))).toMatchObject({ hasMachineCredential, @@ -103,14 +105,13 @@ describe("createServerClient", () => { getSessionId: () => "session-1", hostKey: "host-key", logger: createLogger(), - ...(machineCredential !== undefined ? { machineCredential } : {}), + ...(serverHeaders !== undefined ? { serverHeaders } : {}), serverUrl: "https://bb.example.test", }); await client.openSession({ hostId: "host-1", hostName: "Host", - hostType: "persistent", dataDir: "/tmp/bb", instanceId: "instance-1", localApiPort: 38_888, @@ -292,7 +293,7 @@ describe("createServerClient", () => { getSessionId: () => "session-1", hostKey: "host-key", logger: createLogger(), - machineCredential: "bbcm_machine", + serverHeaders: { "x-bb-connect-machine": "bbcm_machine" }, serverUrl: "https://bb.example.test", }); diff --git a/apps/host-daemon/src/server-client.ts b/apps/host-daemon/src/server-client.ts index 4279f5d4bd..bf538466bf 100644 --- a/apps/host-daemon/src/server-client.ts +++ b/apps/host-daemon/src/server-client.ts @@ -155,17 +155,15 @@ interface CreateServerClientOptions { serverUrl: string; hostKey: string; logger: HostDaemonLogger; - machineCredential?: string; + serverHeaders?: Record; getSessionId: () => string; beforeInteractiveRequestRegistrationAttempt?: () => Promise; fetchFn?: FetchFn; } interface OpenSessionArgs { - connectMachineId?: string; hostId: string; hostName: string; - hostType: HostDaemonSessionOpenRequest["hostType"]; dataDir: string; instanceId: string; localApiPort: number | null; @@ -394,9 +392,7 @@ export function createServerClient( return { authorization: `Bearer ${options.hostKey}`, "content-type": "application/json", - ...(options.machineCredential !== undefined - ? { "x-bb-connect-machine": options.machineCredential } - : {}), + ...options.serverHeaders, }; } @@ -439,13 +435,9 @@ export function createServerClient( hostId: args.hostId, instanceId: args.instanceId, hostName: args.hostName, - hostType: args.hostType, - ...(args.connectMachineId !== undefined - ? { connectMachineId: args.connectMachineId } - : {}), - hasMachineCredential: - options.machineCredential !== undefined && - options.machineCredential.trim().length > 0, + hasMachineCredential: Boolean( + options.serverHeaders?.["x-bb-connect-machine"]?.trim(), + ), platform: resolveHostPlatform(), dataDir: args.dataDir, localApiPort: args.localApiPort, diff --git a/apps/host-daemon/src/server-connection-support.ts b/apps/host-daemon/src/server-connection-support.ts index d0a1c54db1..5cff2e9fbe 100644 --- a/apps/host-daemon/src/server-connection-support.ts +++ b/apps/host-daemon/src/server-connection-support.ts @@ -5,7 +5,6 @@ import { type HostDaemonConnectSharesReplaceMessage, type HostDaemonOnlineRpcRequestMessage, type HostDaemonServerWsMessage, - type HostDaemonSessionOpenRequest, type HostDaemonSessionOpenResponse, type HostDaemonWatchSetReplaceMessage, } from "@bb/host-daemon-contract"; @@ -43,6 +42,7 @@ export type CreateReconnectingWebSocket = ( export type HostDaemonServerTerminalMessage = Exclude< HostDaemonServerWsMessage, + | { type: "machine.shutdown" } | { type: "session-close" } | { type: "heartbeat-ack" } | HostDaemonOnlineRpcRequestMessage @@ -54,14 +54,12 @@ export interface ServerConnectionOptions { serverUrl: string; hostKey: string; logger: HostDaemonLogger; - machineCredential?: string; - connectMachineId?: string; + serverHeaders?: Record; serverClient: ServerClient; protocolSelfUpdater?: ProtocolSelfUpdater; onSelfUpdateInstalled?: () => void | Promise; hostId: string; hostName: string; - hostType: HostDaemonSessionOpenRequest["hostType"]; dataDir: string; instanceId: string; localApiPort: number | null; @@ -87,6 +85,7 @@ export interface ServerConnectionOptions { onSessionOpened?: ( session: HostDaemonSessionOpenResponse, ) => void | Promise; + onMachineShutdown?: () => void | Promise; createWebSocket?: CreateReconnectingWebSocket; startupTimeoutMs?: number; } diff --git a/apps/host-daemon/src/server-connection.test.ts b/apps/host-daemon/src/server-connection.test.ts index 8146f93e16..a69a838ce1 100644 --- a/apps/host-daemon/src/server-connection.test.ts +++ b/apps/host-daemon/src/server-connection.test.ts @@ -23,10 +23,10 @@ interface CreateWebSocketFixtureArgs { interface ConnectionFixtureArgs extends CreateServerClientFixtureArgs { autoReconnect?: boolean; - connectMachineId?: string; - machineCredential?: string; + serverHeaders?: Record; protocolSelfUpdater?: ProtocolSelfUpdater; onSelfUpdateInstalled?: () => void | Promise; + onMachineShutdown?: () => void | Promise; startupTimeoutMs?: number; } @@ -170,20 +170,17 @@ function createConnectionFixture(args: ConnectionFixtureArgs = {}) { hostId: "host-server-connection-test", hostKey: "host-key-server-connection-test", hostName: "Server Connection Test Host", - hostType: "persistent", instanceId: "instance-server-connection-test", localApiPort: 38_887, logger, - ...(args.machineCredential !== undefined - ? { machineCredential: args.machineCredential } - : {}), - ...(args.connectMachineId !== undefined - ? { connectMachineId: args.connectMachineId } + ...(args.serverHeaders !== undefined + ? { serverHeaders: args.serverHeaders } : {}), serverClient: serverClient.serverClient, serverUrl: "http://127.0.0.1:3334", protocolSelfUpdater: args.protocolSelfUpdater, onSelfUpdateInstalled: args.onSelfUpdateInstalled, + onMachineShutdown: args.onMachineShutdown, startupTimeoutMs: args.startupTimeoutMs, setSession, createWebSocket: webSocket.createWebSocket, @@ -204,6 +201,27 @@ afterEach(() => { }); describe("ServerConnection", () => { + it("dispatches the machine shutdown command", async () => { + const onMachineShutdown = vi.fn(async () => undefined); + const { connection, webSocket } = createConnectionFixture({ + onMachineShutdown, + }); + await connection.start(); + const socket = webSocket.sockets[0]; + if (!socket) throw new Error("Expected test socket"); + + socket.onmessage?.({ + data: JSON.stringify({ + type: "machine.shutdown", + }), + }); + + await vi.waitFor(() => { + expect(onMachineShutdown).toHaveBeenCalledOnce(); + }); + await connection.shutdown(); + }); + it("runs protocol self-update handling only for protocol mismatch rejection", async () => { const handleProtocolMismatch = vi.fn(async () => "updated" as const); const onSelfUpdateInstalled = vi.fn(); @@ -280,7 +298,10 @@ describe("ServerConnection", () => { it("adds the machine credential to WS dial headers only when configured", async () => { const configured = createConnectionFixture({ - machineCredential: "bbcm_machine", + serverHeaders: { + "x-bb-connect-machine": "bbcm_machine", + "x-test-access": "opaque", + }, }); const plain = createConnectionFixture(); try { @@ -289,6 +310,7 @@ describe("ServerConnection", () => { expect(configured.webSocket.headers[0]).toEqual({ authorization: "Bearer host-key-server-connection-test", "x-bb-connect-machine": "bbcm_machine", + "x-test-access": "opaque", }); expect(plain.webSocket.headers[0]).toEqual({ authorization: "Bearer host-key-server-connection-test", @@ -299,23 +321,6 @@ describe("ServerConnection", () => { } }); - it("reports the connect machine id when opening a session", async () => { - const fixture = createConnectionFixture({ - connectMachineId: "machine-cloud-1", - }); - try { - await fixture.connection.start(); - expect(fixture.openSession).toHaveBeenCalledWith( - expect.objectContaining({ - connectMachineId: "machine-cloud-1", - localApiPort: 38_887, - }), - ); - } finally { - await fixture.connection.shutdown(); - } - }); - it("logs delayed heartbeat timer ticks without logging normal heartbeats", async () => { vi.useFakeTimers(); vi.setSystemTime(0); diff --git a/apps/host-daemon/src/server-connection.ts b/apps/host-daemon/src/server-connection.ts index f1adf9ab36..efefc44e98 100644 --- a/apps/host-daemon/src/server-connection.ts +++ b/apps/host-daemon/src/server-connection.ts @@ -342,8 +342,6 @@ export class ServerConnection { hostId: this.options.hostId, instanceId: this.options.instanceId, hostName: this.options.hostName, - hostType: this.options.hostType, - connectMachineId: this.options.connectMachineId, dataDir: this.options.dataDir, localApiPort: this.options.localApiPort, activeThreads: this.options.getActiveThreads?.() ?? [], @@ -420,11 +418,7 @@ export class ServerConnection { authorization: buildHostDaemonWebSocketAuthorizationHeader( this.options.hostKey, ), - ...(this.options.machineCredential !== undefined - ? { - "x-bb-connect-machine": this.options.machineCredential, - } - : {}), + ...this.options.serverHeaders, }, maxRetries: Number.POSITIVE_INFINITY, protocols: buildHostDaemonWebSocketProtocols(), @@ -590,6 +584,18 @@ export class ServerConnection { return; } + if (message.data.type === "machine.shutdown") { + void Promise.resolve(this.options.onMachineShutdown?.()).catch( + (error) => { + this.options.logger.error( + { ...runtimeErrorLogFields(error) }, + "Machine shutdown failed", + ); + }, + ); + return; + } + if (message.data.type === "heartbeat-ack") { if (this.session !== null) { this.lastHeartbeatAcknowledgedAt = Date.now(); diff --git a/apps/host-daemon/src/start-host-daemon.ts b/apps/host-daemon/src/start-host-daemon.ts index da91c89588..bb0f96c4ed 100644 --- a/apps/host-daemon/src/start-host-daemon.ts +++ b/apps/host-daemon/src/start-host-daemon.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import { dirname } from "node:path"; import { loadHostDaemonStartConfig } from "@bb/config/host-daemon"; -import type { HostType } from "@bb/domain"; import { createHostWatcher, createSubprocessParcelWatcherBackend, @@ -37,9 +36,7 @@ interface StartHostDaemonOptions { hostName?: string; bbExecutableDirectory?: string; bridgeBundleDir?: string; - hostType?: HostType; - machineCredential?: string; - connectMachineId?: string; + serverHeaders?: Record; autoUpdate?: boolean; } @@ -88,18 +85,6 @@ export async function startHostDaemon( throw new Error("Host daemon server URL is required"); } - const hostType = - persistedAuth?.hostType ?? options.hostType ?? "persistent"; - if ( - persistedAuth && - options.hostType && - persistedAuth.hostType !== options.hostType - ) { - throw new Error( - `Configured host type ${options.hostType} does not match persisted auth state ${persistedAuth.hostType}`, - ); - } - if (persistedAuth && persistedAuth.hostId !== identity.hostId) { throw new Error( `Resolved host ID ${identity.hostId} does not match persisted auth state ${persistedAuth.hostId}`, @@ -112,10 +97,8 @@ export async function startHostDaemon( await enrollDaemonHost({ hostId: identity.hostId, hostName: identity.hostName, - hostType, - connectMachineId: options.connectMachineId, serverUrl, - machineCredential: options.machineCredential, + serverHeaders: options.serverHeaders, token: options.enrollKey ?? (() => { @@ -131,7 +114,6 @@ export async function startHostDaemon( await writeHostAuthState(dataDir, { hostId: identity.hostId, hostKey, - hostType, }); } @@ -150,9 +132,9 @@ export async function startHostDaemon( transportMode: "worker", }); lockDiagnosticsLogger = logger; - if (options.machineCredential !== undefined) { + if (options.serverHeaders !== undefined) { machineAuthProxy = await startMachineAuthProxy({ - machineCredential: options.machineCredential, + serverHeaders: options.serverHeaders, serverUrl, }); } @@ -185,11 +167,9 @@ export async function startHostDaemon( dataDir, serverUrl, hostKey, - machineCredential: options.machineCredential, - connectMachineId: options.connectMachineId, + serverHeaders: options.serverHeaders, autoUpdate: options.autoUpdate, bridgeBundleDir: options.bridgeBundleDir, - hostType, hostId: identity.hostId, hostName: identity.hostName, instanceId, diff --git a/apps/host-daemon/src/suspension-marker.ts b/apps/host-daemon/src/suspension-marker.ts new file mode 100644 index 0000000000..d524965b0e --- /dev/null +++ b/apps/host-daemon/src/suspension-marker.ts @@ -0,0 +1,23 @@ +import { access, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export const MACHINE_SUSPENSION_MARKER = "machine-suspended"; + +export async function hasMachineSuspensionMarker( + dataDir: string, +): Promise { + try { + await access(join(dataDir, MACHINE_SUSPENSION_MARKER)); + return true; + } catch { + return false; + } +} + +export async function writeMachineSuspensionMarker( + dataDir: string, +): Promise { + await writeFile(join(dataDir, MACHINE_SUSPENSION_MARKER), "", { + mode: 0o600, + }); +} diff --git a/apps/host-daemon/src/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts index 4c504308f4..d6c5814a0c 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.test.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.test.ts @@ -360,9 +360,11 @@ function shellQuote(value: string): string { async function openTerminal( harness: TerminalManagerHarness, + contributedEnv: import("@bb/host-daemon-contract").HostDaemonContributedEnvEntry[] = [], ): Promise { await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv, requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -424,11 +426,35 @@ describe("TerminalManager", () => { ).resolves.toEqual([]); }); + it("injects host credentials into a PTY and forwards terminal output as-is", async () => { + const harness = createHarness(); + const pty = await openTerminal(harness, [ + { + name: "GH_TOKEN", + value: "terminal-private-token", + source: { core: "machine-git" }, + reason: "Git", + }, + ]); + expect(harness.adapter.spawned[0]?.args.env.GH_TOKEN).toBe( + "terminal-private-token", + ); + pty.emitData("terminal-private-token"); + await waitForOutputContaining({ + messages: harness.messages, + text: "terminal-private-token", + }); + expect(collectTerminalOutput(harness.messages)).toContain( + "terminal-private-token", + ); + }); + it("opens a command PTY through the resolved shell", async () => { const harness = createHarness(); await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-command", terminalId: "term-command", threadId: "thr-1", @@ -466,6 +492,7 @@ describe("TerminalManager", () => { await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-host-path", terminalId: "term-host-path", target: { @@ -501,6 +528,7 @@ describe("TerminalManager", () => { await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-host-home", terminalId: "term-host-home", target: { @@ -540,6 +568,7 @@ describe("TerminalManager", () => { const openPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -602,6 +631,7 @@ describe("TerminalManager", () => { const openPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -660,6 +690,7 @@ describe("TerminalManager", () => { const openPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -709,6 +740,7 @@ describe("TerminalManager", () => { const firstOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -727,6 +759,7 @@ describe("TerminalManager", () => { const secondOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-2", terminalId: "term-1", threadId: "thr-1", @@ -781,6 +814,7 @@ describe("TerminalManager", () => { const firstOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -799,6 +833,7 @@ describe("TerminalManager", () => { const secondOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-2", terminalId: "term-1", threadId: "thr-1", @@ -859,6 +894,7 @@ describe("TerminalManager", () => { await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-stale", terminalId: "term-stale", threadId: "thr-1", @@ -1406,6 +1442,7 @@ describe("TerminalManager", () => { await manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -1464,6 +1501,7 @@ describe("TerminalManager", () => { await manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-real", terminalId: "term-real", threadId: "thr-real", diff --git a/apps/host-daemon/src/terminals/terminal-manager.ts b/apps/host-daemon/src/terminals/terminal-manager.ts index de08ca6930..471801f20a 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.ts @@ -1,3 +1,4 @@ +import { operationEnvironment } from "../operation-environment.js"; import { accessSync, chmodSync, constants, existsSync } from "node:fs"; import { access, stat } from "node:fs/promises"; import { createRequire } from "node:module"; @@ -585,10 +586,13 @@ export class TerminalManager { args: terminalSpawnArgsForStart(message), cols: message.cols, cwd: target.cwd, - env: buildTerminalEnv({ - shellEnv: this.options.runtimeManager.getShellEnv(), - terminalId: message.terminalId, - }), + env: operationEnvironment( + message.contributedEnv, + buildTerminalEnv({ + shellEnv: this.options.runtimeManager.getShellEnv(), + terminalId: message.terminalId, + }), + ), file: shell, logger: this.options.logger, rows: message.rows, diff --git a/apps/host-daemon/test/command/environment-hook.test.ts b/apps/host-daemon/test/command/environment-hook.test.ts index 315fd1f1b2..4c07ce3a9e 100644 --- a/apps/host-daemon/test/command/environment-hook.test.ts +++ b/apps/host-daemon/test/command/environment-hook.test.ts @@ -34,6 +34,7 @@ it("streams hook output and cancels the process before the run RPC settles", asy dispatchOnlineRpcCommand( { type: "environment.hook.run", + contributedEnv: [], resumeOnly: false, operationId: "hook-1", path, @@ -56,6 +57,7 @@ it("reconciles running and completed hook IDs without executing a second shell", const options = createHarness().dispatchOptions({ dataDir: path }); const command = { type: "environment.hook.run" as const, + contributedEnv: [], resumeOnly: false, operationId: "resume", path, @@ -84,6 +86,7 @@ it("rejects unknown recovery and cancels delayed dispatch within this daemon", a dispatchOnlineRpcCommand( { type: "environment.hook.run", + contributedEnv: [], resumeOnly: true, operationId: "unknown", path, @@ -103,6 +106,7 @@ it("rejects unknown recovery and cancels delayed dispatch within this daemon", a dispatchOnlineRpcCommand( { type: "environment.hook.run", + contributedEnv: [], resumeOnly: false, operationId: "unknown", path, @@ -124,6 +128,7 @@ it("reports unknown after daemon memory is lost without rerunning the script", a const firstOptions = createHarness().dispatchOptions({ dataDir: path }); const command = { type: "environment.hook.run" as const, + contributedEnv: [], resumeOnly: false, operationId: "restart", path, @@ -157,3 +162,121 @@ it("reports unknown after daemon memory is lost without rerunning the script", a } await expect(readFile(join(path, "completed"))).rejects.toThrow(); }); + +it.each(["setup", "teardown"] as const)( + "injects %s contributions and forwards progress as-is", + async (kind) => { + const path = await makeTempDir("bb-hook-environment-"); + const secret = "hook-secret-fixture"; + await writeFile( + join(path, `.bb-env-${kind}.sh`), + 'test "$HOOK_PLAIN" = configured || exit 1\nprintf "%s" "$GH_TOKEN" > received\nprintf "%s\\n" "$GH_TOKEN"\nexit 1\n', + ); + const options = createHarness().dispatchOptions({ dataDir: path }); + const output: string[] = []; + options.emitEnvironmentHookProgress = (message) => + output.push(message.entry.text); + const command = { + type: "environment.hook.run" as const, + contributedEnv: [ + { + name: "GH_TOKEN", + value: secret, + source: { core: "machine-environment" as const }, + reason: "test", + }, + { + name: "HOOK_PLAIN", + value: "configured", + source: { core: "machine-environment" as const }, + reason: "test", + }, + ], + resumeOnly: false, + operationId: `env-${kind}`, + path, + kind, + timeoutMs: 5000, + }; + if (kind === "setup") + await expect(dispatchOnlineRpcCommand(command, options)).rejects.toThrow( + "exit code 1", + ); + else await dispatchOnlineRpcCommand(command, options); + expect(await readFile(join(path, "received"), "utf8")).toBe(secret); + expect(output.join("\n")).toContain(secret); + expect(process.env.GH_TOKEN).not.toBe(secret); + }, +); + +it.each(["setup", "teardown"] as const)( + "streams multiline contributed environment values from %s hook lines", + async (kind) => { + const path = await makeTempDir("bb-hook-multiline-"); + await writeFile( + join(path, `.bb-env-${kind}.sh`), + 'printf "%s\\n" "$MULTILINE" | while IFS= read -r line; do printf "%s\\n" "$line"; sleep 0.05; done\nprintf "%s\\n" "$MULTILINE" | while IFS= read -r line; do printf "%s\\n" "$line"; sleep 0.05; done >&2\n', + ); + const output: string[] = []; + const options = createHarness().dispatchOptions({ dataDir: path }); + options.emitEnvironmentHookProgress = (message) => { + output.push(message.entry.text); + }; + await dispatchOnlineRpcCommand( + { + type: "environment.hook.run", + contributedEnv: [ + { + name: "MULTILINE", + value: "HEADER\nPRIVATE_BODY\nFOOTER", + source: { core: "machine-environment" as const }, + reason: "test", + }, + ], + resumeOnly: false, + operationId: `output-${kind}`, + path, + kind, + timeoutMs: 5000, + }, + options, + ); + expect(output.join("\n")).toContain("PRIVATE_BODY"); + }, +); + +it("applies hook NODE_ENV and PATH contributions after sanitizing inherited state", async () => { + const path = await makeTempDir("bb-hook-overrides-"); + await writeFile( + join(path, ".bb-env-setup.sh"), + 'printf "NODE_ENV=%s\\nPATH=%s\\n" "$NODE_ENV" "$PATH"; sleep 0.1\n', + ); + const output: string[] = []; + const options = createHarness().dispatchOptions({ dataDir: path }); + options.emitEnvironmentHookProgress = (message) => { + output.push(message.entry.text); + }; + const contributedEnv = Object.entries({ + NODE_ENV: "production", + PATH: "/review-toolchain:/usr/bin:/bin", + }).map(([name, value]) => ({ + name, + value, + source: { core: "machine-environment" as const }, + reason: "test", + })); + await dispatchOnlineRpcCommand( + { + type: "environment.hook.run", + contributedEnv, + resumeOnly: false, + operationId: "overrides", + path, + kind: "setup", + timeoutMs: 5000, + }, + options, + ); + expect(output).toContain("NODE_ENV=production"); + expect(output).toContain("PATH=/review-toolchain:/usr/bin:/bin"); +}); diff --git a/apps/host-daemon/test/command/project-clone-private-env.test.ts b/apps/host-daemon/test/command/project-clone-private-env.test.ts new file mode 100644 index 0000000000..430f6e1572 --- /dev/null +++ b/apps/host-daemon/test/command/project-clone-private-env.test.ts @@ -0,0 +1,59 @@ +import { writeFile, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it, vi } from "vitest"; +import { dispatchCommand } from "../../src/command-dispatch.js"; +import { + cleanupTempDirs, + createHarness, + makeTempDir, +} from "./dispatch-helpers.js"; + +afterEach(async () => { + vi.unstubAllEnvs(); + await cleanupTempDirs(); +}); +it("strips daemon-private inherited variables and returns clone failures as-is", async () => { + const dir = await makeTempDir("bb-clone-private-"); + const helper = join(dir, "helper.sh"); + const capture = join(dir, "environment"); + await writeFile( + helper, + `env > '${capture}'\nprintf '%s\\n' "$CONTRIBUTED_SECRET" 'private-daemon-token' >&2\nexit 77\n`, + ); + vi.stubEnv( + "BB_SERVER_HEADERS", + JSON.stringify({ "x-bb-connect-machine": "private-daemon-token" }), + ); + vi.stubEnv("BB_PRIVATE_TEST", "other-daemon-private"); + const contributedEnv = Object.entries({ + GIT_SSH_COMMAND: `/bin/sh '${helper}'`, + CONTRIBUTED_SECRET: "contributed-private", + }).map(([name, value]) => ({ + name, + value, + source: { core: "machine-environment" as const }, + reason: "test", + })); + let failure = ""; + try { + await dispatchCommand( + { + type: "project.clone", + operationId: "private-clone", + projectSlug: "test", + remoteUrl: "ssh://git@invalid.example/repo", + targetPath: join(dir, "clone"), + contributedEnv, + }, + createHarness().dispatchOptions({ dataDir: dir }), + ); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + const environment = await readFile(capture, "utf8"); + expect(environment).not.toContain("BB_SERVER_HEADERS"); + expect(environment).not.toContain("BB_PRIVATE_TEST"); + expect(environment).toContain("CONTRIBUTED_SECRET=contributed-private"); + expect(failure).toContain("private-daemon-token"); + expect(failure).toContain("contributed-private"); +}); diff --git a/apps/server/src/assets/install-machine.sh b/apps/server/src/assets/install-machine.sh index 6867d4cb60..b6d8d84658 100755 --- a/apps/server/src/assets/install-machine.sh +++ b/apps/server/src/assets/install-machine.sh @@ -5,6 +5,8 @@ set -eu usage() { cat >&2 <<'EOF' Usage: install.sh --join-code --host-id --server [--machine-code ] [--host-daemon-port ] + install.sh --bootstrap-env + install.sh --start|--stop|--uninstall --host-id [--server-url ] [--data-dir ] The first three options are required. --machine-code is required through bb connect. By default, the installer assigns this enrolled daemon its own local API port. @@ -12,11 +14,14 @@ EOF exit 2 } +bootstrap_env= join_code= host_id= server_url= machine_code= requested_host_daemon_port= +lifecycle_action= +requested_data_dir= CURL_CONNECT_TIMEOUT_SECONDS=10 PACKAGE_DOWNLOAD_TIMEOUT_SECONDS=300 @@ -96,20 +101,186 @@ ready_row() { log " " "$(dim "$ready_label") $2" } +run_lifecycle() { + case "$host_id" in *[!A-Za-z0-9_-]*|'') fail_step "Invalid machine host ID."; exit 2 ;; esac + lifecycle_data_dir=${requested_data_dir:-${BB_DATA_DIR:-}} + installation=$(node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const [home, requested, hostId, expectedServer] = process.argv.slice(1); + const root = path.join(home, ".bb-machines"); + let candidates; + if (requested) candidates = [path.resolve(requested)]; + else { + try { candidates = fs.readdirSync(root).map((name) => path.join(root, name)); } + catch (error) { if (error.code === "ENOENT") process.exit(3); throw error; } + } + const canonicalRoot = fs.realpathSync(root); + const matches = []; + for (const candidate of candidates) { + let auth; + try { auth = JSON.parse(fs.readFileSync(path.join(candidate, "auth.json"), "utf8")); } + catch (error) { if (error.code === "ENOENT") continue; throw error; } + if (auth.hostId !== hostId) { + if (requested) throw new Error("Machine data directory belongs to another host."); + continue; + } + const dataDir = fs.realpathSync(candidate); + if (path.dirname(dataDir) !== canonicalRoot || fs.lstatSync(candidate).isSymbolicLink()) { + throw new Error("Refusing a machine data directory outside its installer-owned root."); + } + const config = JSON.parse(fs.readFileSync(path.join(dataDir, "config.json"), "utf8")); + const serverUrl = new URL(config.serverUrl).href.replace(/\/+$/u, ""); + if (expectedServer && serverUrl !== new URL(expectedServer).href.replace(/\/+$/u, "")) { + throw new Error("Machine data directory belongs to another server."); + } + matches.push({ dataDir, serverUrl }); + } + if (matches.length > 1) throw new Error("Host identity matches multiple machine installations; specify --data-dir."); + if (matches.length === 0) process.exit(3); + process.stdout.write(JSON.stringify(matches[0])); + ' "$HOME" "$lifecycle_data_dir" "$host_id" "$server_url") || { + lifecycle_status=$? + if [ "$lifecycle_status" -eq 3 ]; then + if [ "$lifecycle_action" = start ]; then fail_step "Machine installation was not found."; exit 1; fi + return 0 + fi + exit "$lifecycle_status" + } + data_dir=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).dataDir)' "$installation") + server_url=$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).serverUrl)' "$installation") + host_daemon_port=$(sed -n '1p' "$data_dir/host-daemon-port") + case "$host_daemon_port" in *[!0-9]*|'') fail_step "Refusing an invalid daemon port."; exit 1 ;; esac + if [ "$host_daemon_port" -lt 1024 ] || [ "$host_daemon_port" -gt 65535 ] || [ "$host_daemon_port" -eq 38886 ] || [ "$host_daemon_port" -eq 38887 ]; then + fail_step "Refusing an invalid or default daemon port." + exit 1 + fi + lifecycle_server_host=$(node -e 'process.stdout.write(new URL(process.argv[1]).host.replace(/[^a-zA-Z0-9.-]/gu, "-"))' "$server_url") + lifecycle_slug=$(printf '%s-%s' "$lifecycle_server_host" "$host_id" | tr '.' '-') + systemd_scope=--user + if [ "$platform" = darwin ]; then + service_name="app.getbb.host-daemon.$lifecycle_slug" + service_file="$HOME/Library/LaunchAgents/$service_name.plist" + else + service_name="bb-host-daemon-$lifecycle_slug.service" + service_file="$HOME/.config/systemd/user/$service_name" + system_service_file="$data_dir/systemd/$service_name" + if [ -f "$system_service_file" ]; then + [ "$(id -u)" -eq 0 ] || { fail_step "Machine system service requires root."; exit 1; } + [ ! -e "$service_file" ] || { fail_step "Machine has both user and system services."; exit 1; } + [ ! -L "${system_service_file%/*}" ] || { fail_step "Refusing a symlinked machine system service directory."; exit 1; } + service_file=$system_service_file + systemd_scope=--system + fi + fi + if [ -e "$service_file" ]; then + [ ! -L "$service_file" ] || { fail_step "Machine service belongs to another installation."; exit 1; } + BB_LIFECYCLE_DATA_DIR="$data_dir" node -e ' + const fs = require("node:fs"); + const service = fs.readFileSync(process.argv[1], "utf8"); + const dataDir = process.env.BB_LIFECYCLE_DATA_DIR; + const escaped = dataDir.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'"'"'", "'"); + const systemd = dataDir.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("%", "%%"); + if (!service.includes(`BB_DATA_DIR${escaped}`) && !service.includes(`Environment="BB_DATA_DIR=${systemd}"`)) { + process.stderr.write(`Machine service does not reference ${dataDir}.\n`); + process.exit(1); + } + ' "$service_file" || { fail_step "Machine service belongs to another installation."; exit 1; } + fi + daemon_matches() { + node -e ' + const [port, hostId, serverUrl] = process.argv.slice(1); + void fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(750) }) + .then(async (response) => { + const status = await response.json(); + const normalize = (value) => new URL(String(value)).href.replace(/\/+$/u, ""); + process.exit(status.hostId === hostId && normalize(status.serverUrl) === normalize(serverUrl) ? 0 : 1); + }).catch(() => process.exit(1)); + ' "$host_daemon_port" "$host_id" "$server_url" >/dev/null 2>&1 + } + pid_file="$data_dir/install-daemon.pid" + daemon_pid= + if [ -f "$pid_file" ]; then daemon_pid=$(sed -n '1p' "$pid_file"); fi + owned_pid() { + [ -n "$daemon_pid" ] || return 1 + case "$daemon_pid" in *[!0-9]*|'0'|'1') fail_step "Invalid installed daemon PID."; exit 1 ;; esac + daemon_command=$(ps -p "$daemon_pid" -o command= 2>/dev/null) || return 1 + launcher="$data_dir/npm/bin/bb-app" + case " $daemon_command " in *" $launcher "*" host-daemon "*" --host-daemon-port $host_daemon_port "*" --server-url $server_url "*) return 0 ;; esac + fail_step "Recorded daemon PID belongs to another process." + exit 1 + } + if [ "$lifecycle_action" = start ]; then + rm -f "$data_dir/machine-suspended" + if daemon_matches; then return 0; fi + if [ -f "$service_file" ]; then + if [ "$platform" = darwin ]; then + launchctl bootstrap "gui/$(id -u)" "$service_file" + else + systemctl "$systemd_scope" enable "$service_file" >/dev/null + systemctl "$systemd_scope" start "$service_name" + fi + elif ! owned_pid; then + BB_APP_NPM_PREFIX="$data_dir/npm" BB_DATA_DIR="$data_dir" nohup "$data_dir/npm/bin/bb-app" host-daemon --auto-update --host-daemon-port "$host_daemon_port" --server-url "$server_url" >"$data_dir/install-daemon.log" 2>&1 & + daemon_pid=$! + (umask 077 && printf '%s\n' "$daemon_pid" >"$pid_file") + fi + lifecycle_attempt=0 + while [ "$lifecycle_attempt" -lt 80 ]; do + if daemon_matches; then return 0; fi + lifecycle_attempt=$((lifecycle_attempt + 1)) + sleep 0.25 + done + fail_step "Machine daemon did not start within 20 seconds." + exit 1 + fi + if [ -f "$service_file" ]; then + if [ "$platform" = darwin ]; then + launchctl bootout "gui/$(id -u)" "$service_file" >/dev/null 2>&1 || true + elif [ "$lifecycle_action" = uninstall ]; then + systemctl "$systemd_scope" disable --now "$service_name" + else + systemctl "$systemd_scope" stop "$service_name" + fi + fi + if owned_pid; then kill "$daemon_pid"; fi + lifecycle_attempt=0 + while [ "$lifecycle_attempt" -lt 80 ]; do + if ! daemon_matches && ! owned_pid; then break; fi + lifecycle_attempt=$((lifecycle_attempt + 1)) + sleep 0.25 + done + [ "$lifecycle_attempt" -lt 80 ] || { fail_step "Machine daemon did not stop within 20 seconds."; exit 1; } + rm -f "$pid_file" + if [ "$lifecycle_action" = uninstall ]; then + if [ -f "$service_file" ]; then rm -f "$service_file"; fi + if [ "$platform" = linux ]; then systemctl "$systemd_scope" daemon-reload; fi + node -e 'require("node:fs").rmSync(process.argv[1], { recursive: true })' "$data_dir" + fi +} + while [ "$#" -gt 0 ]; do case "$1" in - --join-code|--host-id|--server|--machine-code|--host-daemon-port) + --bootstrap-env|--join-code|--host-id|--server|--server-url|--machine-code|--host-daemon-port|--data-dir) [ "$#" -ge 2 ] || usage [ -n "$2" ] || usage case "$1" in + --bootstrap-env) bootstrap_env=$2 ;; --join-code) join_code=$2 ;; --host-id) host_id=$2 ;; --server) server_url=$2 ;; + --server-url) server_url=$2 ;; --machine-code) machine_code=$2 ;; --host-daemon-port) requested_host_daemon_port=$2 ;; + --data-dir) requested_data_dir=$2 ;; esac shift 2 ;; + --start|--stop|--uninstall) + [ -z "$lifecycle_action" ] || usage + lifecycle_action=${1#--} + shift + ;; -h|--help) usage ;; *) fail_step "Unknown option: $1" @@ -118,10 +289,34 @@ while [ "$#" -gt 0 ]; do esac done -[ -n "$join_code" ] || usage +if [ -n "$lifecycle_action" ]; then + [ -z "$bootstrap_env$join_code$machine_code$requested_host_daemon_port" ] || usage +elif [ -n "$bootstrap_env" ]; then + if [ -n "$join_code$host_id$server_url$machine_code" ]; then usage; fi + host_id=$(node -e ' + const name = process.argv[1]; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) process.exit(2); + try { + const bundle = JSON.parse(process.env[name]); + if (typeof bundle.hostId !== "string" || !bundle.hostId) process.exit(2); + process.stdout.write(bundle.hostId); + } catch { process.exit(2); } + ' "$bootstrap_env") || usage + server_url=$(node -e ' + try { + const bundle = JSON.parse(process.env[process.argv[1]]); + const url = new URL(bundle.serverUrl); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) process.exit(2); + process.stdout.write(url.href.replace(/\/$/u, "")); + } catch { process.exit(2); } + ' "$bootstrap_env") || usage + bootstrap_payload=$(node -e 'process.stdout.write(process.env[process.argv[1]])' "$bootstrap_env") + unset "$bootstrap_env" +else + [ -n "$join_code" ] || usage +fi [ -n "$host_id" ] || usage -[ -n "$server_url" ] || usage - +if [ -z "$lifecycle_action" ]; then [ -n "$server_url" ] || usage; fi printf '\n %s\n\n' "$(bold "bb machine setup")" active_step "Setting up this machine as $host_id for $server_url" @@ -153,6 +348,16 @@ if [ "$node_supported" != yes ]; then fi node_bin=$(command -v node) +if [ -z "${HOME:-}" ]; then + HOME=$(node -e 'const home = require("node:os").homedir(); if (!require("node:path").isAbsolute(home)) process.exit(1); process.stdout.write(home);') + export HOME +fi + +if [ -n "$lifecycle_action" ]; then + run_lifecycle + exit 0 +fi + require_npm() { if ! command -v npm >/dev/null 2>&1; then fail_step "bb-app installation requires npm." @@ -167,13 +372,48 @@ server_host=$(node -e ' fail_step "Could not parse the server URL $server_url." exit 1 } -service_slug=$(printf '%s' "$server_host" | tr '.' '-') +host_slug=$(printf '%s' "$host_id" | tr -c 'a-zA-Z0-9_.-' '-') +service_slug=$(printf '%s-%s' "$server_host" "$host_slug" | tr '.' '-') +legacy_service_slug=$(printf '%s' "$server_host" | tr '.' '-') # Each server gets its own data dir and daemon instance, so one machine can # serve several bb servers and a full local bb install keeps ~/.bb to itself. data_dir=${BB_DATA_DIR:-"$HOME/.bb-machines/$server_host"} +mkdir -p "$HOME/.local/bin" +if [ ! -e "$HOME/.local/bin/bb" ] && [ ! -L "$HOME/.local/bin/bb" ]; then + shim_file=$(mktemp "$HOME/.local/bin/.bb-machine.XXXXXX") + node_path_quoted=$(printf '%s' "${node_bin%/*}" | sed "s/'/'\\''/g") + printf '#!/bin/sh\nPATH=\047%s\047:"$PATH"\nexport PATH\n' "$node_path_quoted" > "$shim_file" + cli_path_quoted=$(printf '%s' "$data_dir/npm/bin/bb" | sed "s/'/'\\''/g") + cat >> "$shim_file" <<'BB_MACHINE_EXPLICIT_DATA' +if [ -n "${BB_DATA_DIR:-}" ] && [ -x "$BB_DATA_DIR/npm/bin/bb" ]; then + exec "$BB_DATA_DIR/npm/bin/bb" "$@" +fi +BB_MACHINE_EXPLICIT_DATA + printf 'if [ -x \047%s\047 ]; then exec \047%s\047 "$@"; fi\n' "$cli_path_quoted" "$cli_path_quoted" >> "$shim_file" + cat >> "$shim_file" <<'BB_MACHINE_CLI' +unset BB_DATA_DIR +for candidate in "$HOME"/.bb-machines/*/npm/bin/bb; do + if [ -x "$candidate" ]; then exec "$candidate" "$@"; fi +done +if [ "${1:-}" = machine ] && [ "${2:-}" = uninstall ]; then exit 0; fi +printf '%s\n' 'No installed bb machine CLI is available.' >&2 +exit 1 +BB_MACHINE_CLI + chmod 755 "$shim_file" + if ! ln "$shim_file" "$HOME/.local/bin/bb" 2>/dev/null; then + if [ ! -e "$HOME/.local/bin/bb" ] && [ ! -L "$HOME/.local/bin/bb" ]; then + rm -f "$shim_file" + fail_step "Could not publish the machine CLI shim." + exit 1 + fi + fi + rm -f "$shim_file" +fi + mkdir -p "$data_dir" mkdir -p "$data_dir/logs" +rm -f "$data_dir/machine-suspended" canonical_data_dir=$(node -e ' const fs = require("node:fs"); process.stdout.write(fs.realpathSync(process.argv[1])); @@ -188,8 +428,6 @@ machine_npm_prefix="$canonical_data_dir/npm" # npm 10 ignores the unknown flag; npm 11 accepts it. bb_app_native_modules="better-sqlite3,node-pty,@parcel/watcher" bb_app_allow_scripts="--allow-scripts=$bb_app_native_modules" -port_registry_dir="$HOME/.bb-machines/host-daemon-ports" -mkdir -p "$port_registry_dir" valid_port() { node -e ' @@ -258,60 +496,12 @@ report_wait_progress() { fi } -reservation_owner() { - sed -n '1p' "$port_registry_dir/$1/data-dir" 2>/dev/null || true -} - -claim_port_for_data_dir() { - claim_port=$1 - claim_data_dir=$2 - claim_dir="$port_registry_dir/$claim_port" - if mkdir "$claim_dir" 2>/dev/null; then - claim_owner_temp="$claim_dir/data-dir.$$.tmp" - (umask 077 && printf '%s\n' "$claim_data_dir" >"$claim_owner_temp") - mv "$claim_owner_temp" "$claim_dir/data-dir" - return 0 - fi - [ "$(reservation_owner "$claim_port")" = "$claim_data_dir" ] -} - -release_port_for_data_dir() { - release_port=$1 - release_data_dir=$2 - release_dir="$port_registry_dir/$release_port" - if [ "$(reservation_owner "$release_port")" = "$release_data_dir" ]; then - rm -f "$release_dir/data-dir" - rmdir "$release_dir" 2>/dev/null || true - fi -} - -# Migrate reservations from installs created before the global registry. Each -# per-port mkdir is the allocation lock: concurrent installers cannot claim the -# same port even after its availability probe closes. -register_existing_default_ports() { - for existing_data_dir in "$HOME/.bb-machines"/*; do - [ -d "$existing_data_dir" ] || continue - existing_port_file="$existing_data_dir/host-daemon-port" - [ -f "$existing_port_file" ] || continue - existing_port=$(sed -n '1p' "$existing_port_file") - valid_port "$existing_port" || continue - existing_canonical_data_dir=$(node -e ' - const fs = require("node:fs"); - process.stdout.write(fs.realpathSync(process.argv[1])); - ' "$existing_data_dir") - claim_port_for_data_dir "$existing_port" "$existing_canonical_data_dir" || true - done -} - -find_and_claim_available_host_daemon_port() { +find_available_host_daemon_port() { candidate_port=38888 while [ "$candidate_port" -le 65535 ]; do - if claim_port_for_data_dir "$candidate_port" "$canonical_data_dir"; then - if port_is_available "$candidate_port"; then - printf '%s\n' "$candidate_port" - return 0 - fi - release_port_for_data_dir "$candidate_port" "$canonical_data_dir" + if port_is_available "$candidate_port"; then + printf '%s\n' "$candidate_port" + return 0 fi candidate_port=$((candidate_port + 1)) done @@ -319,26 +509,15 @@ find_and_claim_available_host_daemon_port() { return 1 } -register_existing_default_ports host_daemon_port_file="$data_dir/host-daemon-port" -previous_host_daemon_port= -if [ -f "$host_daemon_port_file" ]; then - previous_host_daemon_port=$(sed -n '1p' "$host_daemon_port_file") -fi host_daemon_port= if [ -n "$requested_host_daemon_port" ]; then if ! valid_port "$requested_host_daemon_port"; then fail_step "--host-daemon-port must be an integer between 1 and 65535." exit 2 fi - if ! claim_port_for_data_dir "$requested_host_daemon_port" "$canonical_data_dir"; then - fail_step "Host daemon local API port $requested_host_daemon_port is reserved by another bb enrollment." - detail "Choose another value for --host-daemon-port and rerun this command." >&2 - exit 1 - fi if ! port_is_available "$requested_host_daemon_port" && \ ! daemon_status_matches "$requested_host_daemon_port" no; then - release_port_for_data_dir "$requested_host_daemon_port" "$canonical_data_dir" fail_step "Host daemon local API port $requested_host_daemon_port is already in use." detail "Choose another value for --host-daemon-port and rerun this command." >&2 exit 1 @@ -347,26 +526,19 @@ if [ -n "$requested_host_daemon_port" ]; then elif [ -f "$host_daemon_port_file" ]; then stored_host_daemon_port=$(sed -n '1p' "$host_daemon_port_file") if valid_port "$stored_host_daemon_port" && \ - claim_port_for_data_dir "$stored_host_daemon_port" "$canonical_data_dir" && \ { port_is_available "$stored_host_daemon_port" || daemon_status_matches "$stored_host_daemon_port" no; }; then host_daemon_port=$stored_host_daemon_port else - if valid_port "$stored_host_daemon_port"; then - release_port_for_data_dir "$stored_host_daemon_port" "$canonical_data_dir" - fi warning_step "Stored host-daemon port $stored_host_daemon_port is unavailable; assigning a new port." fi fi if [ -z "$host_daemon_port" ]; then - host_daemon_port=$(find_and_claim_available_host_daemon_port) + host_daemon_port=$(find_available_host_daemon_port) fi host_daemon_port_temp="$host_daemon_port_file.$$.tmp" (umask 077 && printf '%s\n' "$host_daemon_port" >"$host_daemon_port_temp") mv "$host_daemon_port_temp" "$host_daemon_port_file" -if valid_port "$previous_host_daemon_port" && [ "$previous_host_daemon_port" != "$host_daemon_port" ]; then - release_port_for_data_dir "$previous_host_daemon_port" "$canonical_data_dir" -fi complete_step "Using local host-daemon port $host_daemon_port" # The server's own build is always installed when it offers one: version @@ -375,6 +547,12 @@ complete_step "Using local host-daemon port $host_daemon_port" package_url="${server_url%/}/install/bb-app.tgz" package_dir=$(mktemp -d "${TMPDIR:-/tmp}/bb-app.XXXXXX") package_file="$package_dir/bb-app.tgz" +access_config="$package_dir/access.curl" +: > "$access_config" +chmod 600 "$access_config" +if [ -n "$bootstrap_env" ]; then + BB_ENROLLMENT="$bootstrap_payload" node -e 'for (const [name,value] of Object.entries(JSON.parse(process.env.BB_ENROLLMENT).headers ?? {})) console.log("header = " + JSON.stringify(name + ": " + value))' > "$access_config" +fi package_headers="$package_dir/headers" host_artifact_digest_file="$data_dir/host-artifact.sha256" installed_artifact_digest= @@ -397,7 +575,7 @@ if [ ! -t 2 ]; then fi active_step "Downloading the server's bb-app package (timeout: 5 minutes)" if [ -n "$installed_artifact_digest" ]; then - package_status=$(curl "$curl_output_mode" --show-error --location \ + package_status=$(curl --config "$access_config" "$curl_output_mode" --show-error --location \ --connect-timeout "$CURL_CONNECT_TIMEOUT_SECONDS" \ --max-time "$PACKAGE_DOWNLOAD_TIMEOUT_SECONDS" \ --header "If-None-Match: \"sha256-$installed_artifact_digest\"" \ @@ -406,7 +584,7 @@ if [ -n "$installed_artifact_digest" ]; then --write-out '%{http_code}' \ "$package_url") || package_status=000 else - package_status=$(curl "$curl_output_mode" --show-error --location \ + package_status=$(curl --config "$access_config" "$curl_output_mode" --show-error --location \ --connect-timeout "$CURL_CONNECT_TIMEOUT_SECONDS" \ --max-time "$PACKAGE_DOWNLOAD_TIMEOUT_SECONDS" \ --dump-header "$package_headers" \ @@ -508,6 +686,17 @@ if [ -n "$bb_app_npm_prefix" ]; then fi fi +bb_cli="${bb_app%/*}/bb" +if [ ! -x "$bb_cli" ]; then bb_cli=$(command -v bb || true); fi +if [ -n "$bootstrap_env" ]; then + if [ -z "$bb_cli" ]; then + fail_step "The installed build does not provide the machine enrollment CLI." + exit 1 + fi + BB_ENROLLMENT="$bootstrap_payload" BB_DATA_DIR="$data_dir" "$bb_cli" machine enroll --bootstrap-env BB_ENROLLMENT + bootstrap_payload= +fi + if [ -n "$machine_code" ]; then connect_apex=$(node -e ' const url = new URL(process.argv[1]); @@ -638,8 +827,17 @@ if [ "$already_joined" = no ]; then complete_step "Joined successfully" fi -# Tests and source-development smoke runs can leave the enrolled daemon in the -# foreground-supervised process without modifying the user's service manager. +systemd_scope=--user +if [ "$platform" = linux ] && [ "$(id -u)" = 0 ] && + [ "$(ps -p 1 -o comm= | tr -d '[:space:]')" = systemd ] && + ! systemd-detect-virt --container --quiet >/dev/null 2>&1; then + systemd_scope=--system +fi +if [ -n "$bootstrap_env" ] && [ "$platform" = linux ] && + [ "$systemd_scope" = --user ] && ! systemctl --user show-environment >/dev/null 2>&1; then + BB_INSTALL_SKIP_SERVICE=1 +fi + if [ "${BB_INSTALL_SKIP_SERVICE:-0}" = 1 ]; then if [ -z "$join_pid" ] && ! daemon_status_matches "$host_daemon_port" no; then daemon_log="$data_dir/install-daemon.log" @@ -699,6 +897,14 @@ if [ "$platform" = darwin ]; then escaped_bb_app_npm_prefix=$(xml_escape "$bb_app_npm_prefix") escaped_server=$(xml_escape "$server_url") escaped_data_dir=$(xml_escape "$data_dir") + legacy_service_file="$service_dir/app.getbb.host-daemon.$legacy_service_slug.plist" + if [ -f "$legacy_service_file" ] && \ + grep -F -- '--host-daemon-port' "$legacy_service_file" >/dev/null 2>&1 && \ + grep -F -- "$host_daemon_port" "$legacy_service_file" >/dev/null 2>&1 && \ + grep -F -- "BB_DATA_DIR$escaped_data_dir" "$legacy_service_file" >/dev/null 2>&1; then + launchctl bootout "gui/$(id -u)" "$legacy_service_file" >/dev/null 2>&1 || true + rm -f "$legacy_service_file" + fi cat >"$service_file" < @@ -751,6 +957,17 @@ EOF detail "Uninstall: launchctl bootout gui/$(id -u) '$service_file' && rm '$service_file'" else service_dir="$HOME/.config/systemd/user" + service_target=default.target + if [ "$systemd_scope" = --system ]; then + service_dir="$canonical_data_dir/systemd" + service_target=multi-user.target + owned_launcher="$canonical_data_dir/npm/bin/bb-app" + if [ "$bb_app" != "$owned_launcher" ]; then + mkdir -p "$data_dir/npm/bin" + ln -sf "$bb_app" "$owned_launcher" + bb_app="$owned_launcher" + fi + fi service_name="bb-host-daemon-$service_slug" service_file="$service_dir/$service_name.service" mkdir -p "$service_dir" @@ -759,6 +976,14 @@ else escaped_bb_app_npm_prefix=$(systemd_escape "$bb_app_npm_prefix") escaped_server=$(systemd_escape "$server_url") escaped_data_dir=$(systemd_escape "$data_dir") + legacy_service_name="bb-host-daemon-$legacy_service_slug" + legacy_service_file="$service_dir/$legacy_service_name.service" + if [ -f "$legacy_service_file" ] && \ + grep -F -- "--host-daemon-port \"$host_daemon_port\"" "$legacy_service_file" >/dev/null 2>&1 && \ + grep -F -- "Environment=\"BB_DATA_DIR=$escaped_data_dir\"" "$legacy_service_file" >/dev/null 2>&1; then + systemctl "$systemd_scope" disable --now "$legacy_service_name.service" >/dev/null 2>&1 || true + rm -f "$legacy_service_file" + fi cat >"$service_file" <&1); then + systemctl "$systemd_scope" daemon-reload + enable_unit="$service_name.service" + if [ "$systemd_scope" = --system ]; then enable_unit="$service_file"; fi + if ! systemctl_error=$(systemctl "$systemd_scope" enable "$enable_unit" 2>&1); then fail_step "The bb host-daemon systemd service could not be enabled." [ -z "$systemctl_error" ] || detail "systemctl: $systemctl_error" >&2 - detail "Inspect it with: journalctl --user -u $service_name.service" >&2 + detail "Inspect it with: journalctl $systemd_scope -u $service_name.service" >&2 exit 1 fi - if ! systemctl_error=$(systemctl --user restart "$service_name.service" 2>&1); then + if ! systemctl_error=$(systemctl "$systemd_scope" restart "$service_name.service" 2>&1); then fail_step "The bb host-daemon systemd service was enabled, but it could not be restarted." [ -z "$systemctl_error" ] || detail "systemctl: $systemctl_error" >&2 - detail "Inspect it with: journalctl --user -u $service_name.service" >&2 + detail "Inspect it with: journalctl $systemd_scope -u $service_name.service" >&2 exit 1 fi if ! wait_for_daemon_connection "the systemd service"; then fail_step "The bb host-daemon systemd service started but did not connect to $server_url." - detail "Inspect it with: journalctl --user -u $service_name.service" >&2 + detail "Inspect it with: journalctl $systemd_scope -u $service_name.service" >&2 exit 1 fi - complete_step "Installed and started the systemd user service" + complete_step "Installed and started the systemd service ($systemd_scope)" printf '\n' log "$(green "●")" "$(bold "bb machine is ready")" printf '\n' @@ -802,6 +1029,10 @@ EOF ready_row "data" "$data_dir" ready_row "service" "$service_file" printf '\n' - detail "Starts with your systemd user session." - detail "Uninstall: systemctl --user disable --now $service_name.service && rm '$service_file' && systemctl --user daemon-reload" + if [ "$systemd_scope" = --system ]; then + detail "Starts automatically when this machine boots." + else + detail "Starts with your systemd user session." + fi + detail "Uninstall: systemctl $systemd_scope disable --now $service_name.service && rm '$service_file' && systemctl $systemd_scope daemon-reload" fi diff --git a/apps/server/src/install-machine-asset.ts b/apps/server/src/install-machine-asset.ts new file mode 100644 index 0000000000..d2d5c0f6c5 --- /dev/null +++ b/apps/server/src/install-machine-asset.ts @@ -0,0 +1,5 @@ +import { fileURLToPath } from "node:url"; + +export const INSTALL_MACHINE_SCRIPT_PATH = fileURLToPath( + new URL("./assets/install-machine.sh", import.meta.url), +); diff --git a/apps/server/src/internal/auth.ts b/apps/server/src/internal/auth.ts index b18e4eb7a4..1a1f377840 100644 --- a/apps/server/src/internal/auth.ts +++ b/apps/server/src/internal/auth.ts @@ -1,4 +1,3 @@ -import { hostTypeSchema, type HostType } from "@bb/domain"; import { z } from "zod"; import type { AppDeps } from "../types.js"; import { ApiError } from "../errors.js"; @@ -10,14 +9,12 @@ interface DaemonAuthContext { interface AuthenticatedDaemon { hostId: string; - hostType: HostType; keyId: string; } const authenticatedDaemonSchema = z .object({ hostId: z.string().min(1), - hostType: hostTypeSchema, keyId: z.string().min(1), }) .strict(); @@ -53,7 +50,6 @@ export async function verifyAuthenticatedDaemon( return { hostId: verified.metadata.hostId, - hostType: verified.metadata.hostType, keyId: verified.keyId, }; } @@ -81,9 +77,9 @@ export function getAuthenticatedDaemon( export function assertAuthenticatedHostMatches( daemon: AuthenticatedDaemon, - args: { hostId: string; hostType: HostType }, + args: { hostId: string }, ): void { - if (daemon.hostId !== args.hostId || daemon.hostType !== args.hostType) { + if (daemon.hostId !== args.hostId) { throw new ApiError( 403, "invalid_request", diff --git a/apps/server/src/internal/hosts.ts b/apps/server/src/internal/hosts.ts index 24b0dd21d2..8c943d1ba8 100644 --- a/apps/server/src/internal/hosts.ts +++ b/apps/server/src/internal/hosts.ts @@ -15,7 +15,7 @@ import { getTrustedRemoteAddress, type GateAuthHeaderReader, } from "../request-context.js"; -import { issuePersistentHostEnrollKey } from "../services/hosts/host-enrollment.js"; +import { issueHostEnrollKey } from "../services/hosts/host-enrollment.js"; import { requireBearerToken } from "./auth.js"; function assertLoopbackRequest(remoteAddress: string | undefined): void { @@ -31,11 +31,10 @@ function assertLoopbackRequest(remoteAddress: string | undefined): void { export function resolveReportedConnectMachineId( context: GateAuthHeaderReader, - reportedMachineId: string | undefined, ): string | undefined { - if (getGateAuthKind(context) !== "machine") return reportedMachineId; + if (getGateAuthKind(context) !== "machine") return undefined; const gateMachineId = getGateMachineId(context); - if (gateMachineId === null || reportedMachineId !== gateMachineId) { + if (gateMachineId === null) { throw new ApiError( 403, "connect_machine_id_mismatch", @@ -63,7 +62,7 @@ export function registerInternalHostRoutes(app: Hono, deps: AppDeps): void { ); } assertLoopbackRequest(getTrustedRemoteAddress(context)); - const issued = await issuePersistentHostEnrollKey(deps, { + const issued = await issueHostEnrollKey(deps, { enrollSource: "loopback", ...(payload.hostId ? { hostId: payload.hostId } : {}), }); @@ -83,15 +82,11 @@ export function registerInternalHostRoutes(app: Hono, deps: AppDeps): void { "/hosts/enroll", hostDaemonEnrollRequestSchema, async (context, payload) => { - const connectMachineId = resolveReportedConnectMachineId( - context, - payload.connectMachineId, - ); + const connectMachineId = resolveReportedConnectMachineId(context); const token = requireBearerToken(context.req.header("authorization")); const enrollment = await deps.machineAuth.enrollHost({ allowPublicEnrollment: true, hostId: payload.hostId, - hostType: payload.hostType, token, }); @@ -102,7 +97,6 @@ export function registerInternalHostRoutes(app: Hono, deps: AppDeps): void { ...(connectMachineId !== undefined ? { connectMachineId } : {}), id: enrollment.metadata.hostId, name: payload.hostName, - type: enrollment.metadata.hostType, }); return context.json( diff --git a/apps/server/src/internal/session-owner-side-effects.ts b/apps/server/src/internal/session-owner-side-effects.ts index 0768da96e1..b4eb84786b 100644 --- a/apps/server/src/internal/session-owner-side-effects.ts +++ b/apps/server/src/internal/session-owner-side-effects.ts @@ -166,7 +166,7 @@ export function handleDaemonSocketClosed( } export function handleHostRemoved( - deps: DaemonSocketClosedDeps, + deps: Omit, args: HandleHostRemovedArgs, ): void { const session = deps.db diff --git a/apps/server/src/internal/session.ts b/apps/server/src/internal/session.ts index 2a3e620ae4..266b5357ac 100644 --- a/apps/server/src/internal/session.ts +++ b/apps/server/src/internal/session.ts @@ -1,5 +1,6 @@ import { getLatestSessionForHost, + getHost, listRetiredLoadedEnvironmentIdsOnHost, openSession, upsertHost, @@ -27,6 +28,7 @@ import { readAttachment } from "../services/projects/attachments.js"; import { handleHostSessionOpened } from "./session-owner-side-effects.js"; import { resolveReportedConnectMachineId } from "./hosts.js"; import type { PluginService } from "../services/plugins/plugin-service.js"; +import { isMachineResumeInFlight } from "../services/machines/provider-orchestration.js"; const sessionOpenCompatibilitySchema = z .object({ @@ -69,7 +71,6 @@ export function registerInternalSessionRoutes( const daemon = getAuthenticatedDaemon(context); assertAuthenticatedHostMatches(daemon, { hostId: compatibility.data.hostId, - hostType: daemon.hostType, }); if (compatibility.data.protocolVersion !== HOST_DAEMON_PROTOCOL_VERSION) { @@ -107,18 +108,27 @@ export function registerInternalSessionRoutes( } const payload = parsed.data; + const host = getHost(deps.db, daemon.hostId); + if ( + host?.phase === "suspending" || + (host?.phase === "suspended" && + !isMachineResumeInFlight(deps, daemon.hostId)) + ) { + throw new ApiError( + 409, + "machine_suspended", + "Machine daemon sessions are disabled while the machine is suspending or suspended", + ); + } + const previousSession = getLatestSessionForHost(deps.db, { hostId: daemon.hostId, }); - const connectMachineId = resolveReportedConnectMachineId( - context, - payload.connectMachineId, - ); + const connectMachineId = resolveReportedConnectMachineId(context); upsertHost(deps.db, deps.hub, { ...(connectMachineId !== undefined ? { connectMachineId } : {}), id: daemon.hostId, name: payload.hostName, - type: daemon.hostType, }); updateHost(deps.db, deps.hub, daemon.hostId, { lastRejectedProtocolVersion: null, @@ -127,7 +137,6 @@ export function registerInternalSessionRoutes( hostId: daemon.hostId, instanceId: payload.instanceId, hostName: payload.hostName, - hostType: daemon.hostType, dataDir: payload.dataDir, protocolVersion: payload.protocolVersion, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, diff --git a/apps/server/src/routes/environments.ts b/apps/server/src/routes/environments.ts index 30c47402ed..6dfb926a83 100644 --- a/apps/server/src/routes/environments.ts +++ b/apps/server/src/routes/environments.ts @@ -35,7 +35,10 @@ import { requireReadyEnvironment, } from "../services/lib/entity-lookup.js"; import { runLiveCommandAndWait } from "../services/hosts/live-command-wait.js"; -import { callHostRetryableOnlineRpc } from "../services/hosts/online-rpc.js"; +import { + callHostRetryableOnlineRpc, + callHostRetryableOnlineRpcForWork, +} from "../services/hosts/online-rpc.js"; import { requireDaemonFileContentResult } from "../services/hosts/daemon-file-response.js"; import { generateCommitMessage } from "../services/ai/commit-message.js"; import { archiveEnvironmentThreads } from "../services/threads/thread-archive.js"; @@ -53,7 +56,10 @@ import { requireWorkspaceCommandTarget, type WorkspaceCommandTarget, } from "../services/environments/workspace-command-target.js"; -import { callEnvironmentWorkspaceStatus } from "../services/environments/workspace-status.js"; +import { + callEnvironmentWorkspaceStatus, + callEnvironmentWorkspaceStatusForWork, +} from "../services/environments/workspace-status.js"; import { assembleThreadPullRequest } from "../services/environments/pull-request.js"; import { requireAvailableWorkspaceDiff, @@ -145,7 +151,7 @@ async function getPullRequestForWorkspaceTarget( deps: AppDeps, target: ReturnType, ): Promise { - const result = await callHostRetryableOnlineRpc(deps, { + const result = await callHostRetryableOnlineRpcForWork(deps, { hostId: target.hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { @@ -641,11 +647,11 @@ export function registerEnvironmentRoutes(app: Hono, deps: AppDeps): void { const { workspaceContext } = target; const [statusResult, diffResult] = await Promise.all([ - callEnvironmentWorkspaceStatus(deps, { + callEnvironmentWorkspaceStatusForWork(deps, { environment, target, }), - callHostRetryableOnlineRpc(deps, { + callHostRetryableOnlineRpcForWork(deps, { hostId: target.hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { diff --git a/apps/server/src/routes/files.ts b/apps/server/src/routes/files.ts index 88813a62ef..bf91757797 100644 --- a/apps/server/src/routes/files.ts +++ b/apps/server/src/routes/files.ts @@ -12,7 +12,7 @@ import { ApiError } from "../errors.js"; import { browserRequestProblem } from "../browser-request-guard.js"; import type { AppDeps, LoggedWorkSessionDeps } from "../types.js"; import { - callHostOnlineRpc, + callHostOnlineRpcForWork, callHostRetryableOnlineRpc, } from "../services/hosts/online-rpc.js"; import { @@ -232,7 +232,7 @@ export function registerFileRoutes(app: Hono, deps: AppDeps): void { const hostId = resolveHostId(payload.hostId); try { const result = await runHostFileMutation(hostId, () => - callHostOnlineRpc(deps, { + callHostOnlineRpcForWork(deps, { hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { @@ -313,7 +313,7 @@ export function registerFileRoutes(app: Hono, deps: AppDeps): void { const hostId = resolveHostId(payload.hostId); try { const result = await runHostFileMutation(hostId, () => - callHostOnlineRpc(deps, { + callHostOnlineRpcForWork(deps, { hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { @@ -336,7 +336,7 @@ export function registerFileRoutes(app: Hono, deps: AppDeps): void { const hostId = resolveHostId(payload.hostId); try { const result = await runHostFileMutation(hostId, () => - callHostOnlineRpc(deps, { + callHostOnlineRpcForWork(deps, { hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { @@ -359,7 +359,7 @@ export function registerFileRoutes(app: Hono, deps: AppDeps): void { const hostId = resolveHostId(payload.hostId); try { const result = await runHostFileMutation(hostId, () => - callHostOnlineRpc(deps, { + callHostOnlineRpcForWork(deps, { hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { diff --git a/apps/server/src/routes/hosts.ts b/apps/server/src/routes/hosts.ts index 26f9868322..29c8d7255d 100644 --- a/apps/server/src/routes/hosts.ts +++ b/apps/server/src/routes/hosts.ts @@ -1,3 +1,4 @@ +import { serverAccess } from "../services/machines/server-access.js"; import { getNonDestroyedHost, updateHost } from "@bb/db"; import { publicApiRoutes, @@ -7,7 +8,10 @@ import { import type { Hono } from "hono"; import { HOST_DAEMON_PROTOCOL_VERSION } from "@bb/host-daemon-contract"; import type { AppDeps } from "../types.js"; -import { getProviderInstallations } from "../services/system/provider-installations.js"; +import { + getProviderInstallations, + serializeProviderInstallation, +} from "../services/system/provider-installations.js"; import { resolveBridgeLaunchForProviderId } from "../services/system/provider-bridge-launch.js"; import type { PluginService } from "../services/plugins/plugin-service.js"; import { COMMAND_TIMEOUT_MS } from "../constants.js"; @@ -25,12 +29,22 @@ import { assertUsableHostId, resolvePrimaryHostId, } from "../services/hosts/primary-host.js"; -import { issuePersistentHostEnrollKey } from "../services/hosts/host-enrollment.js"; +import { issueHostEnrollKey } from "../services/hosts/host-enrollment.js"; import { - callHostOnlineRpc, + callHostOnlineRpcForWork, callHostRetryableOnlineRpc, } from "../services/hosts/online-rpc.js"; import { handleHostRemoved } from "../internal/session-owner-side-effects.js"; +import { + submitMachine, + requestMachineRemoval, + startMachineResume, + startMachineSuspension, + retryMachineCleanup, + sweepProviderMachine, +} from "../services/machines/provider-orchestration.js"; +import { getMachineEnrollmentService } from "../services/machines/machine-services.js"; +import { manualHostCommand } from "../services/machines/manual-provider.js"; const PROVIDER_CLI_INSTALL_TIMEOUT_MS = 15 * 60 * 1000; const FOLDER_PICKER_TIMEOUT_MS = 10 * 60 * 1000; @@ -97,9 +111,14 @@ export function registerHostRoutes( }); const routes = publicApiRoutes.hosts; - post(routes.createJoinCode, async (context) => { + post(routes.create, async (context, payload) => { + assertHostManagementAllowed(context); + return context.json(await submitMachine(deps, payload), 201); + }); + + post(routes.createJoinCode, async (context, payload) => { assertHostManagementAllowed(context); - const issued = await issuePersistentHostEnrollKey(deps, { + const issued = await issueHostEnrollKey(deps, { enrollSource: "public-multi-machine", }); return context.json( @@ -112,14 +131,33 @@ export function registerHostRoutes( ); }); - get(routes.list, (context) => context.json(listPublicHostsWithStatus(deps))); - - get(routes.get, (context) => + get(routes.list, (context, query) => context.json( - requireNonDestroyedHostWithStatus(deps, context.req.param("id")), + listPublicHostsWithStatus(deps, { + includeCreating: query.includeCreating === "true", + }), ), ); + get(routes.get, (context) => + context.json({ + ...requireNonDestroyedHostWithStatus(deps, context.req.param("id")), + connectMachineId: requireMutableHost(deps, context.req.param("id")) + .connectMachineId, + }), + ); + + get(routes.enrollmentCommand, async (context) => { + assertHostManagementAllowed(context); + requireMutableHost(deps, context.req.param("id")); + return context.json( + await manualHostCommand( + getMachineEnrollmentService(deps), + context.req.param("id"), + ), + ); + }); + patch(routes.update, (context, payload) => { assertHostManagementAllowed(context); const hostId = context.req.param("id"); @@ -170,6 +208,26 @@ export function registerHostRoutes( return context.json({ ok: true as const }); }); + post(routes.suspend, (context) => { + assertHostManagementAllowed(context); + const hostId = context.req.param("id"); + startMachineSuspension(deps, hostId); + return context.json(requireNonDestroyedHostWithStatus(deps, hostId), 202); + }); + + post(routes.resume, (context) => { + assertHostManagementAllowed(context); + const hostId = context.req.param("id"); + startMachineResume(deps, hostId); + return context.json(requireNonDestroyedHostWithStatus(deps, hostId), 202); + }); + + post(routes.retryCleanup, async (context) => { + assertHostManagementAllowed(context); + await retryMachineCleanup(deps, context.req.param("id")); + return context.json({ ok: true as const }); + }); + del(routes.delete, async (context) => { assertHostManagementAllowed(context); const hostId = context.req.param("id"); @@ -182,9 +240,21 @@ export function registerHostRoutes( ); } + if (host.machineProviderId !== null) { + if (!requestMachineRemoval(deps, hostId)) { + throw new ApiError( + 409, + "machine_has_live_threads", + "Archive or delete every thread on this machine before removing it", + ); + } + await sweepProviderMachine(deps, hostId); + return context.json({ ok: true }); + } + + await serverAccess.release(deps, { key: hostId, hostId }); await deps.machineAuth.revokeHostAuthKeys({ hostId, - hostType: host.type, }); const sessionId = deps.hub.getDaemonSessionIdForHost(hostId); if (sessionId) { @@ -254,7 +324,7 @@ export function registerHostRoutes( "Native folder picker is only available when the browser helper and work host are on the same machine", ); } - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId, timeoutMs: FOLDER_PICKER_TIMEOUT_MS, command: { @@ -294,16 +364,18 @@ export function registerHostRoutes( `Provider bridge is unavailable for ${payload.provider}`, ); } - const result = await callHostOnlineRpc(deps, { - hostId, - timeoutMs: PROVIDER_CLI_INSTALL_TIMEOUT_MS, - command: { - type: "provider.installation.run", - providerId: payload.provider, - action: payload.actionKind, - bridgeLaunch, - }, - }); + const result = await serializeProviderInstallation(deps, hostId, () => + callHostOnlineRpcForWork(deps, { + hostId, + timeoutMs: PROVIDER_CLI_INSTALL_TIMEOUT_MS, + command: { + type: "provider.installation.run", + providerId: payload.provider, + action: payload.actionKind, + bridgeLaunch, + }, + }), + ); if ( result.events.some((event) => event.type === "completed" && event.success) ) { diff --git a/apps/server/src/routes/plugins.ts b/apps/server/src/routes/plugins.ts index a91bafac38..a83a1d685f 100644 --- a/apps/server/src/routes/plugins.ts +++ b/apps/server/src/routes/plugins.ts @@ -786,6 +786,7 @@ export function registerPluginRoutes( app.post("/plugins/:id/rpc/:method", async (context) => { const id = context.req.param("id"); const method = context.req.param("method"); + context.header("Cache-Control", "no-store"); const problem = localAuthProblem(context, deps); if (problem) { return context.json({ ok: false, error: problem.error }, problem.status); diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 1c3f46f87b..fe727228ca 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -5,7 +5,6 @@ import { getPersonalProject, getProjectExecutionDefaults, getPublicProjectByLocalPathSource, - createProjectSource, deleteProjectSource, getProjectSourceByHost, getProjectSourceForProject, @@ -18,7 +17,6 @@ import { updateProject, updateProjectSource, setProjectGitRemoteUrlIfMissing, - isSqliteUniqueConstraintOnColumns, type ReorderProjectResult, } from "@bb/db"; import { @@ -50,7 +48,11 @@ import { import { PROMPT_HISTORY_ENTRY_LIMIT } from "@bb/domain"; import { toThreadListEntryResponses } from "../services/threads/thread-runtime-display.js"; import { callHostRetryableOnlineRpc } from "../services/hosts/online-rpc.js"; -import { runLiveHostCommand } from "../services/hosts/live-command.js"; +import { + cloneProjectSourceOnHost, + registerProjectSourceOnHost, + projectSourceHostConflict, +} from "../services/projects/project-source-setup.js"; import { deleteProjectSkill, listProjectSkillFiles, @@ -99,7 +101,6 @@ import { } from "../services/projects/project-workspace.js"; type ProjectResponseProjectFields = Omit; -const PROJECT_CLONE_TIMEOUT_MS = 20 * 60 * 1000; const ATTACHMENT_CONTENT_CACHE_CONTROL = "private, immutable, max-age=31536000"; function toProjectResponseProjectFields( @@ -297,19 +298,6 @@ function requireProjectSource( return source; } -interface ResolvedProjectSource { - path: string; - gitRemoteUrl: string | null; -} - -function projectSourceHostConflict(): ApiError { - return new ApiError( - 409, - "project_source_host_conflict", - "Project already has a source on this host", - ); -} - async function inspectProjectGitRemoteBestEffort( deps: AppDeps, args: { hostId: string; path: string }, @@ -471,63 +459,26 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { if (getProjectSourceByHost(deps.db, projectId, payload.hostId)) { throw projectSourceHostConflict(); } - let resolved: ResolvedProjectSource; - if (payload.type === "clone") { - const remoteUrl = payload.remoteUrl ?? project.gitRemoteUrl; - if (!remoteUrl) { - throw new ApiError( - 400, - "missing_git_remote", - "A remoteUrl is required because this project has no git remote anchor", - ); - } - resolved = await runLiveHostCommand(deps, { - hostId: payload.hostId, - timeoutMs: PROJECT_CLONE_TIMEOUT_MS, - command: { - type: "project.clone", - remoteUrl, - projectSlug: project.name, - ...(payload.targetPath !== undefined - ? { targetPath: payload.targetPath } - : {}), - }, - }); - } else { - resolved = { - path: payload.path, - gitRemoteUrl: await inspectProjectGitRemoteBestEffort(deps, payload), - }; - } - let source; - try { - source = createProjectSource(deps.db, deps.hub, { - projectId, - type: "local_path", - hostId: payload.hostId, - path: resolved.path, - }); - } catch (error) { - if ( - error instanceof Error && - isSqliteUniqueConstraintOnColumns(error, { - columnNames: ["project_id", "host_id"], - indexName: "project_sources_project_host_idx", - tableName: "project_sources", - }) - ) { - throw projectSourceHostConflict(); - } - throw error; - } - if (resolved.gitRemoteUrl !== null) { - setProjectGitRemoteUrlIfMissing( - deps.db, - deps.hub, - projectId, - resolved.gitRemoteUrl, - ); - } + const source = + payload.type === "clone" + ? await cloneProjectSourceOnHost(deps, { + projectId, + projectName: project.name, + hostId: payload.hostId, + remoteUrl: payload.remoteUrl ?? project.gitRemoteUrl, + ...(payload.targetPath !== undefined + ? { targetPath: payload.targetPath } + : {}), + }) + : registerProjectSourceOnHost(deps, { + projectId, + hostId: payload.hostId, + path: payload.path, + gitRemoteUrl: await inspectProjectGitRemoteBestEffort( + deps, + payload, + ), + }); return context.json(source, 201); }); diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index f4fbc817d7..bead6d158f 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -1,3 +1,9 @@ +import { + machineEnvironmentView, + replaceMachineEnvironment, +} from "../services/machines/environment-settings.js"; +import { getGateAuthKind } from "../request-context.js"; +import { serverAccessStatus } from "../services/machines/server-access.js"; import { getAppSettings, getAppKeybindingOverrides, @@ -24,13 +30,19 @@ import { publicApiRoutes, typedRoutes, type PublicApiSchema, + type SystemEnvironmentProvider, } from "@bb/server-contract"; import type { Hono } from "hono"; import { pluginImageResponse } from "./plugin-image-response.js"; import { getEnvironmentProvider, + listEnvironmentCompositions, listEnvironmentProviders, } from "../services/plugins/plugin-environment-provider-registry.js"; +import { + getMachineProvider, + listMachineProviders, +} from "../services/plugins/plugin-machine-provider-registry.js"; import type { ServerAppDeps, ServerRuntimeConfig } from "../types.js"; import type { PluginService } from "../services/plugins/plugin-service.js"; import { ApiError } from "../errors.js"; @@ -63,6 +75,7 @@ import { environmentProviderAcceptsEmptyInputs, } from "../services/environments/provider-availability.js"; import { environmentProviderMachineAvailability } from "../services/environments/provider-machine-availability.js"; +import { machineProviderAcceptsEmptyInputs } from "../services/machines/provider-availability.js"; import { requirePublicProject } from "../services/lib/entity-lookup.js"; const LEADING_ENVIRONMENT_PROVIDER_IDS: readonly string[] = [ @@ -171,6 +184,7 @@ export function registerSystemRoutes( ]; return { generalSettings: compatibleGeneralSettings(), + serverAccess: await serverAccessStatus(deps), keybindings: applyAppKeybindingOverrides( DEFAULT_APP_KEYBINDINGS, keybindingOverrides, @@ -221,6 +235,22 @@ export function registerSystemRoutes( showUnhandledProviderEvents: settings.showDiagnosticEvents, }; } + get(routes.machineEnvironment, async (context) => + context.json(await machineEnvironmentView(deps.db, deps.config.dataDir)), + ); + put(routes.replaceMachineEnvironment, async (context, payload) => { + if (getGateAuthKind(context) === "machine") + throw new ApiError( + 403, + "forbidden", + "Machine credentials cannot change global environment settings", + ); + await replaceMachineEnvironment(deps.db, deps.config.dataDir, payload); + deps.hub.notifySystem(["config-changed"]); + return context.json( + await machineEnvironmentView(deps.db, deps.config.dataDir), + ); + }); put(routes.generalSettings, (context, payload) => { const { showUnhandledProviderEvents, ...settings } = payload; @@ -362,7 +392,7 @@ export function registerSystemRoutes( ) || left.provider.id.localeCompare(right.provider.id) ); }) - .map(async (record) => { + .map(async (record): Promise => { if ( query.projectId !== undefined && !environmentProviderMatchesContext(deps, record, { @@ -384,6 +414,7 @@ export function registerSystemRoutes( : { hostId: query.hostId }), }); return { + machineProviderId: null, id: record.provider.id, displayName: record.provider.displayName, icon: record.provider.icon, @@ -404,7 +435,84 @@ export function registerSystemRoutes( }; }), ) - ).filter((provider) => provider !== null), + ) + .filter((provider) => provider !== null) + .concat( + query.hostId !== undefined + ? [] + : ( + await Promise.all( + listEnvironmentCompositions().map( + async ({ pluginId, composition }) => { + const record = getEnvironmentProvider( + composition.environmentProviderId, + ); + const machine = getMachineProvider( + composition.machineProviderId, + ); + if (!record || !machine) return null; + if ( + project !== null && + (record.provider.requires.projectCheckout || + record.provider.requires.gitRemote) && + project.gitRemoteUrl === null + ) + return null; + if ( + project !== null && + record.provider.requires.projectless !== + (project.id === PERSONAL_PROJECT_ID) + ) + return null; + return { + id: composition.id, + displayName: composition.displayName, + icon: composition.icon ?? machine.provider.icon, + logoUrl: + machine.icon === undefined + ? null + : `/api/v1/system/providers/${encodeURIComponent("machine:" + machine.provider.id)}/logo?h=${machine.icon.hash}`, + pluginId, + machineProviderId: composition.machineProviderId, + environmentProviderId: + composition.environmentProviderId, + requires: record.provider.requires, + inputs: record.provider.inputsJsonSchema, + acceptsEmptyInputs: + await environmentProviderAcceptsEmptyInputs(record), + machineInputs: machine.provider.inputsJsonSchema, + machineAcceptsEmptyInputs: + await machineProviderAcceptsEmptyInputs(machine), + machineProviderPluginId: machine.pluginId, + availability: null, + machineAvailability: {}, + }; + }, + ), + ) + ).filter((provider) => provider !== null), + ), + }); + }); + + get(routes.machineProviders, async (context) => { + return context.json({ + providers: await Promise.all( + listMachineProviders().map(async (record) => ({ + id: record.provider.id, + displayName: record.provider.displayName, + description: record.provider.description, + icon: record.provider.icon, + logoUrl: + record.icon === undefined + ? null + : `/api/v1/system/providers/${encodeURIComponent(`machine:${record.provider.id}`)}/logo?h=${record.icon.hash}`, + pluginId: record.pluginId, + inputs: record.provider.inputsJsonSchema, + acceptsEmptyInputs: await machineProviderAcceptsEmptyInputs(record), + supportsSuspend: record.provider.suspend !== null, + })), + ), }); }); @@ -416,7 +524,9 @@ export function registerSystemRoutes( const providerId = context.req.param("id"); const registration = providerId.startsWith("environment:") ? getEnvironmentProvider(providerId.slice("environment:".length)) - : deps.providerRegistry.get(providerId); + : providerId.startsWith("machine:") + ? getMachineProvider(providerId.slice("machine:".length)) + : deps.providerRegistry.get(providerId); if (registration?.icon !== undefined) { return pluginImageResponse( context, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 350bdde751..17c41f6301 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,11 +1,14 @@ import { recheckEnvironmentProvisioning } from "./services/threads/thread-environment-providers.js"; +import { enrolledInstallerScript } from "./services/machines/manual-enrollment-command.js"; +import { getMachineEnrollmentService } from "./services/machines/machine-services.js"; +import { withManualMachineProvider } from "./services/machines/manual-provider.js"; import { registerDesktopBrowserRoutes } from "./routes/desktop-browsers.js"; +import { INSTALL_MACHINE_SCRIPT_PATH } from "./install-machine-asset.js"; import { createNodeWebSocket } from "@hono/node-ws"; import { createHash } from "node:crypto"; import { readFile, stat } from "node:fs/promises"; import { performance } from "node:perf_hooks"; import { extname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { Hono } from "hono"; import { terminalWebSocketQuerySchema } from "@bb/server-contract"; import { compress } from "hono/compress"; @@ -38,6 +41,11 @@ import { setPluginEnvironmentProviderBridge, } from "./services/plugins/plugin-environment-provider-registry.js"; import { recheckEnvironmentProviderCreations } from "./services/threads/thread-environment-providers.js"; +import { + setServerAccessBridge, + setServerAccessRecheckHandler, +} from "./services/plugins/plugin-server-access-registry.js"; +import { setPluginMachineProviderBridge } from "./services/plugins/plugin-machine-provider-registry.js"; import { invalidateEnvironmentProviderMachineAvailability } from "./services/environments/provider-machine-availability.js"; import { requestQueuedMessageDispatch } from "./services/threads/queued-message-dispatch.js"; import { registerInternalEventRoutes } from "./internal/events.js"; @@ -83,7 +91,7 @@ import { createPluginCatalogService, type PluginCatalogService, } from "./services/plugin-catalog/plugin-catalog-service.js"; -import { callHostRetryableOnlineRpc } from "./services/hosts/online-rpc.js"; +import { callHostRetryableOnlineRpcForWork } from "./services/hosts/online-rpc.js"; import { allowedAppOrigins, browserRequestProblem, @@ -153,9 +161,6 @@ const WEB_SOCKET_SHUTDOWN_CODE = 1001; const WEB_SOCKET_SHUTDOWN_FORCE_CLOSE_MS = 1_000; const WEB_SOCKET_SHUTDOWN_REASON = "server-shutdown"; const SLOW_API_REQUEST_LOG_THRESHOLD_MS = 1_000; -const INSTALL_MACHINE_SCRIPT_PATH = fileURLToPath( - new URL("./assets/install-machine.sh", import.meta.url), -); const THREAD_EVENT_WAIT_PATH_PATTERN = /^\/api\/v1\/threads\/[^/]+\/events\/wait$/u; const PLUGIN_APP_ASSET_PATH_PATTERN = @@ -475,13 +480,35 @@ export function createApp( ), ); app.get("/install.sh", async (context) => { - const script = await readFile(INSTALL_MACHINE_SCRIPT_PATH); - return new Response(script, { - headers: { - "cache-control": "no-store", - "content-type": "text/x-shellscript; charset=utf-8", + const script = await readFile(INSTALL_MACHINE_SCRIPT_PATH, "utf8"); + const credential = context.req.header("X-BB-Enrollment"); + const bootstrap = + credential === undefined + ? null + : await getMachineEnrollmentService(deps).pendingBootstrapForCredential( + credential, + ); + if (credential !== undefined && bootstrap === null) { + return new Response( + "Enrollment is expired or unavailable. Generate a new command in bb.\n", + { + status: 403, + headers: { + "cache-control": "no-store", + "content-type": "text/plain", + }, + }, + ); + } + return new Response( + bootstrap === null ? script : enrolledInstallerScript(script, bootstrap), + { + headers: { + "cache-control": "no-store", + "content-type": "text/x-shellscript; charset=utf-8", + }, }, - }); + ); }); app.get("/install/version", async (context) => { return context.json({ @@ -558,6 +585,7 @@ export function createApp( return next(); }); const pluginService = createPluginService({ + machineEnrollments: getMachineEnrollmentService(deps), db: deps.db, hub: deps.hub, logger: deps.logger, @@ -572,7 +600,7 @@ export function createApp( aiServices: deps.aiServices, ensureSharedPortTunnel: (hostId) => deps.sharedPorts.ensureTunnelIdentity(hostId, () => - callHostRetryableOnlineRpc(deps, { + callHostRetryableOnlineRpcForWork(deps, { command: { type: "connect-tunnel.ensure-identity" }, hostId, timeoutMs: 30_000, @@ -616,6 +644,16 @@ export function createApp( setEnvironmentProvisioningRecheckHandler((threadId) => recheckEnvironmentProvisioning(deps, threadId), ); + setPluginMachineProviderBridge( + withManualMachineProvider( + pluginService.machineProviders, + getMachineEnrollmentService(deps), + ), + ); + setServerAccessBridge(pluginService.serverAccessProviders); + setServerAccessRecheckHandler(() => { + deps.hub.notifySystem(["config-changed"]); + }); setEnvironmentProviderRecheckHandler((pluginId) => { invalidateEnvironmentProviderMachineAvailability(); deps.hub.notifySystem(["config-changed"]); diff --git a/apps/server/src/services/desktop-browsers.ts b/apps/server/src/services/desktop-browsers.ts index 2ed8330c83..5223b1dbaf 100644 --- a/apps/server/src/services/desktop-browsers.ts +++ b/apps/server/src/services/desktop-browsers.ts @@ -18,7 +18,10 @@ import { requirePublicThread, requireNonDestroyedHostWithStatus, } from "./lib/entity-lookup.js"; -import { callHostOnlineRpc } from "./hosts/online-rpc.js"; +import { + callHostOnlineRpc, + callHostOnlineRpcForWork, +} from "./hosts/online-rpc.js"; interface LeaseEntry { lease: ExperimentalDesktopBrowserLease; @@ -190,7 +193,7 @@ export async function createDesktopBrowserTab( input: ExperimentalDesktopBrowserCreateRequest, ) { authorize(deps, input); - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId: input.hostId, timeoutMs: 15000, command: { @@ -230,7 +233,7 @@ export async function releaseDesktopBrowserControl( entry.active = false; clearTimeout(entry.timer); registry(deps).delete(input.leaseId); - await callHostOnlineRpc(deps, { + await callHostOnlineRpcForWork(deps, { hostId: input.hostId, timeoutMs: 10000, command: { @@ -242,6 +245,25 @@ export async function releaseDesktopBrowserControl( return { ok: true as const }; } +async function releaseExpiredDesktopBrowserControl( + deps: WorkSessionDeps, + input: ExperimentalDesktopBrowserLeaseRequest, +) { + const entry = registry(deps).get(input.leaseId); + if (!entry || !sameScope(entry.lease, input)) return; + entry.active = false; + registry(deps).delete(input.leaseId); + await callHostOnlineRpc(deps, { + hostId: input.hostId, + timeoutMs: 10000, + command: { + type: "desktop.browser.release_control", + ...scopeCommand(input), + leaseId: input.leaseId, + }, + }); +} + export async function acquireDesktopBrowserControl( deps: WorkSessionDeps, input: ExperimentalDesktopBrowserAcquireRequest, @@ -256,13 +278,27 @@ export async function acquireDesktopBrowserControl( expiresAt: Date.now() + input.ttlMs, }; const timer = setTimeout(() => { - void releaseDesktopBrowserControl(deps, lease).catch(() => {}); + void releaseExpiredDesktopBrowserControl(deps, lease).catch(() => {}); }, input.ttlMs); timer.unref(); const entry: LeaseEntry = { lease, timer, active: true }; registry(deps).set(lease.leaseId, entry); try { - const { tabs } = await listDesktopBrowserTabs(deps, input); + const { tabs } = await callHostOnlineRpcForWork(deps, { + hostId: input.hostId, + timeoutMs: 10000, + command: { + type: "desktop.browser.list_tabs", + ...scopeCommand(input), + }, + }); + if (tabs.some((tab) => tab.threadId !== input.threadId)) { + throw new ApiError( + 502, + "desktop_tab_scope", + "Desktop returned tabs outside the requested thread", + ); + } const selected = input.tabIds.map((id) => tabs.find((tab) => tab.tabId === id), ); @@ -287,7 +323,7 @@ export async function acquireDesktopBrowserControl( "desktop_control_expired", "Browser control was cancelled while checking tabs", ); - await callHostOnlineRpc(deps, { + await callHostOnlineRpcForWork(deps, { hostId: input.hostId, timeoutMs: 10000, command: { @@ -300,7 +336,7 @@ export async function acquireDesktopBrowserControl( }, }); if (!entry.active || lease.expiresAt <= Date.now()) { - await callHostOnlineRpc(deps, { + await callHostOnlineRpcForWork(deps, { hostId: input.hostId, timeoutMs: 10000, command: { @@ -345,7 +381,7 @@ export async function openDesktopBrowserConnection( input: ExperimentalDesktopBrowserLeaseRequest, ) { const entry = requireLease(deps, input); - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId: input.hostId, timeoutMs: 10000, command: { @@ -365,7 +401,7 @@ export async function desktopBrowserTabAction( action: "reveal" | "close", ) { authorize(deps, input); - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId: input.hostId, timeoutMs: 10000, command: { @@ -386,7 +422,7 @@ export async function captureDesktopBrowserTab( input: ExperimentalDesktopBrowserTabRequest, ) { authorize(deps, input); - return callHostOnlineRpc(deps, { + return callHostOnlineRpcForWork(deps, { hostId: input.hostId, timeoutMs: 15000, command: { diff --git a/apps/server/src/services/environments/environment-engine.ts b/apps/server/src/services/environments/environment-engine.ts index 5e7c64ee01..e24cbfdfbe 100644 --- a/apps/server/src/services/environments/environment-engine.ts +++ b/apps/server/src/services/environments/environment-engine.ts @@ -115,7 +115,7 @@ export interface ProviderOperationContext { project: Project; host: Host; machine: EnvironmentMachineSelection; - projectCheckout: { path: string } | null; + projectCheckout: { path: string; experimental_ownsPath?: boolean } | null; gitRemote: string | null; inputs: JsonValue | null; suggestedBranchName: string; @@ -918,7 +918,7 @@ interface AdvanceEnvironmentProvisioningArgs { threadId?: string; creation?: { record: PluginEnvironmentProviderRecord; - context: ProviderOperationContext; + context?: ProviderOperationContext; }; environmentId: string | null | undefined; request?: EnvironmentProvisionRequest | null; @@ -1577,17 +1577,28 @@ export async function advanceEnvironmentProvisioning( map, key: row.id, run: async (signal) => { - const creation = - args.creation?.context ?? - (owner !== null && - context?.request.environmentIntent.type === "provider" - ? await resolveProviderOperationContext( - deps, - owner, - context.request.environmentIntent, - record, - ) - : null); + let creation: ProviderOperationContext | null; + try { + creation = + args.creation?.context ?? + (owner !== null && + context?.request.environmentIntent.type === "provider" + ? await resolveProviderOperationContext( + deps, + owner, + context.request.environmentIntent, + record, + ) + : null); + } catch (error) { + mutateProvisioning(deps, row, ["creating"], (current) => { + current.status = "error"; + current.statusMessage = message(error); + }); + if (row.ownerThreadId !== null) + requestEnvironmentProvisioningRecheck(row.ownerThreadId); + return; + } if (creation === null) return; await runCreate(deps, record, row, creation, signal); }, diff --git a/apps/server/src/services/environments/environment-hooks.ts b/apps/server/src/services/environments/environment-hooks.ts index c41c258a86..82a25af601 100644 --- a/apps/server/src/services/environments/environment-hooks.ts +++ b/apps/server/src/services/environments/environment-hooks.ts @@ -1,7 +1,13 @@ +import { resolveHostEnvironment } from "../hosts/host-environment.js"; +import { environmentHookOperations } from "@bb/db"; +import { eq } from "drizzle-orm"; import type { EnvironmentHookProgressMessage } from "@bb/host-daemon-contract"; import type { PluginEnvironmentProviderProgress } from "@get-bb/plugin-sdk/environment-provider"; import type { WorkSessionDeps } from "../../types.js"; -import { callHostOnlineRpc } from "../hosts/online-rpc.js"; +import { + callHostOnlineRpc, + callHostOnlineRpcForWork, +} from "../hosts/online-rpc.js"; export const ENVIRONMENT_HOOK_TIMEOUT_MS = 15 * 60 * 1000; const TRANSPORT_GRACE_MS = 6_000; @@ -16,6 +22,29 @@ const reports = new WeakMap< > >(); +export function registerEnvironmentProgressReport( + deps: Pick, + args: { + hostId: string; + operationId: string; + report: PluginEnvironmentProviderProgress; + }, +): () => void { + let active = reports.get(deps.db); + if (active === undefined) { + active = new Map(); + reports.set(deps.db, active); + } + active.set(args.operationId, { + hostId: args.hostId, + report: args.report, + }); + return () => { + if (active.get(args.operationId)?.report === args.report) + active.delete(args.operationId); + }; +} + export function reportEnvironmentHookProgress( deps: Pick, hostId: string, @@ -42,13 +71,34 @@ export async function runEnvironmentHook( }, ): Promise { args.signal.throwIfAborted(); - let active = reports.get(deps.db); - if (active === undefined) { - active = new Map(); - reports.set(deps.db, active); + const existing = deps.db + .select() + .from(environmentHookOperations) + .where(eq(environmentHookOperations.id, args.id)) + .get(); + if (existing?.finishedAt != null) { + if (existing.error !== null && args.kind === "setup") + throw new Error(existing.error); + return; } const operationId = args.id; - active.set(operationId, { hostId: args.hostId, report: args.report }); + if (existing === undefined) + deps.db + .insert(environmentHookOperations) + .values({ + id: args.id, + operationId, + hostId: args.hostId, + path: args.path, + kind: args.kind, + startedAt: Date.now(), + }) + .run(); + const unregister = registerEnvironmentProgressReport(deps, { + operationId, + hostId: args.hostId, + report: args.report, + }); const abort = (): void => { void callHostOnlineRpc(deps, { hostId: args.hostId, @@ -63,18 +113,28 @@ export async function runEnvironmentHook( }; args.signal.addEventListener("abort", abort, { once: true }); try { - await callHostOnlineRpc(deps, { + args.signal.throwIfAborted(); + await callHostOnlineRpcForWork(deps, { hostId: args.hostId, timeoutMs: ENVIRONMENT_HOOK_TIMEOUT_MS + TRANSPORT_GRACE_MS, command: { type: "environment.hook.run", - resumeOnly: args.resumeOnly, + contributedEnv: await resolveHostEnvironment(deps, { + hostId: args.hostId, + projectId: null, + }), + resumeOnly: args.resumeOnly || existing !== undefined, operationId, path: args.path, kind: args.kind, timeoutMs: ENVIRONMENT_HOOK_TIMEOUT_MS, }, }); + deps.db + .update(environmentHookOperations) + .set({ finishedAt: Date.now() }) + .where(eq(environmentHookOperations.id, args.id)) + .run(); args.signal.throwIfAborted(); } catch (error) { await cancelPendingEnvironmentHook(deps, { @@ -90,7 +150,7 @@ export async function runEnvironmentHook( ); } finally { args.signal.removeEventListener("abort", abort); - active.delete(operationId); + unregister(); } } @@ -98,13 +158,31 @@ export async function cancelPendingEnvironmentHook( deps: WorkSessionDeps, args: { id: string; hostId: string }, ): Promise { + const id = args.id; + const operation = deps.db + .select() + .from(environmentHookOperations) + .where(eq(environmentHookOperations.id, id)) + .get(); + if (operation?.finishedAt != null) return; const result = await callHostOnlineRpc(deps, { hostId: args.hostId, timeoutMs: TRANSPORT_GRACE_MS, - command: { type: "environment.hook.cancel", operationId: args.id }, + command: { + type: "environment.hook.cancel", + operationId: args.id, + }, }); if (result.status === "unknown") throw new Error( "Environment hook outcome is unknown after interruption. Automatic cleanup is blocked; inspect the workspace before recovering it.", ); + deps.db + .update(environmentHookOperations) + .set({ + finishedAt: Date.now(), + error: "Environment hook cancelled", + }) + .where(eq(environmentHookOperations.id, id)) + .run(); } diff --git a/apps/server/src/services/environments/provider-availability.ts b/apps/server/src/services/environments/provider-availability.ts index 9befd0c2ae..bac5ef1c35 100644 --- a/apps/server/src/services/environments/provider-availability.ts +++ b/apps/server/src/services/environments/provider-availability.ts @@ -84,7 +84,7 @@ export function environmentProviderMatchesContext( ? listPublicHostsWithStatus(deps) : [getNonDestroyedHostWithStatus(deps, query.hostId)]; return hosts.some((host) => { - if (host === null || host.type !== "persistent") return false; + if (host === null) return false; const requires = record.provider.requires; if (requires.projectless !== (project.id === PERSONAL_PROJECT_ID)) { return false; diff --git a/apps/server/src/services/environments/workspace-status.ts b/apps/server/src/services/environments/workspace-status.ts index c0cabe06b2..b986c370c7 100644 --- a/apps/server/src/services/environments/workspace-status.ts +++ b/apps/server/src/services/environments/workspace-status.ts @@ -7,7 +7,10 @@ import { WORKSPACE_STATUS_MAX_UNTRACKED_LINE_STAT_FILES, } from "../../constants.js"; import type { AppDeps } from "../../types.js"; -import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; +import { + callHostRetryableOnlineRpc, + callHostRetryableOnlineRpcForWork, +} from "../hosts/online-rpc.js"; import type { WorkspaceCommandTarget } from "./workspace-command-target.js"; type WorkspaceStatusResult = HostDaemonOnlineRpcResult<"workspace.status">; @@ -26,7 +29,30 @@ export async function callEnvironmentWorkspaceStatus( deps: AppDeps, args: CallEnvironmentWorkspaceStatusArgs, ): Promise { - const result = await callHostRetryableOnlineRpc(deps, { + return callEnvironmentWorkspaceStatusWith( + deps, + args, + callHostRetryableOnlineRpc, + ); +} + +export async function callEnvironmentWorkspaceStatusForWork( + deps: AppDeps, + args: CallEnvironmentWorkspaceStatusArgs, +): Promise { + return callEnvironmentWorkspaceStatusWith( + deps, + args, + callHostRetryableOnlineRpcForWork, + ); +} + +async function callEnvironmentWorkspaceStatusWith( + deps: AppDeps, + args: CallEnvironmentWorkspaceStatusArgs, + callRpc: typeof callHostRetryableOnlineRpc, +): Promise { + const result = await callRpc(deps, { hostId: args.target.hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { diff --git a/apps/server/src/services/hosts/host-enrollment.ts b/apps/server/src/services/hosts/host-enrollment.ts index 021aa4da19..8291e379e4 100644 --- a/apps/server/src/services/hosts/host-enrollment.ts +++ b/apps/server/src/services/hosts/host-enrollment.ts @@ -3,21 +3,20 @@ import type { AppDeps } from "../../types.js"; type HostEnrollmentDeps = Pick; -interface IssuePersistentHostEnrollKeyArgs { +interface IssueHostEnrollKeyArgs { enrollSource: "loopback" | "public-multi-machine"; hostId?: string; } -export async function issuePersistentHostEnrollKey( +export async function issueHostEnrollKey( deps: HostEnrollmentDeps, - args: IssuePersistentHostEnrollKeyArgs, + args: IssueHostEnrollKeyArgs, ) { const hostId = args.hostId ?? createHostId(); const enrollKey = await deps.machineAuth.issueHostEnrollKey({ enrollSource: args.enrollSource, hostId, - hostType: "persistent", }); return { enrollKey, hostId }; diff --git a/apps/server/src/services/hosts/host-environment.test.ts b/apps/server/src/services/hosts/host-environment.test.ts new file mode 100644 index 0000000000..793ad7768c --- /dev/null +++ b/apps/server/src/services/hosts/host-environment.test.ts @@ -0,0 +1,103 @@ +import { defaultAppSettings } from "@bb/domain"; +import { + createConnection, + migrate, + upsertHost, + noopNotifier, + getHost, + setAppSettings, +} from "@bb/db"; +import { mkdtemp, writeFile, mkdir, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { resolveHostEnvironment } from "./host-environment.js"; +import { updateMachineEnvironment } from "../machines/environment-settings.js"; + +it("gives backfilled manual machines user and gh environment without enrollment while excluding the local daemon", async () => { + const db = createConnection(":memory:"); + const dataDir = await mkdtemp(join(tmpdir(), "bb-backfilled-env-")); + try { + migrate(db); + upsertHost(db, noopNotifier, { id: "legacy-remote", name: "Remote" }); + upsertHost(db, noopNotifier, { id: "local-daemon", name: "Local" }); + const sql = ( + await readFile( + new URL( + "../../../../../packages/db/drizzle/0117_machine_providers.sql", + import.meta.url, + ), + "utf8", + ) + ) + .split("--> statement-breakpoint") + .find((statement) => statement.includes("UPDATE hosts")); + if (sql === undefined) throw new Error("Missing manual machine backfill"); + db.$client.exec(sql); + migrate(db); + await writeFile(join(dataDir, "host-id"), "local-daemon"); + expect(getHost(db, "legacy-remote")?.machineProviderId).toBe("manual"); + await updateMachineEnvironment(db, dataDir, "MACHINE_VALUE", { + name: "MACHINE_VALUE", + value: "configured", + note: null, + }); + const bin = join(dataDir, "bin"); + await mkdir(bin); + await writeFile( + join(bin, "gh"), + `#!/bin/sh +if [ "$1" = auth ]; then printf 'test-gh-secret\\n'; else printf '{"login":"octocat","id":123,"email":null}\\n'; fi +`, + { mode: 0o700 }, + ); + vi.stubEnv("PATH", `${bin}:${process.env.PATH}`); + const deps = { db, config: { dataDir } }; + expect( + await resolveHostEnvironment(deps, { + hostId: "legacy-remote", + projectId: null, + }), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "MACHINE_VALUE", value: "configured" }), + expect.objectContaining({ + name: "GH_TOKEN", + value: "test-gh-secret", + }), + ]), + ); + expect( + await resolveHostEnvironment(deps, { + hostId: "local-daemon", + projectId: null, + }), + ).toEqual([]); + setAppSettings(db, { + ...defaultAppSettings, + machineGitCredentialsEnabled: false, + }); + const disabled = await resolveHostEnvironment(deps, { + hostId: "legacy-remote", + projectId: null, + }); + expect(disabled.some((row) => row.name === "GH_TOKEN")).toBe(false); + expect(disabled.some((row) => row.name === "MACHINE_VALUE")).toBe(true); + await updateMachineEnvironment(db, dataDir, "GH_TOKEN", { + name: "GH_TOKEN", + value: "custom-token", + note: null, + }); + const overridden = await resolveHostEnvironment(deps, { + hostId: "legacy-remote", + projectId: null, + }); + expect(overridden.find((row) => row.name === "GH_TOKEN")?.value).toBe( + "custom-token", + ); + } finally { + vi.unstubAllEnvs(); + db.$client.close(); + await rm(dataDir, { recursive: true, force: true }); + } +}); diff --git a/apps/server/src/services/hosts/host-environment.ts b/apps/server/src/services/hosts/host-environment.ts new file mode 100644 index 0000000000..171b2f9280 --- /dev/null +++ b/apps/server/src/services/hosts/host-environment.ts @@ -0,0 +1,60 @@ +import { getAppSettings, getHost } from "@bb/db"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { HOST_ID_FILE_NAME } from "@bb/host-daemon-contract"; +import { resolveUserMachineEnvironment } from "../machines/environment-settings.js"; +import type { AppDeps } from "../../types.js"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; +import { + githubGitConfiguration, + resolveGitCredentials, +} from "../machines/git-credentials.js"; + +type HostEnvironmentContext = { hostId: string; projectId: string | null }; +type HostEnvironmentContributor = ( + context: HostEnvironmentContext, +) => Promise; + +const contributors: readonly HostEnvironmentContributor[] = [ + () => resolveGitCredentials(), +]; + +export async function resolveHostEnvironment( + deps: { db: AppDeps["db"]; config: Pick }, + context: HostEnvironmentContext, +): Promise { + const host = getHost(deps.db, context.hostId); + if (!host || host.machineProviderId === null || host.destroyedAt !== null) + return []; + try { + if ( + readFileSync( + join(deps.config.dataDir, HOST_ID_FILE_NAME), + "utf8", + ).trim() === context.hostId + ) + return []; + } catch {} + const resolved = getAppSettings(deps.db).machineGitCredentialsEnabled + ? await Promise.all(contributors.map((resolve) => resolve(context))) + : []; + const builtIn = resolved.flat(); + const user = await resolveUserMachineEnvironment( + deps.db, + deps.config.dataDir, + ); + if (!builtIn.length && user.some((entry) => entry.name === "GH_TOKEN")) + builtIn.push(...githubGitConfiguration()); + return mergeHostAndProviderEnvironment(builtIn, user); +} + +export function mergeHostAndProviderEnvironment( + host: readonly HostDaemonContributedEnvEntry[], + provider: readonly HostDaemonContributedEnvEntry[], +): HostDaemonContributedEnvEntry[] { + const providerNames = new Set(provider.map((entry) => entry.name)); + return [ + ...host.filter((entry) => !providerNames.has(entry.name)), + ...provider, + ]; +} diff --git a/apps/server/src/services/hosts/host-lifecycle.ts b/apps/server/src/services/hosts/host-lifecycle.ts index e4fc7ad55a..5f6a268481 100644 --- a/apps/server/src/services/hosts/host-lifecycle.ts +++ b/apps/server/src/services/hosts/host-lifecycle.ts @@ -1,7 +1,12 @@ +import { assertMachineLifecycleAdmission } from "../machines/lifecycle.js"; import { getHost } from "@bb/db"; import type { WorkSessionDeps } from "../../types.js"; import { ApiError } from "../../errors.js"; import { requireConnectedHostSession } from "../lib/entity-lookup.js"; +import { + resumeMachine, + waitForMachineMaintenance, +} from "../machines/provider-orchestration.js"; export async function ensureHostSessionReadyForWork( deps: WorkSessionDeps, @@ -12,5 +17,25 @@ export async function ensureHostSessionReadyForWork( throw new ApiError(404, "host_not_found", "Host not found"); } + if (host.phase === "removing") { + throw new ApiError( + 409, + "machine_removing", + "Machine removal has begun; wait for a replacement machine", + ); + } + + await waitForMachineMaintenance(deps, host.id); + await resumeMachine(deps, host.id); + assertMachineLifecycleAdmission(deps, host.id); + const current = getHost(deps.db, host.id); + if (current?.phase === "removing") { + throw new ApiError( + 409, + "machine_removing", + "Machine removal has begun; wait for a replacement machine", + ); + } + return requireConnectedHostSession(deps, host.id); } diff --git a/apps/server/src/services/hosts/live-command-wait.ts b/apps/server/src/services/hosts/live-command-wait.ts index 524e122a99..d60767ac49 100644 --- a/apps/server/src/services/hosts/live-command-wait.ts +++ b/apps/server/src/services/hosts/live-command-wait.ts @@ -7,7 +7,7 @@ import { import type { LoggedWorkSessionDeps } from "../../types.js"; import { ApiError } from "../../errors.js"; import { roundDurationMs } from "../lib/duration.js"; -import { callHostOnlineRpc } from "./online-rpc.js"; +import { callHostOnlineRpcForWork } from "./online-rpc.js"; interface RunLiveCommandAndWaitArgs< TType extends HostDaemonSettledCommandType, @@ -127,7 +127,7 @@ export async function runLiveCommandAndWait( let completed = true; let failureLogFields: SlowCommandWaitFailureLogFields | null = null; try { - return await callHostOnlineRpc(deps, { + return await callHostOnlineRpcForWork(deps, { command: args.command, hostId: args.hostId, timeoutMs: args.timeoutMs, diff --git a/apps/server/src/services/hosts/live-command.ts b/apps/server/src/services/hosts/live-command.ts index 05ee6d5b64..0a356d90f9 100644 --- a/apps/server/src/services/hosts/live-command.ts +++ b/apps/server/src/services/hosts/live-command.ts @@ -18,7 +18,7 @@ import { } from "../../internal/command-result-side-effects.js"; import { handleLiveCommandResultSideEffects } from "../../internal/command-results.js"; import { NotificationBuffer } from "../lib/notification-buffer.js"; -import { callHostOnlineRpc } from "./online-rpc.js"; +import { callHostOnlineRpc, callHostOnlineRpcForWork } from "./online-rpc.js"; export const LIVE_DAEMON_COMMAND_TIMEOUT_MS = 24 * 60 * 60 * 1000; @@ -225,7 +225,11 @@ export async function runLiveHostCommand< const execution = args.execution ?? createLiveHostCommandExecution(args.hostId); try { - const result = await callHostOnlineRpc(deps, { + const call = + args.command.type === "thread.stop" + ? callHostOnlineRpc + : callHostOnlineRpcForWork; + const result = await call(deps, { command: args.command, hostId: args.hostId, timeoutMs: args.timeoutMs, diff --git a/apps/server/src/services/hosts/online-rpc.ts b/apps/server/src/services/hosts/online-rpc.ts index 401cf44a55..d8772ed9c9 100644 --- a/apps/server/src/services/hosts/online-rpc.ts +++ b/apps/server/src/services/hosts/online-rpc.ts @@ -1,3 +1,5 @@ +import { assertMachineLifecycleAdmission } from "../machines/lifecycle.js"; +import { getHost, getThread } from "@bb/db"; import { randomUUID } from "node:crypto"; import { type HostDaemonOnlineRpcResponseMessage, @@ -14,6 +16,7 @@ import { HostOnlineRpcUnavailableError, } from "../../ws/hub.js"; import { ensureHostSessionReadyForWork } from "./host-lifecycle.js"; +import { inactiveHostUnavailableDetails } from "../lib/lifecycle-api-errors.js"; const HOST_DAEMON_REGISTRATION_WAIT_MS = 1_000; @@ -39,8 +42,25 @@ export async function callHostOnlineRpc( deps: WorkSessionDeps, args: CallHostOnlineRpcArgs, ): Promise { + assertHostActiveForRead(deps, args); return callHostOnlineRpcWithRetry(deps, args, { retryOnTransportFailure: false, + waitForTransportFailure: false, + }); +} + +export function callHostOnlineRpcForWork( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, +): Promise>; +export async function callHostOnlineRpcForWork( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, +): Promise { + await prepareHostForWork(deps, args, false); + return callHostOnlineRpcWithRetry(deps, args, { + retryOnTransportFailure: false, + waitForTransportFailure: false, }); } @@ -54,37 +74,110 @@ export async function callHostRetryableOnlineRpc( deps: WorkSessionDeps, args: CallHostRetryableOnlineRpcArgs, ): Promise { + assertHostActiveForRead(deps, args); return callHostOnlineRpcWithRetry(deps, args, { retryOnTransportFailure: true, + waitForTransportFailure: false, }); } -async function callHostOnlineRpcWithRetry( +export function callHostRetryableOnlineRpcForWork< + TCommand extends HostDaemonRetryableOnlineRpcCommand, +>( deps: WorkSessionDeps, - args: CallHostOnlineRpcArgs, - options: { retryOnTransportFailure: false }, -): Promise; -async function callHostOnlineRpcWithRetry( + args: CallHostRetryableOnlineRpcArgs, +): Promise>; +export async function callHostRetryableOnlineRpcForWork( deps: WorkSessionDeps, args: CallHostRetryableOnlineRpcArgs, - options: { retryOnTransportFailure: true }, -): Promise; -async function callHostOnlineRpcWithRetry( +): Promise { + await prepareHostForWork(deps, args, true); + return callHostOnlineRpcWithRetry(deps, args, { + retryOnTransportFailure: true, + waitForTransportFailure: true, + }); +} + +function assertHostActiveForRead( deps: WorkSessionDeps, args: CallHostOnlineRpcArgs, - options: { retryOnTransportFailure: boolean }, -): Promise { +): void { + if ( + args.command.type === "thread.stop" || + args.command.type === "environment.hook.cancel" || + args.command.type === "plugin.host.cancel" || + args.command.type === "plugin.host.dispose" + ) { + return; + } + const host = getHost(deps.db, args.hostId); + if (host !== null && host.phase !== "active") { + const details = inactiveHostUnavailableDetails("disconnected", host); + throw new ApiError( + 502, + "host_unavailable", + details.reason === "suspended" + ? "Host is suspended" + : "Host is not connected", + { details, retryable: false }, + ); + } +} + +async function prepareHostForWork( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, + retryOnTransportFailure: boolean, +): Promise { await ensureHostSessionReadyForWork(deps, { hostId: args.hostId }).catch( async (error) => { - if ( - !options.retryOnTransportFailure || - !isHostUnavailableApiError(error) - ) { + if (!retryOnTransportFailure || !isHostUnavailableApiError(error)) { throw error; } await waitForRetryableHostRpcTransport(deps, args.hostId); }, ); + assertMachineLifecycleAdmission(deps, args.hostId); + if ( + (args.command.type === "thread.start" || + args.command.type === "turn.submit") && + getHost(deps.db, args.hostId)?.machineOperationId !== null && + !["active", "starting"].includes( + getThread(deps.db, args.command.threadId)?.status ?? "", + ) + ) { + throw new ApiError( + 409, + "machine_dispatch_interrupted", + "This turn was interrupted while waiting for machine preservation; submit a new continuation turn", + ); + } +} + +async function callHostOnlineRpcWithRetry( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, + options: { + retryOnTransportFailure: false; + waitForTransportFailure: false; + }, +): Promise; +async function callHostOnlineRpcWithRetry( + deps: WorkSessionDeps, + args: CallHostRetryableOnlineRpcArgs, + options: { + retryOnTransportFailure: true; + waitForTransportFailure: boolean; + }, +): Promise; +async function callHostOnlineRpcWithRetry( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, + options: { + retryOnTransportFailure: boolean; + waitForTransportFailure: boolean; + }, +): Promise { const timeoutRetryDeadline = options.retryOnTransportFailure && args.timeoutMs > 1 ? Date.now() + args.timeoutMs @@ -104,6 +197,7 @@ async function callHostOnlineRpcWithRetry( throwOnlineRpcError(error); } if (error instanceof HostOnlineRpcUnavailableError) { + if (!options.waitForTransportFailure) throwOnlineRpcError(error); await waitForRetryableHostRpcTransport(deps, args.hostId); return requestHostOnlineRpcResponse(deps, args).catch((retryError) => { throwOnlineRpcError(retryError); diff --git a/apps/server/src/services/hosts/primary-host.test.ts b/apps/server/src/services/hosts/primary-host.test.ts index cdf1f686df..946a7d9ae9 100644 --- a/apps/server/src/services/hosts/primary-host.test.ts +++ b/apps/server/src/services/hosts/primary-host.test.ts @@ -78,4 +78,15 @@ describe("assertUsableHostId", () => { expect(resolvePrimaryHostId(harness.deps)).toBe(primary.id); }); + + it("resolves a provider-made machine when it is configured as primary", async () => { + harness = await createTestAppHarness(); + const { host: providerMachine } = seedHostSession(harness.deps, { + name: "sandbox", + }); + seedHostSession(harness.deps, { name: "laptop" }); + seedPrimaryHost(harness.deps, providerMachine.id); + + expect(resolvePrimaryHostId(harness.deps)).toBe(providerMachine.id); + }); }); diff --git a/apps/server/src/services/hosts/primary-host.ts b/apps/server/src/services/hosts/primary-host.ts index 7f2adc4652..9690285c6d 100644 --- a/apps/server/src/services/hosts/primary-host.ts +++ b/apps/server/src/services/hosts/primary-host.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { listPublicHosts, type DbConnection } from "@bb/db"; +import { getHost, listPublicHosts, type DbConnection } from "@bb/db"; import { HOST_ID_FILE_NAME } from "@bb/host-daemon-contract"; import { ApiError } from "../../errors.js"; import type { AppDeps } from "../../types.js"; @@ -64,8 +64,13 @@ function resolveSingleConnectedPublicHostId( } export function resolvePrimaryHostId(deps: PrimaryHostDeps): string | null { + const configured = readPrimaryHostIdFromDataDir({ + dataDir: deps.config.dataDir, + }); + const configuredHost = + configured === null ? null : getHost(deps.db, configured); return ( - readPrimaryHostIdFromDataDir({ dataDir: deps.config.dataDir }) ?? + (configuredHost?.destroyedAt === null ? configuredHost.id : null) ?? resolveSingleConnectedPublicHostId(deps) ?? resolveSinglePublicHostId(deps.db) ); diff --git a/apps/server/src/services/lib/entity-lookup.ts b/apps/server/src/services/lib/entity-lookup.ts index f2b928086f..1d0e64581c 100644 --- a/apps/server/src/services/lib/entity-lookup.ts +++ b/apps/server/src/services/lib/entity-lookup.ts @@ -16,7 +16,7 @@ import { ApiError } from "../../errors.js"; import { destroyedHostUnavailableDetails, destroyedThreadEnvironmentDetails, - disconnectedHostUnavailableDetails, + inactiveHostUnavailableDetails, throwEnvironmentNotReady, throwHostUnavailable, throwProjectUnavailable, @@ -78,8 +78,22 @@ function toHostRecord(row: HostRow, status: Host["status"]): Host { return { id: row.id, name: row.name, - status, type: row.type, + status, + machineProviderId: row.machineProviderId, + lifecycle: { + phase: row.phase, + suspendedAt: row.suspendedAt, + message: row.statusMessage, + pendingLog: row.pendingLog, + teardown: + row.teardownStatus === null + ? null + : { + status: row.teardownStatus, + attempt: row.teardownAttempt, + }, + }, maxPermissionMode: row.maxPermissionMode, lastSeenAt: row.lastSeenAt, lastRejectedProtocolVersion: row.lastRejectedProtocolVersion, @@ -96,8 +110,11 @@ function isStandardProject(project: ProjectRow): project is StandardProject { return project.kind === "standard"; } -export function listPublicHostsWithStatus(deps: HostLookupDeps): Host[] { - const rows = listPublicHosts(deps.db); +export function listPublicHostsWithStatus( + deps: HostLookupDeps, + options?: { includeCreating?: boolean }, +): Host[] { + const rows = listPublicHosts(deps.db, options); return rows.map((row) => toHostRecord( @@ -140,26 +157,28 @@ export function requireConnectedHostSession( deps: HostLookupDeps, hostId: string, ) { - const session = getOpenDaemonSessionForHost(deps, hostId); - if (!session) { - const host = getHost(deps.db, hostId); - if (!host) { - throwHostNotFound(); - } - if (host.destroyedAt !== null) { - throwHostUnavailable( - 404, - "Host is unavailable", - destroyedHostUnavailableDetails(host.destroyedAt), - ); - } - const hostStatus = toHostStatus(deps, hostId); + const host = getHost(deps.db, hostId); + if (!host) { + throwHostNotFound(); + } + if (host.destroyedAt !== null) { throwHostUnavailable( - 502, - "Host is not connected", - disconnectedHostUnavailableDetails(hostStatus), + 404, + "Host is unavailable", + destroyedHostUnavailableDetails(host.destroyedAt), ); } + const session = getOpenDaemonSessionForHost(deps, hostId); + const details = inactiveHostUnavailableDetails( + session ? "connected" : "disconnected", + host, + ); + if (details.reason === "suspended") { + throwHostUnavailable(502, "Host is suspended", details); + } + if (!session) { + throwHostUnavailable(502, "Host is not connected", details); + } return session; } diff --git a/apps/server/src/services/lib/lifecycle-api-errors.ts b/apps/server/src/services/lib/lifecycle-api-errors.ts index 943767c0a5..1dd626e548 100644 --- a/apps/server/src/services/lib/lifecycle-api-errors.ts +++ b/apps/server/src/services/lib/lifecycle-api-errors.ts @@ -10,6 +10,7 @@ import type { ThreadNotWritableErrorDetails, ThreadNotWritableReason, } from "@bb/server-contract"; +import { hostUnavailableErrorDetailsSchema } from "@bb/server-contract"; import { ApiError } from "../../errors.js"; type EnvironmentReadinessFields = Pick; @@ -20,6 +21,8 @@ type ThreadWritableFields = Pick; type HostUnavailableStatus = 404 | 502; +type HostLifecycleFields = Pick; + interface ParentThreadInvalidDetailsArgs { reason: ParentThreadInvalidReason; subject: ParentThreadInvalidSubject; @@ -125,17 +128,33 @@ export function throwThreadNotWritable( }); } -export function disconnectedHostUnavailableDetails( +export function inactiveHostUnavailableDetails( hostStatus: Host["status"] = "disconnected", + lifecycle?: HostLifecycleFields, ): HostUnavailableErrorDetails { + const suspended = + lifecycle !== undefined && + (lifecycle.phase === "suspending" || + lifecycle.phase === "suspended" || + lifecycle.suspendedAt !== null); return { - reason: "disconnected", + reason: suspended ? "suspended" : "disconnected", hostStatus, - suspendedAt: null, + suspendedAt: suspended ? lifecycle.suspendedAt : null, destroyedAt: null, }; } +export function isSuspendedHostUnavailableError(error: unknown): boolean { + if (!(error instanceof ApiError) || error.body.code !== "host_unavailable") { + return false; + } + const details = hostUnavailableErrorDetailsSchema.safeParse( + error.body.details, + ); + return details.success && details.data.reason === "suspended"; +} + export function destroyedHostUnavailableDetails( destroyedAt: number, ): HostUnavailableErrorDetails { diff --git a/apps/server/src/services/machine-auth.ts b/apps/server/src/services/machine-auth.ts index 55b5b1945d..6c70b1846f 100644 --- a/apps/server/src/services/machine-auth.ts +++ b/apps/server/src/services/machine-auth.ts @@ -4,7 +4,6 @@ import { betterAuth } from "better-auth"; import { apiKey } from "@better-auth/api-key"; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { authApiKeys, authUsers, type DbConnection } from "@bb/db"; -import { hostTypeSchema, type HostType } from "@bb/domain"; import { readOrCreateSecretFile } from "@bb/secret-storage"; import { z } from "zod"; import type { ServerLogger } from "../types.js"; @@ -22,32 +21,45 @@ const machineAuthSchema = { user: authUsers, }; -const machineCredentialMetadataSchema = z +const currentMachineCredentialMetadataSchema = z .object({ hostId: z.string().min(1), - hostType: hostTypeSchema, enrollSource: z.enum(["loopback", "public-multi-machine"]).optional(), }) .strict(); +const legacyMachineCredentialMetadataSchema = z + .object({ + hostId: z.string().min(1), + hostType: z.literal("persistent"), + enrollSource: z.enum(["loopback", "public-multi-machine"]).optional(), + }) + .strict() + .transform(({ hostId, enrollSource }) => ({ + hostId, + ...(enrollSource === undefined ? {} : { enrollSource }), + })); + +const machineCredentialMetadataSchema = z.union([ + currentMachineCredentialMetadataSchema, + legacyMachineCredentialMetadataSchema, +]); + type MachineCredentialMetadata = z.infer< typeof machineCredentialMetadataSchema >; interface IssueHostEnrollKeyArgs { hostId: string; - hostType: HostType; enrollSource: "loopback" | "public-multi-machine"; } interface RevokeHostAuthKeysArgs { hostId: string; - hostType: HostType; } interface IssueDaemonHostKeyArgs { hostId: string; - hostType: HostType; } interface IssueHostEnrollKeyResult { @@ -58,7 +70,6 @@ interface IssueHostEnrollKeyResult { export interface EnrollHostArgs { allowPublicEnrollment: boolean; hostId: string; - hostType: HostType; token: string; } @@ -92,6 +103,7 @@ export interface MachineAuthService { ): Promise; pruneExpiredKeys(): Promise; revokeHostAuthKeys(args: RevokeHostAuthKeysArgs): Promise; + revokeHostEnrollKeys(args: RevokeHostAuthKeysArgs): Promise; verifyDaemonHostKey(token: string): Promise; } @@ -156,6 +168,20 @@ export async function createMachineAuthService( }); let readyPromise: Promise | null = null; + const hostOperations = new Map>(); + async function forHost( + hostId: string, + operation: () => Promise, + ): Promise { + const previous = hostOperations.get(hostId) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(operation); + hostOperations.set(hostId, current); + try { + return await current; + } finally { + if (hostOperations.get(hostId) === current) hostOperations.delete(hostId); + } + } async function ensureSystemUser(): Promise { const now = new Date(); @@ -251,7 +277,6 @@ export async function createMachineAuthService( eq(authApiKeys.configId, DAEMON_ENROLL_CONFIG_ID), eq(authApiKeys.enabled, true), sql`json_extract(${authApiKeys.metadata}, '$.hostId') = ${metadata.hostId}`, - sql`json_extract(${authApiKeys.metadata}, '$.hostType') = ${metadata.hostType}`, ), ) .run(); @@ -274,7 +299,6 @@ export async function createMachineAuthService( eq(authApiKeys.enabled, true), ne(authApiKeys.id, preserveKeyId), sql`json_extract(${authApiKeys.metadata}, '$.hostId') = ${metadata.hostId}`, - sql`json_extract(${authApiKeys.metadata}, '$.hostType') = ${metadata.hostType}`, ), ) .run(); @@ -295,7 +319,6 @@ export async function createMachineAuthService( eq(authApiKeys.configId, DAEMON_HOST_CONFIG_ID), eq(authApiKeys.enabled, true), sql`json_extract(${authApiKeys.metadata}, '$.hostId') = ${metadata.hostId}`, - sql`json_extract(${authApiKeys.metadata}, '$.hostType') = ${metadata.hostType}`, ), ) .run(); @@ -325,94 +348,91 @@ export async function createMachineAuthService( async enrollHost({ allowPublicEnrollment, hostId, - hostType, token, }: EnrollHostArgs): Promise { - const verified = await verifyKey({ - configId: DAEMON_ENROLL_CONFIG_ID, - token, + return forHost(hostId, async () => { + const verified = await verifyKey({ + configId: DAEMON_ENROLL_CONFIG_ID, + token, + }); + if (!verified) { + return null; + } + if (verified.metadata.hostId !== hostId) { + return null; + } + if ( + verified.metadata.enrollSource === "public-multi-machine" && + !allowPublicEnrollment + ) { + return null; + } + + const hostMetadata: MachineCredentialMetadata = { + hostId: verified.metadata.hostId, + }; + + const hostKey = await createDaemonHostKey(hostMetadata); + await disableOtherActiveDaemonHostKeysForHost( + hostMetadata, + hostKey.keyId, + ); + return { + hostKey: hostKey.key, + metadata: hostMetadata, + }; }); - if (!verified) { - return null; - } - if ( - verified.metadata.hostId !== hostId || - verified.metadata.hostType !== hostType - ) { - return null; - } - if ( - verified.metadata.enrollSource === "public-multi-machine" && - !allowPublicEnrollment - ) { - return null; - } - - const hostMetadata: MachineCredentialMetadata = { - hostId: verified.metadata.hostId, - hostType: verified.metadata.hostType, - }; - - const hostKey = await createDaemonHostKey(hostMetadata); - await disableOtherActiveDaemonHostKeysForHost( - hostMetadata, - hostKey.keyId, - ); - return { - hostKey: hostKey.key, - metadata: hostMetadata, - }; }, async issueDaemonHostKey({ hostId, - hostType, }: IssueDaemonHostKeyArgs): Promise { - const created = await createDaemonHostKey({ - hostId, - hostType, - }); + const created = await createDaemonHostKey({ hostId }); return created.key; }, async issueHostEnrollKey({ enrollSource, hostId, - hostType, }: IssueHostEnrollKeyArgs): Promise { - await ensureReady(); - const metadata = { - enrollSource, - hostId, - hostType, - }; - await disableActiveEnrollKeysForHost(metadata); - - const created = await auth.api.createApiKey({ - body: { - configId: DAEMON_ENROLL_CONFIG_ID, - metadata, - remaining: 1, - rateLimitEnabled: false, - userId: MACHINE_AUTH_SYSTEM_USER_ID, - }, - }); + return forHost(hostId, async () => { + await ensureReady(); + const metadata = { + enrollSource, + hostId, + }; + await disableActiveEnrollKeysForHost(metadata); + + const created = await auth.api.createApiKey({ + body: { + configId: DAEMON_ENROLL_CONFIG_ID, + metadata, + remaining: 1, + rateLimitEnabled: false, + userId: MACHINE_AUTH_SYSTEM_USER_ID, + }, + }); - if (!created.expiresAt) { - throw new Error("Machine enroll key is missing an expiration time"); - } + if (!created.expiresAt) { + throw new Error("Machine enroll key is missing an expiration time"); + } - return { - expiresAt: created.expiresAt.getTime(), - key: created.key, - }; + return { + expiresAt: created.expiresAt.getTime(), + key: created.key, + }; + }); }, async pruneExpiredKeys(): Promise { await pruneExpiredKeys(); }, + async revokeHostEnrollKeys({ + hostId, + }: RevokeHostAuthKeysArgs): Promise { + await forHost(hostId, () => disableActiveEnrollKeysForHost({ hostId })); + }, async revokeHostAuthKeys({ hostId, - hostType, }: RevokeHostAuthKeysArgs): Promise { - const metadata = { hostId, hostType }; + const metadata = { hostId }; await disableActiveEnrollKeysForHost(metadata); await disableActiveDaemonHostKeysForHost(metadata); }, diff --git a/apps/server/src/services/machines/bootstrap.test.ts b/apps/server/src/services/machines/bootstrap.test.ts new file mode 100644 index 0000000000..927743038c --- /dev/null +++ b/apps/server/src/services/machines/bootstrap.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MachineExecutor } from "@get-bb/plugin-sdk"; +import type { MachineEnrollments, EnrollmentBootstrap } from "./enrollments.js"; +import { createMachineBootstrapApi } from "./bootstrap.js"; + +const bootstrap: EnrollmentBootstrap = { + hostId: "host_1", + serverUrl: "https://server.example", + credential: "private-credential", + expiresAt: Date.now() + 60_000, +}; + +function harness() { + const enrollments: MachineEnrollments = { + clearPending: vi.fn(), + prepare: vi.fn(async () => ({ + id: "enrollment", + hostId: "host_1", + state: "pending", + bootstrap, + })), + waitForConnection: vi.fn(async () => ({ hostId: "host_1" })), + }; + const api = createMachineBootstrapApi(enrollments); + const exec = vi.fn(async () => ({ + exitCode: 0, + stdout: "", + stderr: "", + })); + const report = { step: vi.fn(), log: vi.fn() }; + return { api, enrollments, exec, report }; +} + +describe("machine bootstrap", () => { + it("delivers credentials through stdin and forwards streamed executor output", async () => { + const h = harness(); + h.exec.mockImplementation(async (request) => { + request.onOutput?.("installing\nstart"); + request.onOutput?.("ed\n"); + return { + exitCode: 0, + stdout: "installing\nstarted\n", + stderr: "", + }; + }); + const result = await h.api.bootstrap({ + key: "key", + executor: { exec: h.exec }, + report: h.report, + signal: new AbortController().signal, + }); + const request = vi.mocked(h.exec).mock.calls[0]; + expect(JSON.stringify(request)).toContain(bootstrap.credential); + expect(JSON.stringify(h.report.step.mock.calls)).not.toContain( + bootstrap.credential, + ); + expect(h.report.log.mock.calls).toEqual([["installing\n"], ["started\n"]]); + expect(h.enrollments.waitForConnection).toHaveBeenCalledOnce(); + expect(result).toEqual({ hostId: "host_1" }); + expect(request[0].command.join(" ")).not.toContain(bootstrap.credential); + }); + + it("includes the last 20 streamed lines when the command exits non-zero", async () => { + const h = harness(); + h.exec.mockImplementation(async (request) => { + request.onOutput?.( + Array.from({ length: 25 }, (_, index) => `line ${index + 1}`).join( + "\n", + ) + "\n", + ); + return { exitCode: 9, stdout: "", stderr: "" }; + }); + const error = await h.api + .bootstrap({ + key: "key", + executor: { exec: h.exec }, + report: h.report, + signal: new AbortController().signal, + }) + .catch((caught) => caught); + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain("line 6\nline 7"); + expect(error.message).toContain("line 25"); + expect(error.message).not.toContain("line 5\n"); + }); + + it("redacts transport failures and retains enrollment for retry", async () => { + const h = harness(); + h.exec.mockRejectedValue(new Error(bootstrap.credential)); + await expect( + h.api.bootstrap({ + key: "key", + executor: { exec: h.exec }, + report: h.report, + signal: new AbortController().signal, + }), + ).rejects.toThrow(/^Machine bootstrap command failed$/); + expect(h.enrollments.waitForConnection).not.toHaveBeenCalled(); + }); + + it("starts an already enrolled machine before waiting after snapshot restore", async () => { + const h = harness(); + vi.mocked(h.enrollments.prepare).mockResolvedValue({ + id: "enrollment", + hostId: "host_1", + state: "enrolled", + }); + await h.api.bootstrap({ + key: "key", + executor: { exec: h.exec }, + report: h.report, + signal: new AbortController().signal, + }); + expect(h.exec).toHaveBeenCalledWith( + expect.objectContaining({ + command: ["sh", "-s", "--", "--start", "--host-id", "host_1"], + stdin: expect.stringContaining("Usage: install.sh"), + }), + ); + expect(h.enrollments.waitForConnection).toHaveBeenCalledOnce(); + }); + + it("waits for a manual connection when executor is omitted", async () => { + const h = harness(); + await expect( + h.api.bootstrap({ + key: "key", + report: h.report, + signal: new AbortController().signal, + }), + ).resolves.toEqual({ hostId: "host_1" }); + expect(h.exec).not.toHaveBeenCalled(); + expect(h.enrollments.waitForConnection).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 15 * 60_000 }), + ); + }); + + it("does no work after abort", async () => { + const h = harness(); + await expect( + h.api.bootstrap({ + key: "key", + executor: { exec: h.exec }, + report: h.report, + signal: AbortSignal.abort(), + }), + ).rejects.toThrow(); + expect(h.enrollments.prepare).not.toHaveBeenCalled(); + expect(h.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/services/machines/bootstrap.ts b/apps/server/src/services/machines/bootstrap.ts new file mode 100644 index 0000000000..cc20b12dc9 --- /dev/null +++ b/apps/server/src/services/machines/bootstrap.ts @@ -0,0 +1,127 @@ +import type { MachineBootstrapApi } from "@get-bb/plugin-sdk"; +import type { PluginMachineProviderProgress } from "@get-bb/plugin-sdk/machine-provider"; +import type { EnrollmentBootstrap, MachineEnrollments } from "./enrollments.js"; +import { readFile } from "node:fs/promises"; +import { INSTALL_MACHINE_SCRIPT_PATH } from "../../install-machine-asset.js"; + +const installerScript = ` +set -eu +umask 077 +BB_ENROLLMENT=$(cat) +export BB_ENROLLMENT +installer_url=$1 +installer_file=$(mktemp) +trap 'rm -f "$installer_file"' EXIT HUP INT TERM +node -e 'for (const [name,value] of Object.entries(JSON.parse(process.env.BB_ENROLLMENT).headers ?? {})) console.log("header = " + JSON.stringify(name + ": " + value))' | curl --config - --fail --silent --show-error --location --connect-timeout 10 --max-time 60 "$installer_url" > "$installer_file" +sh "$installer_file" --bootstrap-env BB_ENROLLMENT +`; + +function installerCommand(bootstrap: EnrollmentBootstrap) { + return { + command: [ + "sh", + "-c", + installerScript, + "bb-machine-install", + new URL("/install.sh", bootstrap.serverUrl).href, + ], + stdin: JSON.stringify(bootstrap), + }; +} + +const installerSource = readFile(INSTALL_MACHINE_SCRIPT_PATH, "utf8"); + +function createOutputReporter(report: PluginMachineProviderProgress) { + let pending = ""; + const tail: string[] = []; + const emit = (line: string, terminated: boolean): void => { + report.log(line + (terminated ? "\n" : "")); + tail.push(line); + if (tail.length > 20) tail.shift(); + }; + return { + onOutput(chunk: string): void { + pending += chunk; + for (;;) { + const match = /\r\n|\r|\n/u.exec(pending); + if (match === null) return; + emit(pending.slice(0, match.index), true); + pending = pending.slice(match.index + match[0].length); + } + }, + finish(): void { + if (pending.length === 0) return; + emit(pending, false); + pending = ""; + }, + failureMessage(): string { + return tail.length === 0 + ? "Machine bootstrap command failed" + : `Machine bootstrap command failed:\n${tail.join("\n")}`; + }, + }; +} + +async function installerStartCommand(hostId: string) { + return { + command: ["sh", "-s", "--", "--start", "--host-id", hostId], + stdin: await installerSource, + }; +} + +export function createMachineBootstrapApi( + enrollments: MachineEnrollments, +): MachineBootstrapApi { + return { + async bootstrap(request) { + request.signal.throwIfAborted(); + request.report.step("Preparing machine enrollment"); + const enrollment = await enrollments.prepare({ + key: request.key, + access: request.access, + }); + try { + request.signal.throwIfAborted(); + request.report.step( + request.executor === undefined + ? "Run the enrollment command shown below" + : enrollment.state === "enrolled" + ? "Starting enrolled machine" + : "Bootstrapping machine", + ); + if (request.executor) { + const execution = await (enrollment.state === "enrolled" + ? installerStartCommand(enrollment.hostId) + : installerCommand(enrollment.bootstrap)); + const output = createOutputReporter(request.report); + let result; + try { + result = await request.executor.exec({ + ...execution, + timeoutMs: 600_000, + signal: request.signal, + onOutput: output.onOutput, + }); + } catch { + request.signal.throwIfAborted(); + throw new Error("Machine bootstrap command failed"); + } + output.finish(); + if (result.exitCode !== 0) throw new Error(output.failureMessage()); + } + request.signal.throwIfAborted(); + if (request.executor !== undefined) + request.report.step("Waiting for machine connection"); + await enrollments.waitForConnection({ + enrollmentId: enrollment.id, + timeoutMs: request.executor ? 120_000 : 15 * 60_000, + signal: request.signal, + }); + return { hostId: enrollment.hostId }; + } catch (error) { + enrollments.clearPending(request.key); + throw error; + } + }, + }; +} diff --git a/apps/server/src/services/machines/enrollments.test.ts b/apps/server/src/services/machines/enrollments.test.ts new file mode 100644 index 0000000000..81efce8dad --- /dev/null +++ b/apps/server/src/services/machines/enrollments.test.ts @@ -0,0 +1,220 @@ +import { eq } from "drizzle-orm"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createConnection, migrate, hosts } from "@bb/db"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMachineAuthService } from "../machine-auth.js"; +import { createMachineEnrollmentService } from "./enrollments.js"; +import { setPluginMachineProviderBridge } from "../plugins/plugin-machine-provider-registry.js"; +import { validatePluginMachineProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; + +const cleanup: Array<() => Promise> = []; +afterEach(async () => { + for (const dispose of cleanup.splice(0)) await dispose(); + setPluginMachineProviderBridge(undefined); +}); + +async function harness() { + const dataDir = await mkdtemp(join(tmpdir(), "bb-enrollments-test-")); + const db = createConnection(":memory:"); + migrate(db); + cleanup.push(async () => { + db.$client.close(); + await rm(dataDir, { recursive: true, force: true }); + }); + const machineAuth = await createMachineAuthService({ + db, + dataDir, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + }); + const serverAccess = { + resolve: vi.fn(async () => ({ + id: "grant", + serverUrl: "https://server.example", + })), + release: vi.fn(async () => {}), + }; + const connected = new Set(); + const deps = { + dataDir, + db, + machineAuth, + serverAccess, + isConnected: (id: string) => connected.has(id), + }; + const create = () => createMachineEnrollmentService(deps); + const service = create(); + const provider = validatePluginMachineProviderDeclaration({ + id: "test-machine", + displayName: "Test machine", + description: "Test machine", + icon: "Terminal", + create: async () => ({ status: "failed", message: "unused" }), + reconcileCleanup: async () => ({ status: "removed" }), + remove: async () => ({ status: "removed" }), + }); + setPluginMachineProviderBridge({ + listMachineProviders: () => [{ pluginId: "plugin-a", provider }], + getMachineProvider: (id) => + id === provider.id ? { pluginId: "plugin-a", provider } : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 1_000, + }); + const seed = (key: string) => { + const now = Date.now(); + db.insert(hosts) + .values({ + id: `host_${key}`, + name: "Test machine", + type: "persistent", + machineProviderId: provider.id, + launchKey: key, + attempt: 1, + phase: "creating", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .run(); + }; + return { + ...deps, + connected, + create, + api: service.forOwner("plugin-a"), + other: service.forOwner("plugin-b"), + seed, + }; +} + +describe("machine enrollments", () => { + it("serializes same-key prepares and reissues credentials for the same durable identity", async () => { + const h = await harness(); + h.seed("create"); + const [first, parallel] = await Promise.all([ + h.api.prepare({ key: "create" }), + h.api.prepare({ key: "create" }), + ]); + expect(parallel.hostId).toBe(first.hostId); + expect(h.serverAccess.resolve).toHaveBeenCalledTimes(2); + const restarted = await h + .create() + .forOwner("plugin-a") + .prepare({ key: "create" }); + expect(restarted.id).toBe(first.id); + expect(restarted.hostId).toBe(first.hostId); + expect(first.state).toBe("pending"); + if (first.state !== "pending" || restarted.state !== "pending") + throw new Error("Expected pending enrollment"); + expect(restarted.bootstrap.credential).not.toBe(first.bootstrap.credential); + expect(parallel.state).toBe("pending"); + if (parallel.state !== "pending") + throw new Error("Expected pending enrollment"); + expect(parallel.bootstrap.credential).not.toBe(first.bootstrap.credential); + expect(JSON.stringify(h.db.select().from(hosts).all())).not.toContain( + first.bootstrap.credential, + ); + }); + + it("rejects conflicting access selection even when a bundle is cached", async () => { + const h = await harness(); + h.seed("access"); + const prepared = await h.api.prepare({ key: "access" }); + h.db.$client + .prepare("UPDATE hosts SET server_access_provider_id = ? WHERE id = ?") + .run("direct", prepared.hostId); + await expect( + h.api.prepare({ key: "access", access: { providerId: "connect" } }), + ).rejects.toThrow("different server access provider"); + }); + + it("rejects removed identities", async () => { + const h = await harness(); + h.seed("removed"); + const prepared = await h.api.prepare({ key: "removed" }); + if (prepared.state !== "pending") throw new Error("Expected enrollment"); + h.db + .update(hosts) + .set({ phase: "destroyed" }) + .where(eq(hosts.id, prepared.hostId)) + .run(); + await expect(h.api.prepare({ key: "removed" })).rejects.toThrow( + "cancelled", + ); + }); + + it("recovers a lost exchange response with a fresh credential for the same identity", async () => { + const h = await harness(); + h.seed("lost-response"); + const first = await h.api.prepare({ key: "lost-response" }); + if (first.state !== "pending") + throw new Error("Expected pending enrollment"); + const lostResponse = await h.machineAuth.enrollHost({ + token: first.bootstrap.credential, + hostId: first.hostId, + allowPublicEnrollment: true, + }); + expect(lostResponse).not.toBeNull(); + await h.machineAuth.issueHostEnrollKey({ + hostId: first.hostId, + enrollSource: "public-multi-machine", + }); + const retry = await h + .create() + .forOwner("plugin-a") + .prepare({ key: "lost-response" }); + if (retry.state !== "pending") + throw new Error("Expected recoverable pending enrollment"); + expect(retry.hostId).toBe(first.hostId); + expect(retry.bootstrap.credential === first.bootstrap.credential).toBe( + false, + ); + const recovered = await h.machineAuth.enrollHost({ + token: retry.bootstrap.credential, + hostId: retry.hostId, + allowPublicEnrollment: true, + }); + if (!recovered || !lostResponse) + throw new Error("Expected successful exchanges"); + expect( + await h.machineAuth.verifyDaemonHostKey(recovered.hostKey), + ).not.toBeNull(); + expect( + await h.machineAuth.verifyDaemonHostKey(lostResponse.hostKey), + ).toBeNull(); + const beforeStart = await h.api.prepare({ key: "lost-response" }); + expect(beforeStart.state).toBe("pending"); + expect( + await h.machineAuth.verifyDaemonHostKey(recovered.hostKey), + ).not.toBeNull(); + h.db + .update(hosts) + .set({ lastSeenAt: Date.now() }) + .where(eq(hosts.id, first.hostId)) + .run(); + expect( + await h.create().forOwner("plugin-a").prepare({ key: "lost-response" }), + ).toEqual({ + id: first.id, + hostId: first.hostId, + state: "enrolled", + }); + }); + + it("recovers access failures with the same durable host identity", async () => { + const h = await harness(); + h.seed("create"); + h.serverAccess.resolve.mockRejectedValueOnce( + new Error("temporarily unavailable"), + ); + await expect(h.api.prepare({ key: "create" })).rejects.toThrow( + "temporarily unavailable", + ); + const retry = await h.api.prepare({ key: "create" }); + expect(retry.hostId).toBe("host_create"); + }); +}); diff --git a/apps/server/src/services/machines/enrollments.ts b/apps/server/src/services/machines/enrollments.ts new file mode 100644 index 0000000000..a594922d65 --- /dev/null +++ b/apps/server/src/services/machines/enrollments.ts @@ -0,0 +1,277 @@ +import { defaultKeyHasher } from "@better-auth/api-key"; +import { getMachineProvider } from "../plugins/plugin-machine-provider-registry.js"; +import { setTimeout as delay } from "node:timers/promises"; +import { and, eq, gt, sql } from "drizzle-orm"; +import { + authApiKeys, + getHost, + getNonDestroyedHostByLaunchKey, + type DbConnection, +} from "@bb/db"; +import type { + ServerAccessGrant, + ServerAccessSelection, +} from "@get-bb/plugin-sdk"; +import type { MachineAuthService } from "../machine-auth.js"; + +export interface EnrollmentBootstrap { + hostId: string; + serverUrl: string; + headers?: ServerAccessGrant["headers"]; + credential: string; + expiresAt: number; +} + +export type MachineEnrollment = + | { + id: string; + hostId: string; + state: "pending"; + bootstrap: EnrollmentBootstrap; + } + | { id: string; hostId: string; state: "enrolled" }; + +export interface MachineEnrollments { + clearPending(key: string): void; + prepare(request: { + key: string; + access?: ServerAccessSelection; + }): Promise; + waitForConnection(request: { + enrollmentId: string; + timeoutMs: number; + signal: AbortSignal; + }): Promise<{ hostId: string }>; +} + +interface EnrollmentServiceDependencies { + db: DbConnection; + machineAuth: MachineAuthService; + serverAccess: { + resolve(request: { + key: string; + hostId: string; + access?: ServerAccessSelection; + signal: AbortSignal; + }): Promise; + release(request: { + key: string; + hostId: string; + signal: AbortSignal; + }): Promise; + }; + isConnected(hostId: string): boolean; +} + +export function createMachineEnrollmentService( + deps: EnrollmentServiceDependencies, +) { + const pending = new Map< + string, + { owner: string; launchKey: string; bootstrap: EnrollmentBootstrap } + >(); + const locks = new Map>(); + + async function serialized( + key: string, + action: () => Promise, + ): Promise { + const previous = locks.get(key) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(action); + locks.set(key, current); + try { + return await current; + } finally { + if (locks.get(key) === current) locks.delete(key); + } + } + + function hasIssuedDaemonCredential(hostId: string): boolean { + return ( + deps.db + .select({ id: authApiKeys.id }) + .from(authApiKeys) + .where( + and( + eq(authApiKeys.configId, "daemon-host"), + eq(authApiKeys.enabled, true), + sql`json_extract(${authApiKeys.metadata}, '$.hostId') = ${hostId}`, + ), + ) + .limit(1) + .get() !== undefined + ); + } + + async function hasUnusedEnrollmentCredential( + hostId: string, + credential: string, + now: number, + ): Promise { + const hashedCredential = await defaultKeyHasher(credential); + return ( + deps.db + .select({ id: authApiKeys.id }) + .from(authApiKeys) + .where( + and( + eq(authApiKeys.configId, "daemon-enroll"), + eq(authApiKeys.key, hashedCredential), + eq(authApiKeys.enabled, true), + gt(authApiKeys.remaining, 0), + gt(authApiKeys.expiresAt, new Date(now)), + sql`json_extract(${authApiKeys.metadata}, '$.hostId') = ${hostId}`, + ), + ) + .limit(1) + .get() !== undefined + ); + } + + function scoped(owner: string): MachineEnrollments { + function hostForId(id: string) { + const host = getHost(deps.db, id); + const provider = + host?.machineProviderId === null + ? undefined + : getMachineProvider(host?.machineProviderId ?? ""); + if (!host || provider?.pluginId !== owner) + throw new Error("Machine enrollment was not found"); + return host; + } + return { + clearPending(key) { + for (const [hostId, entry] of pending) { + if (entry.owner === owner && entry.launchKey === key) + pending.delete(hostId); + } + }, + async prepare(request) { + if (!request.key.trim()) + throw new Error("Machine enrollment key must not be empty"); + const lockKey = JSON.stringify([owner, request.key]); + return serialized(lockKey, async () => { + const host = getNonDestroyedHostByLaunchKey(deps.db, request.key); + if (!host) throw new Error("Machine creation host was not found"); + if (host.phase === "destroyed" || host.phase === "removing") + throw new Error("Machine enrollment was cancelled"); + if ( + getMachineProvider(host.machineProviderId ?? "")?.pluginId !== owner + ) + throw new Error("Machine creation belongs to a different plugin"); + if ( + request.access && + host.serverAccessProviderId && + request.access.providerId !== host.serverAccessProviderId + ) + throw new Error( + "Machine enrollment already uses a different server access provider", + ); + if (host.lastSeenAt !== null || deps.isConnected(host.id)) { + pending.delete(host.id); + return { id: host.id, hostId: host.id, state: "enrolled" }; + } + const grant = await deps.serverAccess.resolve({ + key: lockKey, + hostId: host.id, + access: request.access, + signal: AbortSignal.timeout(60_000), + }); + await deps.machineAuth.revokeHostEnrollKeys({ hostId: host.id }); + const credential = await deps.machineAuth.issueHostEnrollKey({ + hostId: host.id, + enrollSource: "public-multi-machine", + }); + const expiresAt = credential.expiresAt; + const result: Extract = { + id: host.id, + hostId: host.id, + state: "pending", + bootstrap: { + hostId: host.id, + serverUrl: grant.serverUrl, + ...(grant.headers === undefined + ? {} + : { headers: grant.headers }), + credential: credential.key, + expiresAt, + }, + }; + pending.set(host.id, { + owner, + launchKey: request.key, + bootstrap: result.bootstrap, + }); + return result; + }); + }, + async waitForConnection({ enrollmentId, timeoutMs, signal }) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) + throw new Error("Connection timeout must be positive"); + const deadline = Date.now() + timeoutMs; + while (true) { + signal.throwIfAborted(); + const host = hostForId(enrollmentId); + if (host.destroyedAt !== null || host.phase === "removing") + throw new Error("Machine enrollment was cancelled"); + if (deps.isConnected(host.id)) { + pending.delete(host.id); + return { hostId: host.id }; + } + const remaining = deadline - Date.now(); + if (remaining <= 0) + throw new Error("Timed out waiting for machine connection"); + await delay(Math.min(250, remaining), undefined, { signal }); + } + }, + }; + } + async function pendingBootstrapForHost(request: { + hostId: string; + owner: string; + }): Promise { + const host = getHost(deps.db, request.hostId); + const entry = pending.get(request.hostId); + if ( + !host || + host.phase !== "creating" || + host.destroyedAt !== null || + !entry || + entry.owner !== request.owner || + entry.bootstrap.expiresAt <= Date.now() || + deps.isConnected(host.id) || + hasIssuedDaemonCredential(host.id) + ) + return null; + const bootstrap = entry.bootstrap; + if ( + !(await hasUnusedEnrollmentCredential( + host.id, + bootstrap.credential, + Date.now(), + )) + ) + return null; + if (pending.get(request.hostId) !== entry) return null; + return bootstrap; + } + return { + forOwner: scoped, + pendingBootstrapForHost, + async pendingBootstrapForCredential( + credential: string, + ): Promise { + if (!credential || credential.length > 512) return null; + const row = [...pending].find( + ([, entry]) => entry.bootstrap.credential === credential, + ); + if (!row) return null; + const [hostId, entry] = row; + const bootstrap = await pendingBootstrapForHost({ + hostId, + owner: entry.owner, + }); + return bootstrap?.credential === credential ? bootstrap : null; + }, + }; +} diff --git a/apps/server/src/services/machines/environment-settings.ts b/apps/server/src/services/machines/environment-settings.ts new file mode 100644 index 0000000000..b323019cc0 --- /dev/null +++ b/apps/server/src/services/machines/environment-settings.ts @@ -0,0 +1,65 @@ +import { machineGitHealth } from "./git-credentials.js"; +import { getAppSettings, type DbConnection } from "@bb/db"; +import { + readMachineEnvironment, + decryptMachineEnvironment, +} from "./environment-storage.js"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; + +export { + replaceMachineEnvironment, + updateMachineEnvironment, +} from "./environment-storage.js"; + +export async function resolveUserMachineEnvironment( + db: DbConnection, + dataDir: string, +): Promise { + const rows = readMachineEnvironment(db); + return Promise.all( + rows.map(async (row) => ({ + name: row.name, + value: await decryptMachineEnvironment(dataDir, row), + reason: row.note ?? "Machine environment setting", + source: { core: "machine-environment" }, + })), + ); +} + +export async function machineEnvironmentView( + db: DbConnection, + dataDir: string, +) { + const variables = readMachineEnvironment(db).map((row) => ({ + name: row.name, + value: null, + secret: true as const, + note: row.note, + })); + const overridden = variables.some((row) => row.name === "GH_TOKEN"); + const enabled = getAppSettings(db).machineGitCredentialsEnabled; + const health = + overridden || !enabled + ? { + status: "ready", + statusMessage: + "The built-in gh token is overridden by Machine environment.", + } + : await machineGitHealth(); + return { + variables, + builtInGit: { + status: overridden + ? ("overridden" as const) + : !enabled + ? ("disabled" as const) + : health.status === "ready" + ? ("logged in" as const) + : ("not logged in" as const), + statusMessage: + !enabled && !overridden + ? "Automatic GitHub credentials are disabled." + : health.statusMessage, + }, + }; +} diff --git a/apps/server/src/services/machines/environment-storage.test.ts b/apps/server/src/services/machines/environment-storage.test.ts new file mode 100644 index 0000000000..2ccf2aecbc --- /dev/null +++ b/apps/server/src/services/machines/environment-storage.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { appSettingsValues, createConnection, migrate } from "@bb/db"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { + decryptMachineEnvironment, + readMachineEnvironment, + replaceMachineEnvironment, + updateMachineEnvironment, +} from "./environment-storage.js"; + +let db: ReturnType; +let dataDir: string; +beforeEach(async () => { + db = createConnection(":memory:"); + migrate(db); + dataDir = await mkdtemp(join(tmpdir(), "bb-env-encryption-")); +}); +afterEach(async () => { + db.$client.close(); + await rm(dataDir, { recursive: true, force: true }); +}); +it("stores encrypted values that survive a database reopen", async () => { + await replaceMachineEnvironment(db, dataDir, { + variables: ["REGION", "TOKEN"].map((name) => ({ + name, + value: "private-" + name, + note: null, + })), + }); + const rows = readMachineEnvironment(db); + const persisted = db.select().from(appSettingsValues).all(); + expect(JSON.stringify(persisted)).not.toContain("private-"); + expect( + (await stat(join(dataDir, "machine-environment-key"))).mode & 0o777, + ).toBe(0o600); + db.$client.close(); + db = createConnection(":memory:"); + migrate(db); + db.insert(appSettingsValues).values(persisted).run(); + expect(readMachineEnvironment(db)).toEqual(rows); + expect(await decryptMachineEnvironment(dataDir, rows[1]!)).toBe( + "private-TOKEN", + ); +}); + +it("replaces the whole list while retaining unchanged ciphertext", async () => { + await replaceMachineEnvironment(db, dataDir, { + variables: [ + { name: "REMOVE", value: "old", note: null }, + { name: "TOKEN", value: "private", note: null }, + ], + }); + const token = readMachineEnvironment(db).find((row) => row.name === "TOKEN"); + await replaceMachineEnvironment(db, dataDir, { + variables: [ + { name: "TOKEN", value: null, note: "Retained" }, + { name: "ADDED", value: "new", note: null }, + ], + }); + const rows = readMachineEnvironment(db); + expect(rows.map((row) => row.name)).toEqual(["ADDED", "TOKEN"]); + expect(rows[1]).toEqual({ ...token, note: "Retained" }); + expect(await decryptMachineEnvironment(dataDir, rows[1]!)).toBe("private"); +}); + +it("authenticates ciphertext and its variable name", async () => { + await updateMachineEnvironment(db, dataDir, "TOKEN", { + name: "TOKEN", + value: "private", + note: null, + }); + const [row] = readMachineEnvironment(db); + await expect( + decryptMachineEnvironment(dataDir, { ...row!, name: "OTHER" }), + ).rejects.toThrow("cannot be decrypted"); + const bytes = Buffer.from(row!.ciphertext, "base64"); + bytes[28] = bytes[28]! ^ 1; + await expect( + decryptMachineEnvironment(dataDir, { + ...row!, + ciphertext: bytes.toString("base64"), + }), + ).rejects.toThrow("cannot be decrypted"); +}); + +it("does not replace a missing encryption key or overwrite existing ciphertext", async () => { + await updateMachineEnvironment(db, dataDir, "TOKEN", { + name: "TOKEN", + value: "private", + note: null, + }); + const before = db.select().from(appSettingsValues).all(); + await rm(join(dataDir, "machine-environment-key")); + await expect( + updateMachineEnvironment(db, dataDir, "OTHER", { + name: "OTHER", + value: "new", + note: null, + }), + ).rejects.toThrow("encryption key is unavailable"); + expect(db.select().from(appSettingsValues).all()).toEqual(before); + await expect( + readFile(join(dataDir, "machine-environment-key")), + ).rejects.toMatchObject({ code: "ENOENT" }); +}); + +it("serializes replacement and removal without resurrecting values", async () => { + await updateMachineEnvironment(db, dataDir, "TOKEN", { + name: "TOKEN", + value: "old", + note: null, + }); + await Promise.all([ + readMachineEnvironment(db), + updateMachineEnvironment(db, dataDir, "TOKEN", { + name: "TOKEN", + value: "new", + note: null, + }), + updateMachineEnvironment(db, dataDir, "TOKEN", null), + ]); + expect(readMachineEnvironment(db)).toEqual([]); +}); diff --git a/apps/server/src/services/machines/environment-storage.ts b/apps/server/src/services/machines/environment-storage.ts new file mode 100644 index 0000000000..42bf7409b4 --- /dev/null +++ b/apps/server/src/services/machines/environment-storage.ts @@ -0,0 +1,187 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { like } from "drizzle-orm"; +import { z } from "zod"; +import { + appSettingsValues, + type DbConnection, + type DbQueryConnection, +} from "@bb/db"; +import { readOrCreateSecretFile } from "@bb/secret-storage"; +import { + machineEnvironmentNameSchema, + type MachineEnvironmentReplace, + type MachineEnvironmentSet, +} from "@bb/server-contract"; + +const prefix = "machineEnvironment:"; +const keyFile = "machine-environment-key"; +const encryptedSchema = z + .object({ + version: z.literal(1), + name: machineEnvironmentNameSchema, + ciphertext: z.string(), + note: z.string().nullable(), + }) + .strict(); +type EncryptedVariable = z.infer; +const locks = new WeakMap>(); + +async function serialized( + db: DbConnection, + operation: () => Promise, +): Promise { + const previous = locks.get(db) ?? Promise.resolve(); + const current = previous.catch(() => {}).then(operation); + locks.set(db, current); + try { + return await current; + } finally { + if (locks.get(db) === current) locks.delete(db); + } +} + +function records(db: DbConnection) { + return db + .select() + .from(appSettingsValues) + .where(like(appSettingsValues.key, `${prefix}%`)) + .orderBy(appSettingsValues.key) + .all(); +} + +async function encryptionKey(dataDir: string, allowCreate: boolean) { + try { + const value = allowCreate + ? await readOrCreateSecretFile({ + dataDir, + fileName: keyFile, + bytes: 32, + encoding: "hex", + }) + : (await readFile(join(dataDir, keyFile), "utf8")).trim(); + if (!/^[a-f0-9]{64}$/u.test(value)) throw new Error("Invalid key"); + return Buffer.from(value, "hex"); + } catch { + throw new Error( + "Machine environment encryption key is unavailable; restore it from backup.", + ); + } +} + +function encrypt(key: Buffer, input: MachineEnvironmentSet): EncryptedVariable { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(Buffer.from(input.name)); + const encrypted = Buffer.concat([ + cipher.update(input.value, "utf8"), + cipher.final(), + ]); + return { + version: 1, + name: input.name, + ciphertext: Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString( + "base64", + ), + note: input.note, + }; +} + +export async function decryptMachineEnvironment( + dataDir: string, + row: EncryptedVariable, +): Promise { + const key = await encryptionKey(dataDir, false); + try { + const encrypted = Buffer.from(row.ciphertext, "base64"); + const cipher = createDecipheriv( + "aes-256-gcm", + key, + encrypted.subarray(0, 12), + ); + cipher.setAAD(Buffer.from(row.name)); + cipher.setAuthTag(encrypted.subarray(12, 28)); + return Buffer.concat([ + cipher.update(encrypted.subarray(28)), + cipher.final(), + ]).toString("utf8"); + } catch { + throw new Error( + `Machine environment variable ${row.name} cannot be decrypted; restore its encryption key or set it again.`, + ); + } +} + +function save(db: DbQueryConnection, row: EncryptedVariable) { + const value = JSON.stringify(row); + const updatedAt = Date.now(); + db.insert(appSettingsValues) + .values({ key: prefix + row.name, value, updatedAt }) + .onConflictDoUpdate({ + target: appSettingsValues.key, + set: { value, updatedAt }, + }) + .run(); +} + +export function readMachineEnvironment(db: DbConnection): EncryptedVariable[] { + return records(db).map((row) => { + const parsed = encryptedSchema.parse(JSON.parse(row.value)); + if (row.key !== prefix + parsed.name) + throw new Error("Invalid machine environment record"); + return parsed; + }); +} + +async function replaceRows( + db: DbConnection, + dataDir: string, + variables: MachineEnvironmentReplace["variables"], +): Promise { + const current = readMachineEnvironment(db); + const currentByName = new Map(current.map((row) => [row.name, row])); + const needsEncryption = variables.some((variable) => variable.value !== null); + const key = needsEncryption + ? await encryptionKey(dataDir, current.length === 0) + : null; + const replacements = variables.flatMap((variable) => { + if (variable.value !== null) { + if (key === null) throw new Error("Missing machine environment key"); + return [encrypt(key, { ...variable, value: variable.value })]; + } + const existing = currentByName.get(variable.name); + return existing === undefined ? [] : [{ ...existing, note: variable.note }]; + }); + db.transaction((tx) => { + tx.delete(appSettingsValues) + .where(like(appSettingsValues.key, `${prefix}%`)) + .run(); + for (const row of replacements) save(tx, row); + }); +} + +export function replaceMachineEnvironment( + db: DbConnection, + dataDir: string, + input: MachineEnvironmentReplace, +): Promise { + return serialized(db, () => replaceRows(db, dataDir, input.variables)); +} + +export function updateMachineEnvironment( + db: DbConnection, + dataDir: string, + name: string, + input: MachineEnvironmentSet | null, +): Promise { + name = machineEnvironmentNameSchema.parse(name); + return serialized(db, async () => { + const variables: MachineEnvironmentReplace["variables"] = + readMachineEnvironment(db) + .filter((row) => row.name !== name) + .map((row) => ({ name: row.name, value: null, note: row.note })); + if (input !== null) variables.push({ ...input, name }); + await replaceRows(db, dataDir, variables); + }); +} diff --git a/apps/server/src/services/machines/git-credentials.test.ts b/apps/server/src/services/machines/git-credentials.test.ts new file mode 100644 index 0000000000..f6a73b656c --- /dev/null +++ b/apps/server/src/services/machines/git-credentials.test.ts @@ -0,0 +1,188 @@ +import { mergeHostAndProviderEnvironment } from "../hosts/host-environment.js"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveGitCredentials, machineGitHealth } from "./git-credentials.js"; + +const exec = promisify(execFile); +const cleanup: Array<() => Promise> = []; +afterEach(async () => { + for (const dispose of cleanup.splice(0)) await dispose(); +}); + +function gh(email: string | null = null) { + return vi.fn(async (args: string[]) => + args[0] === "auth" + ? "test-private-token\n" + : JSON.stringify({ login: "octocat", id: 123, email }), + ); +} + +async function gitEnv() { + const home = await mkdtemp(join(tmpdir(), "bb-git-env-")); + cleanup.push(() => rm(home, { recursive: true, force: true })); + const entries = await resolveGitCredentials(gh()); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: join(home, "gitconfig"), + GIT_TERMINAL_PROMPT: "0", + }; + for (const entry of entries) { + if (typeof entry.value !== "string") throw new Error("Unresolved entry"); + env[entry.name] = entry.value; + } + return { home, env }; +} + +function fill( + env: NodeJS.ProcessEnv, + input: string, +): Promise<{ code: number | null; stdout: string }> { + return new Promise((resolve, reject) => { + const child = spawn("git", ["credential", "fill"], { env }); + let stdout = ""; + child.stdout.on("data", (value: Buffer) => { + stdout += value.toString(); + }); + child.stderr.resume(); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout })); + child.stdin.end(input); + }); +} + +describe("machine Git environment", () => { + it("lets agent-provider contributions override host credentials", async () => { + const host = await resolveGitCredentials(gh()); + const provider = [ + { + name: "GH_TOKEN", + value: "provider-token", + source: { plugin: "provider" }, + reason: "Override", + }, + ]; + const merged = mergeHostAndProviderEnvironment(host, provider); + expect(merged.filter((entry) => entry.name === "GH_TOKEN")).toEqual( + provider, + ); + expect(merged).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "GIT_CONFIG_COUNT" }), + ]), + ); + }); + + it("derives login identity and public or noreply email", async () => { + for (const email of [null, "public@example.com"]) { + const entries = await resolveGitCredentials(gh(email)); + const env = Object.fromEntries( + entries.map((entry) => [entry.name, entry.value]), + ); + expect(env.GIT_AUTHOR_NAME).toBe("octocat"); + expect(env.GIT_COMMITTER_NAME).toBe("octocat"); + expect(env.GIT_AUTHOR_EMAIL).toBe( + email ?? "123+octocat@users.noreply.github.com", + ); + expect(env.GIT_COMMITTER_EMAIL).toBe(env.GIT_AUTHOR_EMAIL); + expect( + JSON.stringify(entries.filter((entry) => entry.name !== "GH_TOKEN")), + ).not.toContain("test-private-token"); + } + }); + + it("returns no credentials and safe health for gh failure or malformed identity", async () => { + for (const run of [ + async () => { + throw new Error("private-token-in-stderr"); + }, + async () => "invalid-json", + ]) { + expect(await resolveGitCredentials(run)).toEqual([]); + expect(await machineGitHealth(run)).toEqual({ + status: "not configured", + statusMessage: "gh is not logged in on the server", + }); + } + }); + + it("expands GH_TOKEN when Git invokes the helper, and only for github.com HTTPS", async () => { + const { env } = await gitEnv(); + env.GH_TOKEN = "rotated-token"; + const result = await fill(env, "protocol=https\nhost=github.com\n\n"); + expect(result.code).toBe(0); + expect(result.stdout).toContain( + "username=x-access-token\npassword=rotated-token\n", + ); + for (const input of [ + "protocol=https\nhost=github.com.attacker.example\n\n", + "protocol=http\nhost=github.com\n\n", + ]) { + const rejected = await fill(env, input); + expect(rejected.code).not.toBe(0); + expect(rejected.stdout).not.toContain("rotated-token"); + } + }); + + it("clones a local bare repo through a fake HTTPS helper that requests Git credentials", async () => { + const { env, home } = await gitEnv(); + const source = join(home, "source"); + const bare = join(home, "private.git"); + const helpers = join(home, "helpers"); + await mkdir(helpers); + await exec("git", ["init", source], { env }); + await writeFile(join(source, "gate.txt"), "private clone succeeded"); + await exec("git", ["add", "."], { cwd: source, env }); + await exec("git", ["commit", "-m", "seed"], { cwd: source, env }); + await exec("git", ["clone", "--bare", source, bare], { env }); + const helper = `#!/usr/bin/env python3 +import os, subprocess, sys +assert sys.argv[2] == "https://github.com/octocat/private.git" +auth = subprocess.run(["git", "credential", "fill"], input="protocol=https\\nhost=github.com\\n\\n", text=True, capture_output=True, check=True).stdout +assert "username=x-access-token\\n" in auth +assert "password=" + os.environ["GH_TOKEN"] + "\\n" in auth +for line in sys.stdin: + if line.strip() == "capabilities": + print("connect\\n", flush=True) + elif line.startswith("connect "): + print("", flush=True) + os.execlp("git", "git", "upload-pack", os.environ["FAKE_BARE"]) +`; + await writeFile(join(helpers, "git-remote-https"), helper, { mode: 0o755 }); + const target = join(home, "cloned"); + await exec("git", ["clone", "git@github.com:octocat/private.git", target], { + env: { ...env, GIT_EXEC_PATH: helpers, FAKE_BARE: bare }, + }); + expect(await readFile(join(target, "gate.txt"), "utf8")).toBe( + "private clone succeeded", + ); + }); + + it("rewrites both SSH forms without storing any Git configuration", async () => { + const { env, home } = await gitEnv(); + await exec("git", ["init", home], { env }); + for (const remote of [ + "git@github.com:octocat/private.git", + "ssh://git@github.com/octocat/private.git", + ]) { + const result = await exec("git", ["ls-remote", "--get-url", remote], { + env, + cwd: home, + }); + expect(result.stdout.trim()).toBe( + "https://github.com/octocat/private.git", + ); + } + const result = await exec("git", ["config", "--local", "--list"], { + env, + cwd: home, + }); + expect(result.stdout).not.toContain("credential"); + expect(result.stdout).not.toContain("test-private-token"); + }); +}); diff --git a/apps/server/src/services/machines/git-credentials.ts b/apps/server/src/services/machines/git-credentials.ts new file mode 100644 index 0000000000..fde4e16444 --- /dev/null +++ b/apps/server/src/services/machines/git-credentials.ts @@ -0,0 +1,92 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; +import { z } from "zod"; + +const exec = promisify(execFile); + +const githubCredentialHelper = + '!f() { test "$1" = get || exit 0; protocol=; host=; while IFS= read -r line && test -n "$line"; do case "$line" in protocol=*) protocol=${line#protocol=} ;; host=*) host=${line#host=} ;; esac; done; if test "$protocol" = https && test "$host" = github.com && test -n "$GH_TOKEN"; then printf "username=x-access-token\\npassword=%s\\n" "$GH_TOKEN"; fi; }; f'; + +const gitConfig = [ + ["credential.helper", ""], + ["credential.helper", githubCredentialHelper], + ["url.https://github.com/.insteadOf", "git@github.com:"], + ["url.https://github.com/.insteadOf", "ssh://git@github.com/"], +] as const; +const identitySchema = z.object({ + login: z.string().regex(/^[a-zA-Z0-9-]+$/u), + id: z.number().int().positive(), + email: z.email().nullable(), +}); + +async function runGh(args: string[]): Promise { + const { stdout } = await exec("gh", args, { + timeout: 15_000, + maxBuffer: 1024 * 1024, + }); + return stdout; +} + +export function githubGitConfiguration(): HostDaemonContributedEnvEntry[] { + const configEnv: Record = { + GIT_CONFIG_COUNT: String(gitConfig.length), + }; + gitConfig.forEach(([key, value], index) => { + configEnv[`GIT_CONFIG_KEY_${index}`] = key; + configEnv[`GIT_CONFIG_VALUE_${index}`] = value; + }); + return Object.entries(configEnv).map( + ([name, value]) => ({ + name, + value, + source: { core: "machine-git" }, + reason: "GitHub HTTPS authentication", + }), + ); +} + +export async function resolveGitCredentials( + run = runGh, +): Promise { + try { + const token = z + .string() + .trim() + .min(1) + .regex(/^[^\s\x00]+$/u) + .parse(await run(["auth", "token", "--hostname", "github.com"])); + const user = identitySchema.parse( + JSON.parse(await run(["api", "--hostname", "github.com", "user"])), + ); + const email = + user.email ?? `${user.id}+${user.login}@users.noreply.github.com`; + return [ + ...githubGitConfiguration(), + ...Object.entries({ + GH_TOKEN: token, + GIT_AUTHOR_NAME: user.login, + GIT_AUTHOR_EMAIL: email, + GIT_COMMITTER_NAME: user.login, + GIT_COMMITTER_EMAIL: email, + }).map(([name, value]) => ({ + name, + value, + source: { core: "machine-git" }, + reason: "GitHub credentials from the server gh login", + })), + ]; + } catch { + return []; + } +} + +export async function machineGitHealth(run = runGh) { + const entries = await resolveGitCredentials(run); + return { + status: entries.length ? ("ready" as const) : ("not configured" as const), + statusMessage: entries.length + ? "Generated using gh auth token --hostname github.com." + : "gh is not logged in on the server", + }; +} diff --git a/apps/server/src/services/machines/lifecycle.ts b/apps/server/src/services/machines/lifecycle.ts new file mode 100644 index 0000000000..a96abd82e1 --- /dev/null +++ b/apps/server/src/services/machines/lifecycle.ts @@ -0,0 +1,250 @@ +import { cancelPendingEnvironmentHook } from "../environments/environment-hooks.js"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { + environmentHookOperations, + environments, + getHost, + terminalSessions, + threads, + updateHost, +} from "@bb/db"; +import type { + WorkSessionDeps, + LoggedPendingInteractionWorkSessionDeps, +} from "../../types.js"; +import { ApiError } from "../../errors.js"; + +import { appendSystemErrorEvent } from "../threads/thread-events.js"; +import { threadScope } from "@bb/domain"; +import { stopThreadForCurrentState } from "../threads/thread-lifecycle.js"; + +const RETRY_MS = 10_000; +const preparingPauses = new WeakMap< + WorkSessionDeps["db"], + Map +>(); + +type Deps = Pick; + +export function assertMachineLifecycleAdmission( + deps: Deps, + hostId: string, +): void { + const host = getHost(deps.db, hostId); + if (host?.phase === "suspending") + throw new ApiError( + 409, + "machine_maintenance", + host.statusMessage ?? + "Machine is preserving its filesystem; dispatch will wait", + ); +} + +export function cancelPreparingMachinePause( + deps: Pick, + hostId: string, +): void { + const pending = preparingPauses.get(deps.db)?.get(hostId); + if (pending !== undefined) pending.cancelled = true; +} + +export function isMachineWaitingForExecution( + deps: Pick, + hostId: string, +): boolean { + const host = getHost(deps.db, hostId); + return ( + host?.suspendedAt != null || + host?.phase === "suspending" || + host?.phase === "suspended" + ); +} + +export async function maintainMachine( + deps: LoggedPendingInteractionWorkSessionDeps, + hostId: string, + operationId: string, + save: () => Promise, +): Promise { + const host = getHost(deps.db, hostId); + if (host === null) + throw new ApiError(404, "host_not_found", "Host not found"); + if (host.suspendRetryAt !== null && host.suspendRetryAt > Date.now()) + throw new ApiError( + 409, + "machine_maintenance", + "Machine already has a lifecycle operation or is waiting for retry.", + ); + const pending = { cancelled: false }; + const pauses = + preparingPauses.get(deps.db) ?? new Map(); + preparingPauses.set(deps.db, pauses); + pauses.set(hostId, pending); + const originalPhase = host.phase; + updateHost(deps.db, deps.hub, hostId, { + phase: "suspending", + machineOperationId: operationId, + statusMessage: + "Preserving this machine. Active turns will be interrupted and open terminals closed before the filesystem is saved.", + suspendRetryAt: null, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + try { + await boundedDrain(async () => { + const hooks = deps.db + .select({ id: environmentHookOperations.id }) + .from(environmentHookOperations) + .where( + and( + eq(environmentHookOperations.hostId, hostId), + isNull(environmentHookOperations.finishedAt), + ), + ) + .all(); + await Promise.all( + hooks.map((hook) => + cancelPendingEnvironmentHook(deps, { id: hook.id, hostId }), + ), + ); + const active = deps.db + .select({ + id: threads.id, + status: threads.status, + environmentId: threads.environmentId, + }) + .from(threads) + .innerJoin(environments, eq(threads.environmentId, environments.id)) + .where( + and( + eq(environments.hostId, hostId), + inArray(threads.status, ["active", "stopping"]), + ), + ) + .all(); + for (const thread of active) + appendSystemErrorEvent(deps, { + threadId: thread.id, + environmentId: thread.environmentId, + scope: threadScope(), + code: "machine_maintenance", + message: + "Machine preservation is interrupting this turn. Its result is not a successful completion. Continue with a new turn after the machine resumes.", + }); + await Promise.all( + active.map((thread) => + stopThreadForCurrentState( + deps, + thread, + thread.environmentId === null + ? null + : { id: thread.environmentId, hostId }, + { requireStopped: true }, + ), + ), + ); + const stillActive = deps.db + .select({ id: threads.id }) + .from(threads) + .innerJoin(environments, eq(threads.environmentId, environments.id)) + .where( + and( + eq(environments.hostId, hostId), + inArray(threads.status, ["active", "stopping"]), + ), + ) + .limit(1) + .get(); + if (stillActive !== undefined) + throw new Error( + "A turn did not stop; refusing to save or terminate live writers", + ); + const terminals = deps.db + .select({ id: terminalSessions.id }) + .from(terminalSessions) + .where( + and( + eq(terminalSessions.hostId, hostId), + inArray(terminalSessions.status, [ + "starting", + "running", + "disconnected", + ]), + ), + ) + .all(); + await Promise.all( + terminals.map((terminal) => + deps.terminalSessions.closeTerminal({ + terminalId: terminal.id, + payload: { mode: "force", reason: "user" }, + }), + ), + ); + }, 5 * 60_000); + pauses.delete(hostId); + if (pending.cancelled) { + throw new ApiError( + 409, + "machine_pause_cancelled", + "Pause cancelled because a follow-up was sent.", + ); + } + updateHost(deps.db, deps.hub, hostId, { + statusMessage: "Saving the filesystem before terminating compute.", + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + await save(); + updateHost(deps.db, deps.hub, hostId, { + statusMessage: null, + suspendRetryAt: null, + }); + } catch (error) { + const cancelled = + error instanceof ApiError && + error.body.code === "machine_pause_cancelled"; + const message = error instanceof Error ? error.message : String(error); + const current = getHost(deps.db, hostId); + const phase = + current?.phase === "removing" + ? current.phase + : current !== null && current.suspendedAt !== null + ? "suspended" + : originalPhase === "suspending" + ? "active" + : originalPhase; + updateHost(deps.db, deps.hub, hostId, { + ...(phase === undefined ? {} : { phase }), + statusMessage: cancelled ? null : `Machine suspension failed: ${message}`, + suspendRetryAt: cancelled ? null : Date.now() + RETRY_MS, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + throw error; + } finally { + pauses.delete(hostId); + } +} + +async function boundedDrain( + run: () => Promise, + timeoutMs: number, +): Promise { + let timer: ReturnType | undefined; + try { + await Promise.race([ + run(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error( + "Machine drain exceeded its deadline; old compute is retained", + ), + ), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +} diff --git a/apps/server/src/services/machines/machine-services.ts b/apps/server/src/services/machines/machine-services.ts new file mode 100644 index 0000000000..27c8b6d422 --- /dev/null +++ b/apps/server/src/services/machines/machine-services.ts @@ -0,0 +1,29 @@ +import type { DbConnection } from "@bb/db"; +import type { AppDeps } from "../../types.js"; +import { createMachineEnrollmentService } from "./enrollments.js"; +import { serverAccess } from "./server-access.js"; + +export type MachineEnrollmentService = ReturnType< + typeof createMachineEnrollmentService +>; + +const services = new WeakMap(); + +export function getMachineEnrollmentService( + deps: Pick, +): MachineEnrollmentService { + let service = services.get(deps.db); + if (!service) { + service = createMachineEnrollmentService({ + db: deps.db, + machineAuth: deps.machineAuth, + serverAccess: { + resolve: (request) => serverAccess.resolve(deps, request), + release: (request) => serverAccess.release(deps, request), + }, + isConnected: (hostId) => deps.hub.hasDaemonForHost(hostId), + }); + services.set(deps.db, service); + } + return service; +} diff --git a/apps/server/src/services/machines/manual-enrollment-command.test.ts b/apps/server/src/services/machines/manual-enrollment-command.test.ts new file mode 100644 index 0000000000..f0bc9c271a --- /dev/null +++ b/apps/server/src/services/machines/manual-enrollment-command.test.ts @@ -0,0 +1,33 @@ +import { spawnSync } from "node:child_process"; +import { expect, it } from "vitest"; +import { + enrolledInstallerScript, + manualEnrollmentCommand, +} from "./manual-enrollment-command.js"; +import type { EnrollmentBootstrap } from "./enrollments.js"; + +const bootstrap: EnrollmentBootstrap = { + hostId: "host_test", + credential: "short-lived-code", + serverUrl: "https://test.getbb.app", + expiresAt: Date.now() + 60_000, + headers: { "x-access": "private'$value" }, +}; + +it("passes the exact bootstrap and arguments to the installer without shell expansion", () => { + const script = enrolledInstallerScript( + 'printf "%s\\n%s\\n%s" "$1" "$2" "$BB_ENROLLMENT"', + bootstrap, + ); + const result = spawnSync("sh", ["-c", script], { encoding: "utf8" }); + expect(result.status).toBe(0); + expect(result.stdout).toBe( + `--bootstrap-env\nBB_ENROLLMENT\n${JSON.stringify(bootstrap)}`, + ); +}); + +it("builds the transient curl command from the enrollment bootstrap", () => { + expect(manualEnrollmentCommand(bootstrap)).toBe( + "curl -fsSL -H 'X-BB-Enrollment: short-lived-code' 'https://test.getbb.app/install.sh' | sh", + ); +}); diff --git a/apps/server/src/services/machines/manual-enrollment-command.ts b/apps/server/src/services/machines/manual-enrollment-command.ts new file mode 100644 index 0000000000..652ba7854e --- /dev/null +++ b/apps/server/src/services/machines/manual-enrollment-command.ts @@ -0,0 +1,20 @@ +import type { EnrollmentBootstrap } from "./enrollments.js"; + +function quote(value: string): string { + return "'" + value.replaceAll("'", "'\"'\"'") + "'"; +} + +export function manualEnrollmentCommand( + bootstrap: EnrollmentBootstrap, +): string { + const header = `X-BB-Enrollment: ${bootstrap.credential}`; + const installerUrl = new URL("/install.sh", bootstrap.serverUrl).href; + return `curl -fsSL -H ${quote(header)} ${quote(installerUrl)} | sh`; +} + +export function enrolledInstallerScript( + script: string, + bootstrap: EnrollmentBootstrap, +): string { + return `export BB_ENROLLMENT=${quote(JSON.stringify(bootstrap))}\nset -- --bootstrap-env BB_ENROLLMENT\n${script}`; +} diff --git a/apps/server/src/services/machines/manual-provider.ts b/apps/server/src/services/machines/manual-provider.ts new file mode 100644 index 0000000000..dd3a7f6d4f --- /dev/null +++ b/apps/server/src/services/machines/manual-provider.ts @@ -0,0 +1,100 @@ +import { validatePluginMachineProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import type { MachineBootstrapApi } from "@get-bb/plugin-sdk"; +import { createMachineBootstrapApi } from "./bootstrap.js"; +import type { MachineEnrollmentService } from "./machine-services.js"; +import { manualEnrollmentCommand } from "./manual-enrollment-command.js"; +import type { + PluginMachineProviderBridge, + PluginMachineProviderRecord, +} from "../plugins/plugin-machine-provider-registry.js"; + +const MANUAL_PROVIDER_OWNER = "core"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createManualMachineProviderRecord( + machines: MachineBootstrapApi, +): PluginMachineProviderRecord { + return { + pluginId: MANUAL_PROVIDER_OWNER, + provider: validatePluginMachineProviderDeclaration({ + id: "manual", + displayName: "Manual machine setup", + description: + "Run one command on a machine you already have to connect it to this server.", + icon: "Terminal", + async create(context) { + context.signal.throwIfAborted(); + const resource = { key: context.key }; + await context.checkpoint(resource); + const { hostId } = await machines.bootstrap({ + key: context.key, + report: context.report, + signal: context.signal, + }); + context.report.step("Machine connected"); + return { + status: "created", + name: `Manual machine ${hostId.replace(/[^a-z0-9]/giu, "").slice(-6)}`, + resource, + }; + }, + async reconcileCleanup() { + return { status: "removed" }; + }, + async remove(context) { + context.report.step( + `Uninstall the machine service with its original installer: install-machine.sh --uninstall --host-id ${context.hostId}`, + ); + return { status: "removed" }; + }, + }), + }; +} + +export function withManualMachineProvider( + bridge: PluginMachineProviderBridge, + enrollments: MachineEnrollmentService, +): PluginMachineProviderBridge { + const manual = createManualMachineProviderRecord( + createMachineBootstrapApi(enrollments.forOwner(MANUAL_PROVIDER_OWNER)), + ); + return { + decisionTimeoutMs: bridge.decisionTimeoutMs, + listMachineProviders: () => [ + manual, + ...bridge + .listMachineProviders() + .filter((record) => record.provider.id !== manual.provider.id), + ], + getMachineProvider: (id) => + id === manual.provider.id ? manual : bridge.getMachineProvider(id), + async invokeProvider(pluginId, label, run) { + if (pluginId !== MANUAL_PROVIDER_OWNER) + return bridge.invokeProvider(pluginId, label, run); + try { + return { ok: true, value: await run() }; + } catch (error) { + return { ok: false, error: errorMessage(error) }; + } + }, + }; +} + +export async function manualHostCommand( + enrollments: MachineEnrollmentService, + hostId: string, +): Promise<{ command: string; expiresAt: number } | null> { + const bootstrap = await enrollments.pendingBootstrapForHost({ + hostId, + owner: MANUAL_PROVIDER_OWNER, + }); + return bootstrap === null + ? null + : { + command: manualEnrollmentCommand(bootstrap), + expiresAt: bootstrap.expiresAt, + }; +} diff --git a/apps/server/src/services/machines/provider-availability.ts b/apps/server/src/services/machines/provider-availability.ts new file mode 100644 index 0000000000..406d744a83 --- /dev/null +++ b/apps/server/src/services/machines/provider-availability.ts @@ -0,0 +1,85 @@ +import { jsonValueSchema } from "@bb/domain"; +import { z } from "zod"; +import { decideWithinBox } from "../threads/dispatch-hooks.js"; +import { + invokeMachineProvider, + machineProviderDecisionTimeoutMs, + type PluginMachineProviderRecord, +} from "../plugins/plugin-machine-provider-registry.js"; + +const availabilitySchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("available") }).strict(), + z + .object({ + status: z.literal("setup-required"), + message: z.string().min(1).max(500), + }) + .strict(), + z + .object({ + status: z.literal("unavailable"), + message: z.string().min(1).max(500), + }) + .strict(), +]); + +const emptyInputsCache = new WeakMap< + PluginMachineProviderRecord["provider"], + Promise +>(); + +export function machineProviderAcceptsEmptyInputs( + record: PluginMachineProviderRecord, +): Promise { + const cached = emptyInputsCache.get(record.provider); + if (cached !== undefined) return cached; + const resolved = resolveEmptyInputs(record); + emptyInputsCache.set(record.provider, resolved); + return resolved; +} + +async function resolveEmptyInputs( + record: PluginMachineProviderRecord, +): Promise { + const schema = record.provider.inputs; + if (schema === null) return true; + const invocation = await invokeMachineProvider( + record, + `"${record.provider.id}" machine provider empty inputs`, + async () => schema["~standard"].validate({}), + ); + if (!invocation.ok || invocation.value.issues !== undefined) return false; + return jsonValueSchema.safeParse(invocation.value.value).success; +} + +export async function machineProviderUnavailableReason( + record: PluginMachineProviderRecord, +): Promise { + const availability = record.provider.availability; + if (availability === null) return null; + const invocation = await invokeMachineProvider( + record, + `"${record.provider.id}" machine provider availability`, + () => + decideWithinBox( + () => Promise.resolve(availability()), + machineProviderDecisionTimeoutMs(), + ), + ); + const failure = !invocation.ok + ? invocation.error + : invocation.value.ok + ? null + : invocation.value.error; + if (failure !== null) { + return `Plugin "${record.pluginId}" could not determine availability: ${failure}`; + } + if (!invocation.ok || !invocation.value.ok) { + return `Plugin "${record.pluginId}" could not determine availability.`; + } + const parsed = availabilitySchema.safeParse(invocation.value.value); + if (!parsed.success) { + return `Plugin "${record.pluginId}" returned an invalid availability result.`; + } + return parsed.data.status === "available" ? null : parsed.data.message; +} diff --git a/apps/server/src/services/machines/provider-orchestration.ts b/apps/server/src/services/machines/provider-orchestration.ts new file mode 100644 index 0000000000..732c01a9ce --- /dev/null +++ b/apps/server/src/services/machines/provider-orchestration.ts @@ -0,0 +1,1448 @@ +import { requestQueuedMachineReadiness } from "../threads/queued-message-dispatch.js"; +import { and, desc, eq } from "drizzle-orm"; +import { createHostId, hostDaemonSessions, hosts } from "@bb/db"; +import { handleHostRemoved } from "../../internal/session-owner-side-effects.js"; +import type { WorkSessionDeps } from "../../types.js"; +import { maintainMachine } from "./lifecycle.js"; +import { serverAccess } from "./server-access.js"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { + deleteProjectSource, + getHost, + getNonDestroyedHostByLaunchKey, + listEnvironments, + listProjectSourcesByHost, + listProviderMachines, + listThreadIdsWithHostOfflineQueueWaits, + markHostEnvironmentsDestroyed, + machineHasLiveThreadLaunch, + machineHasProvisioningEnvironment, + machineHasLiveThreads, + updateHost, +} from "@bb/db"; +import { jsonValueSchema, type Host, type JsonValue } from "@bb/domain"; +import type { + PluginMachineProviderCreateResult, + PluginMachineProviderProgress, +} from "@get-bb/plugin-sdk/machine-provider"; +import { summarizeStandardIssues } from "@get-bb/plugin-sdk/internal/host-policy"; +import { ApiError } from "../../errors.js"; +import type { ThreadProvisioningDeps } from "../threads/thread-provisioning-environment.js"; +import { decideWithinBox } from "../threads/dispatch-hooks.js"; +import { + getMachineProvider, + invokeMachineProvider, + listMachineProviders, + machineProviderDecisionTimeoutMs, + type PluginMachineProviderRecord, +} from "../plugins/plugin-machine-provider-registry.js"; +import { + requestEnvironmentRemoval, + sweepProviderEnvironment, +} from "../environments/environment-engine.js"; +import { hasPendingProjectSourceSetupOnHost } from "../projects/project-source-setup.js"; +import { machineProviderUnavailableReason } from "./provider-availability.js"; + +type Deps = ThreadProvisioningDeps; +type MachineLifecycleDeps = Pick; + +function expireMachineSessions(deps: Deps, hostId: string): void { + for (const session of deps.db + .select({ id: hostDaemonSessions.id }) + .from(hostDaemonSessions) + .where( + and( + eq(hostDaemonSessions.hostId, hostId), + eq(hostDaemonSessions.status, "active"), + ), + ) + .all()) { + handleHostRemoved(deps, { hostId, sessionId: session.id }); + } +} + +interface ActiveOperation { + controller: AbortController; + done: Promise; +} + +const resourceSchema = jsonValueSchema.refine( + (value) => Buffer.byteLength(JSON.stringify(value)) <= 16_384, + "Resource exceeds 16 KiB", +); +const createResultSchema = z.discriminatedUnion("status", [ + z + .object({ + status: z.literal("created"), + name: z.string().trim().min(1).max(200).optional(), + resource: resourceSchema, + }) + .strict(), + z + .object({ status: z.literal("failed"), message: z.string().min(1) }) + .strict(), +]); +const resourceResultSchema = z.object({ resource: resourceSchema }).strict(); +const removeResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("removed") }).strict(), + z + .object({ status: z.literal("failed"), message: z.string().min(1) }) + .strict(), +]); +const validateDecisionSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("accept") }).strict(), + z + .object({ + action: z.literal("refuse"), + message: z.string().min(1).max(500), + }) + .strict(), +]); + +const createOperations = new WeakMap>(); +const suspendOperations = new WeakMap>(); +const resumeOperations = new WeakMap>(); +const removeOperations = new WeakMap>(); +const machineSweepOperations = new WeakMap< + object, + Map +>(); + +const MACHINE_CREATE_FAILURE_CLEANUP_GRACE_MS = 30_000; + +function operations( + registry: WeakMap>, + db: Deps["db"], +): Map { + let map = registry.get(db); + if (map === undefined) { + map = new Map(); + registry.set(db, map); + } + return map; +} + +function runTrackedOperation(args: { + map: Map; + key: string; + run: (signal: AbortSignal) => Promise; +}): ActiveOperation { + const existing = args.map.get(args.key); + if (existing !== undefined) return existing; + const controller = new AbortController(); + const operation: ActiveOperation = { + controller, + done: Promise.resolve(), + }; + operation.done = args.run(controller.signal).finally(() => { + if (args.map.get(args.key) === operation) args.map.delete(args.key); + }); + args.map.set(args.key, operation); + return operation; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function deleteMachineProjectSources( + deps: MachineLifecycleDeps, + hostId: string, +): void { + for (const source of listProjectSourcesByHost(deps.db, hostId)) { + deleteProjectSource(deps.db, deps.hub, source.id); + } +} + +type MachineHostRow = NonNullable>; + +function createOwns( + current: ReturnType, + owner: MachineHostRow, +): current is MachineHostRow { + return ( + current !== null && + current.destroyedAt === null && + current.id === owner.id && + current.attempt === owner.attempt && + current.machineProviderId === owner.machineProviderId && + current.machineOperationId === owner.machineOperationId && + (current.phase === "creating" || current.phase === "removing") + ); +} + +function createReporter( + deps: Deps, + owner: MachineHostRow, +): PluginMachineProviderProgress { + return { + step: (text) => { + const current = getHost(deps.db, owner.id); + if (!createOwns(current, owner) || current.phase !== "creating") return; + updateHost(deps.db, deps.hub, owner.id, { + statusMessage: text.slice(0, 500), + }); + deps.hub.notifyHost(owner.id, ["host-connected"]); + }, + log: (text) => { + const current = getHost(deps.db, owner.id); + if (!createOwns(current, owner) || current.phase !== "creating") return; + updateHost(deps.db, deps.hub, owner.id, { + pendingLog: ( + current.pendingLog + (text.endsWith("\n") ? text : `${text}\n`) + ).slice(-16_384), + }); + deps.hub.notifyHost(owner.id, ["host-connected"]); + }, + }; +} + +function lifecycleReporter( + deps: MachineLifecycleDeps, + hostId: string, +): PluginMachineProviderProgress { + const owner = getHost(deps.db, hostId); + return { + step: (text) => { + const current = getHost(deps.db, hostId); + if ( + current === null || + current.destroyedAt !== null || + owner === null || + current.machineOperationId !== owner.machineOperationId || + current.machineProviderId !== owner.machineProviderId || + current.phase !== owner.phase + ) + return; + updateHost(deps.db, deps.hub, hostId, { + statusMessage: text.slice(0, 500), + }); + deps.hub.notifyHost(hostId, ["host-connected"]); + }, + log: (text) => { + deps.logger.info({ hostId }, text.slice(-16_384)); + }, + }; +} + +async function invokeCreate( + record: PluginMachineProviderRecord, + host: MachineHostRow, + deps: Deps, + signal: AbortSignal, +): Promise< + | { status: "created"; name?: string; resource: JsonValue } + | Extract +> { + const invocation = await invokeMachineProvider(record, "machine create", () => + record.provider.create({ + inputs: host.inputs, + key: host.launchKey!, + attempt: host.attempt, + checkpoint: async (resource) => { + const parsed = resourceSchema.parse(resource); + const current = getHost(deps.db, host.id); + if (!createOwns(current, host)) + throw new Error( + "Machine creation attempt no longer owns this resource", + ); + updateHost(deps.db, deps.hub, host.id, { resource: parsed }); + }, + report: createReporter(deps, host), + signal, + }), + ); + if (!invocation.ok) throw new Error(invocation.error); + return createResultSchema.parse(invocation.value); +} + +async function removeResource( + deps: Deps, + record: PluginMachineProviderRecord, + args: { hostId: string; resource: JsonValue; signal: AbortSignal }, +): Promise { + const invocation = await invokeMachineProvider(record, "machine remove", () => + record.provider.remove({ + hostId: args.hostId, + resource: args.resource, + report: lifecycleReporter(deps, args.hostId), + signal: args.signal, + }), + ); + if (!invocation.ok) throw new Error(invocation.error); + const result = removeResultSchema.parse(invocation.value); + if (result.status === "failed") throw new Error(result.message); +} + +async function runCreate( + deps: Deps, + record: PluginMachineProviderRecord, + host: MachineHostRow, + signal: AbortSignal, +): Promise { + try { + const result = await invokeCreate(record, host, deps, signal); + if (result.status === "failed") { + const current = getHost(deps.db, host.id); + if (createOwns(current, host)) { + updateHost(deps.db, deps.hub, host.id, { + phase: "removing", + removeRetryAt: Date.now() + MACHINE_CREATE_FAILURE_CLEANUP_GRACE_MS, + statusMessage: result.message, + teardownStatus: "failed", + }); + } + return; + } + const current = getHost(deps.db, host.id); + if (!createOwns(current, host)) { + await removeResource(deps, record, { + hostId: host.id, + resource: result.resource, + signal: new AbortController().signal, + }); + return; + } + if (current.phase === "removing") { + updateHost(deps.db, deps.hub, host.id, { + resource: result.resource, + removeRetryAt: Date.now(), + }); + return; + } + updateHost(deps.db, deps.hub, host.id, { + name: + result.name ?? + `${record.provider.displayName} ${host.id.replace(/[^a-z0-9]/giu, "").slice(-6)}`, + phase: "active", + machineOperationId: null, + resource: result.resource, + inputs: null, + pendingLog: "", + removeRetryAt: null, + suspendedAt: null, + teardownAttempt: 0, + statusMessage: null, + teardownStatus: null, + }); + deps.hub.notifyHost(host.id, ["host-connected"]); + } catch (error) { + const current = getHost(deps.db, host.id); + if (signal.aborted && current?.phase === "removing") return; + if (createOwns(current, host)) { + updateHost(deps.db, deps.hub, host.id, { + phase: "removing", + removeRetryAt: Date.now() + MACHINE_CREATE_FAILURE_CLEANUP_GRACE_MS, + statusMessage: `The "${record.provider.id}" machine provider (plugin "${record.pluginId}") failed: ${errorMessage(error)}`, + teardownStatus: "failed", + }); + } + } +} + +function startCreate( + deps: Deps, + record: PluginMachineProviderRecord, + host: MachineHostRow, +): ActiveOperation { + const operation = runTrackedOperation({ + map: operations(createOperations, deps.db), + key: host.id, + run: (signal) => runCreate(deps, record, host, signal), + }); + void operation.done + .then(async () => { + await sweepProviderMachine(deps, host.id); + }) + .catch((error: unknown) => { + deps.logger.warn( + { hostId: host.id, error: errorMessage(error) }, + "Machine creation cleanup failed", + ); + }); + return operation; +} + +export async function parseMachineProviderInputs( + record: PluginMachineProviderRecord, + inputs: JsonValue | null, +): Promise { + const schema = record.provider.inputs; + if (schema === null) { + if (inputs !== null) { + throw new ApiError( + 400, + "invalid_request", + `The "${record.provider.id}" machine provider takes no inputs, but the request carried some`, + ); + } + return null; + } + if (inputs === null) { + throw new ApiError( + 400, + "invalid_request", + `The "${record.provider.id}" machine provider needs inputs, and the request carried none`, + ); + } + const invocation = await invokeMachineProvider( + record, + `"${record.provider.id}" machine provider inputs`, + async () => schema["~standard"].validate(inputs), + ); + if (!invocation.ok) { + throw new ApiError( + 502, + "machine_provider_failed", + `The "${record.provider.id}" machine provider (plugin "${record.pluginId}") failed to validate its inputs: ${invocation.error}`, + ); + } + if (invocation.value.issues !== undefined) { + throw new ApiError( + 400, + "invalid_request", + `The "${record.provider.id}" machine provider refused the inputs: ${summarizeStandardIssues(invocation.value.issues)}`, + ); + } + const parsed = jsonValueSchema.safeParse(invocation.value.value); + if (!parsed.success) { + throw new ApiError( + 502, + "machine_provider_failed", + `The "${record.provider.id}" machine provider parsed its inputs into a value that is not JSON`, + ); + } + return parsed.data; +} + +export async function prepareMachineProviderSelection( + deps: Deps, + args: { + machineProviderId: string; + inputs: JsonValue | null; + }, +): Promise<{ record: PluginMachineProviderRecord; inputs: JsonValue | null }> { + const record = getMachineProvider(args.machineProviderId); + if (record === undefined) { + throw new ApiError( + 400, + "invalid_request", + `Unknown machine provider "${args.machineProviderId}"`, + ); + } + const unavailableReason = await machineProviderUnavailableReason(record); + if (unavailableReason !== null) { + throw new ApiError(409, "machine_provider_rejected", unavailableReason); + } + const inputs = await parseMachineProviderInputs(record, args.inputs); + if (record.provider.validate !== null) { + const invocation = await invokeMachineProvider( + record, + `"${record.provider.id}" machine provider validate`, + () => + decideWithinBox( + () => Promise.resolve(record.provider.validate?.({ inputs })), + machineProviderDecisionTimeoutMs(), + ), + ); + if (!invocation.ok) { + throw new ApiError( + 502, + "machine_provider_failed", + `The "${record.provider.id}" machine provider failed to validate the request: ${invocation.error}`, + ); + } + if (!invocation.value.ok) { + throw new ApiError( + 502, + "machine_provider_failed", + `The "${record.provider.id}" machine provider failed to validate the request: ${invocation.value.error}`, + ); + } + const decision = validateDecisionSchema.safeParse(invocation.value.value); + if (!decision.success) { + throw new ApiError( + 502, + "machine_provider_failed", + `The "${record.provider.id}" machine provider returned an invalid validate decision`, + ); + } + if (decision.data.action === "refuse") { + throw new ApiError( + 409, + "machine_provider_rejected", + decision.data.message, + ); + } + } + return { record, inputs }; +} + +export type MachineLaunchDecision = + | { action: "wait"; reason: string; sendAt: number; log: string } + | { action: "reject"; message: string; log: string } + | { action: "ready"; host: Host; log: string }; + +export function askMachineLaunch( + deps: Deps, + args: { + key: string; + record: PluginMachineProviderRecord; + inputs: JsonValue | null; + }, +): MachineLaunchDecision { + const now = Date.now(); + let row = getNonDestroyedHostByLaunchKey(deps.db, args.key); + if ( + row !== null && + (row.machineProviderId !== args.record.provider.id || + (row.phase === "creating" && + JSON.stringify(row.inputs) !== JSON.stringify(args.inputs))) + ) { + throw new ApiError( + 409, + "machine_launch_key_conflict", + `Machine launch key "${args.key}" is already in use`, + ); + } + if (row !== null && row.phase === "removing") { + const decision: MachineLaunchDecision = { + action: "reject", + message: row.statusMessage ?? "Machine creation was cancelled", + log: takeCreateLog(deps, row), + }; + if ( + row.teardownAttempt === 0 && + row.teardownStatus === "failed" && + row.removeRetryAt !== null && + row.removeRetryAt > now + ) { + const hostId = row.id; + updateHost(deps.db, deps.hub, hostId, { removeRetryAt: now }); + void sweepProviderMachine(deps, hostId).catch((error: unknown) => { + deps.logger.warn( + { hostId, error: errorMessage(error) }, + "Machine creation cleanup failed", + ); + }); + } + return decision; + } + if (row === null) { + const id = createHostId(); + const attempt = + (deps.db + .select({ attempt: hosts.attempt }) + .from(hosts) + .where(eq(hosts.launchKey, args.key)) + .orderBy(desc(hosts.attempt)) + .limit(1) + .get()?.attempt ?? 0) + 1; + const suffix = id.replace(/[^a-z0-9]/giu, "").slice(-6); + const operationId = `${args.record.pluginId}:${randomUUID()}`; + deps.db + .insert(hosts) + .values({ + id, + name: `${args.record.provider.displayName} ${suffix}`, + type: args.record.provider.ephemeral ? "ephemeral" : "persistent", + machineProviderId: args.record.provider.id, + machineOperationId: operationId, + launchKey: args.key, + inputs: args.inputs, + attempt, + phase: "creating", + resource: null, + statusMessage: `Creating ${args.record.provider.displayName}…`, + pendingLog: "", + createdAt: now, + updatedAt: now, + }) + .run(); + deps.hub.notifyHost(id, ["host-connected"]); + row = getHost(deps.db, id)!; + startCreate(deps, args.record, row); + } else if (row.phase === "creating") { + startCreate(deps, args.record, row); + } else { + return { + action: "ready", + host: machineHostResponse(row, deps), + log: takeCreateLog(deps, row), + }; + } + return { + action: "wait", + reason: row.statusMessage ?? "Creating machine…", + sendAt: now + 1_000, + log: takeCreateLog(deps, row), + }; +} + +function takeCreateLog(deps: Deps, row: MachineHostRow): string { + const log = row.pendingLog; + if (log.length > 0) { + updateHost(deps.db, deps.hub, row.id, { pendingLog: "" }); + } + return log; +} + +function machineHostResponse( + row: NonNullable>, + deps: Deps, +): Host { + return { + id: row.id, + name: row.name, + type: row.type, + status: deps.hub.hasDaemonForHost(row.id) ? "connected" : "disconnected", + machineProviderId: row.machineProviderId, + lifecycle: { + phase: row.phase, + suspendedAt: row.suspendedAt, + message: row.statusMessage, + pendingLog: row.pendingLog, + teardown: + row.teardownStatus === null + ? null + : { + status: row.teardownStatus, + attempt: row.teardownAttempt, + }, + }, + maxPermissionMode: row.maxPermissionMode, + lastSeenAt: row.lastSeenAt, + lastRejectedProtocolVersion: row.lastRejectedProtocolVersion, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function submitMachine( + deps: Deps, + args: { + key?: string; + machineProviderId: string; + inputs: JsonValue | null; + }, +): Promise { + const key = args.key ?? `machine-${randomUUID()}`; + const prepared = await prepareMachineProviderSelection(deps, args); + const decision = askMachineLaunch(deps, { + key, + record: prepared.record, + inputs: prepared.inputs, + }); + if (decision.action === "reject") + throw new ApiError(409, "machine_provider_rejected", decision.message); + const host = getNonDestroyedHostByLaunchKey(deps.db, key); + if (host === null) + throw new ApiError(409, "machine_provider_rejected", "Machine was removed"); + return machineHostResponse(host, deps); +} + +export async function removeCreatingMachine( + deps: Deps, + launchKey: string, +): Promise { + const host = getNonDestroyedHostByLaunchKey(deps.db, launchKey); + if (host?.phase !== "creating") return; + requestMachineRemoval(deps, host.id); + await sweepProviderMachine(deps, host.id); +} + +export async function createMachine( + deps: Deps, + args: Parameters[1] & { signal?: AbortSignal }, +): Promise { + let host = await submitMachine(deps, args); + for (;;) { + args.signal?.throwIfAborted(); + if (host.lifecycle.phase === "active") return host; + if ( + host.lifecycle.phase === "removing" || + host.lifecycle.phase === "destroyed" + ) { + throw new ApiError( + 409, + "machine_provider_rejected", + host.lifecycle.message ?? "Machine creation cancelled", + ); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + const row = getHost(deps.db, host.id); + if (row === null || row.destroyedAt !== null) + throw new ApiError( + 409, + "machine_provider_rejected", + "Machine creation failed", + ); + host = machineHostResponse(row, deps); + } +} + +function lifecycleOwns( + current: ReturnType, + providerId: string, + operationId: string, + phase: + | NonNullable>["phase"] + | NonNullable>["phase"][], +): current is NonNullable> { + return ( + current !== null && + current.destroyedAt === null && + current.machineProviderId === providerId && + current.machineOperationId === operationId && + operationId.startsWith(`${getMachineProvider(providerId)?.pluginId}:`) && + (Array.isArray(phase) + ? phase.includes(current.phase) + : current.phase === phase) + ); +} + +async function suspendMachine( + deps: Deps, + hostId: string, + coordinateMaintenance = false, +): Promise { + const daemonShutdownTimeoutMs = 30_000; + const removing = operations(removeOperations, deps.db).get(hostId); + if (removing !== undefined) { + await removing.done.catch(() => {}); + return; + } + const resuming = operations(resumeOperations, deps.db).get(hostId); + if (resuming !== undefined) { + await resuming.done.catch(() => {}); + } + const suspending = operations(suspendOperations, deps.db).get(hostId); + if (suspending !== undefined) { + if (coordinateMaintenance) + throw new ApiError( + 409, + "machine_maintenance", + "Machine already has a lifecycle operation or is waiting for retry.", + ); + await suspending.done; + return; + } + const row = getHost(deps.db, hostId); + if ( + row === null || + row.machineProviderId === null || + (row.phase !== "active" && + row.phase !== "suspending" && + !(row.phase === "removing" && row.suspendedAt === null)) + ) { + return; + } + const record = getMachineProvider(row.machineProviderId); + if (record === undefined || record.provider.suspend === null) return; + if (row.resource === null) { + throw new Error(`Machine "${hostId}" has no provider resource`); + } + const operationId = `${record.pluginId}:${randomUUID()}`; + const suspend = record.provider.suspend; + const resource = row.resource; + const removalRequested = row.phase === "removing"; + const operation = runTrackedOperation({ + map: operations(suspendOperations, deps.db), + key: hostId, + run: async (signal) => { + const run = async () => { + let checkpointed = false; + updateHost(deps.db, deps.hub, hostId, { + phase: "suspending", + machineOperationId: operationId, + ...(coordinateMaintenance ? {} : { statusMessage: null }), + teardownStatus: null, + }); + const daemonSessionId = deps.hub.getDaemonSessionIdForHost(hostId); + if (daemonSessionId !== null) { + deps.hub.requestDaemonShutdown(daemonSessionId); + const closed = await deps.hub.waitForDaemonSessionClose( + daemonSessionId, + daemonShutdownTimeoutMs, + ); + if (!closed || deps.hub.hasDaemonForHost(hostId)) { + if (!coordinateMaintenance) { + updateHost(deps.db, deps.hub, hostId, { + phase: "active", + machineOperationId: null, + }); + } + throw new Error( + `Machine "${hostId}" daemon did not shut down cleanly within ${daemonShutdownTimeoutMs}ms; suspend was cancelled`, + ); + } + } + const invocation = await invokeMachineProvider( + record, + "machine suspend", + () => + suspend({ + hostId, + resource, + report: lifecycleReporter(deps, hostId), + signal, + checkpoint: async (checkpoint) => { + const parsed = resourceSchema.parse(checkpoint); + const current = getHost(deps.db, hostId); + if ( + !lifecycleOwns(current, record.provider.id, operationId, [ + "suspending", + "removing", + ]) + ) { + throw new Error(`Machine "${hostId}" is no longer active`); + } + updateHost(deps.db, deps.hub, hostId, { + resource: parsed, + }); + checkpointed = true; + }, + }), + ); + if (!invocation.ok) { + if (checkpointed) { + updateHost(deps.db, deps.hub, hostId, { + suspendedAt: Date.now(), + }); + } + throw new Error(invocation.error); + } + const result = resourceResultSchema.parse(invocation.value); + const current = getHost(deps.db, hostId); + if ( + !lifecycleOwns(current, record.provider.id, operationId, [ + "suspending", + "removing", + ]) + ) { + return; + } + updateHost(deps.db, deps.hub, hostId, { + phase: + removalRequested || current.phase === "removing" + ? "removing" + : "suspended", + resource: result.resource, + suspendedAt: Date.now(), + statusMessage: null, + teardownStatus: null, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + }; + if (coordinateMaintenance) { + await maintainMachine(deps, hostId, operationId, run); + } else { + await run(); + } + }, + }); + await operation.done; +} + +function requireSuspendableMachine(deps: Deps, hostId: string) { + const row = getHost(deps.db, hostId); + if (row === null || row.destroyedAt !== null) { + throw new ApiError(404, "host_not_found", "Host not found"); + } + if (row.machineProviderId === null) { + throw new ApiError( + 409, + "machine_provider_unavailable", + "This machine is not managed by a machine provider", + ); + } + const record = getMachineProvider(row.machineProviderId); + if ( + record === undefined || + record.provider.suspend === null || + record.provider.resume === null + ) { + throw new ApiError( + 409, + "machine_suspend_unsupported", + `Machine provider "${row.machineProviderId}" does not support suspend and resume`, + ); + } + return row; +} + +function assertMachineProvisioningComplete(deps: Deps, hostId: string): void { + if ( + !hasPendingProjectSourceSetupOnHost(deps.db, hostId) && + !machineHasProvisioningEnvironment(deps.db, hostId) && + !machineHasLiveThreadLaunch(deps.db, hostId) + ) + return; + throw new ApiError( + 409, + "machine_busy", + "Wait for thread provisioning to finish before suspending this machine.", + ); +} + +export async function requestMachineSuspension( + deps: Deps, + hostId: string, +): Promise { + const row = requireSuspendableMachine(deps, hostId); + if (row.phase !== "active" && row.phase !== "suspending") { + throw new ApiError( + 409, + "machine_not_active", + "Only an active machine can be suspended", + ); + } + assertMachineProvisioningComplete(deps, hostId); + await suspendMachine(deps, hostId, true); + if (listThreadIdsWithHostOfflineQueueWaits(deps.db, hostId).length > 0) { + requestQueuedMachineReadiness(deps, hostId); + } +} + +export function startMachineSuspension(deps: Deps, hostId: string): void { + const row = requireSuspendableMachine(deps, hostId); + if (row.phase !== "active" && row.phase !== "suspending") { + throw new ApiError( + 409, + "machine_not_active", + "Only an active machine can be suspended", + ); + } + assertMachineProvisioningComplete(deps, hostId); + void requestMachineSuspension(deps, hostId).catch((error: unknown) => { + deps.logger.warn( + { hostId, error: errorMessage(error) }, + "Requested machine suspension will retry in the lifecycle sweep", + ); + }); +} + +export async function waitForMachineMaintenance( + deps: MachineLifecycleDeps, + hostId: string, +): Promise { + const suspending = operations(suspendOperations, deps.db).get(hostId); + if (suspending !== undefined) { + await suspending.done; + } +} + +export async function requestMachineResume( + deps: Deps, + hostId: string, +): Promise { + await waitForMachineMaintenance(deps, hostId); + const row = requireSuspendableMachine(deps, hostId); + if ( + row.phase !== "active" && + row.phase !== "suspended" && + row.phase !== "suspending" + ) { + throw new ApiError( + 409, + "machine_not_suspended", + "Only an active or suspended machine can be resumed", + ); + } + await waitForMachineMaintenance(deps, hostId); + await resumeMachine(deps, hostId); +} + +export function startMachineResume(deps: Deps, hostId: string): void { + const row = requireSuspendableMachine(deps, hostId); + if ( + row.phase !== "active" && + row.phase !== "suspended" && + row.phase !== "suspending" + ) { + throw new ApiError( + 409, + "machine_not_suspended", + "Only an active or suspended machine can be resumed", + ); + } + void resumeMachine(deps, hostId).catch((error: unknown) => { + deps.logger.warn( + { hostId, error: errorMessage(error) }, + "Requested machine resume will retry in the lifecycle sweep", + ); + }); +} + +export function isMachineResumeInFlight( + deps: Pick, + hostId: string, +): boolean { + const row = getHost(deps.db, hostId); + return ( + row?.phase === "suspended" && + row.machineOperationId !== null && + operations(resumeOperations, deps.db).has(hostId) + ); +} + +export async function resumeMachine( + deps: WorkSessionDeps, + hostId: string, +): Promise { + const removing = operations(removeOperations, deps.db).get(hostId); + if (removing !== undefined) { + await removing.done.catch(() => {}); + return; + } + const suspending = operations(suspendOperations, deps.db).get(hostId); + if (suspending !== undefined) { + await suspending.done.catch(() => {}); + } + await resumeMachineWithIntent(deps, hostId, false); +} + +async function resumeMachineWithIntent( + deps: WorkSessionDeps, + hostId: string, + preserveRemoval: boolean, +): Promise { + let row = getHost(deps.db, hostId); + if ( + row === null || + row.machineProviderId === null || + (row.phase === "removing" && !preserveRemoval) + ) + return; + if ( + row.phase !== "suspended" && + row.phase !== "suspending" && + !(row.phase === "removing" && row.suspendedAt !== null && preserveRemoval) + ) { + return; + } + const machineProviderId = row.machineProviderId; + const record = getMachineProvider(machineProviderId); + if (record === undefined || record.provider.resume === null) { + throw new ApiError( + 409, + "machine_provider_unavailable", + `Machine provider "${machineProviderId}" is not installed`, + ); + } + if (row.resource === null) { + throw new Error(`Machine "${hostId}" has no provider resource`); + } + const operationId = `${record.pluginId}:${randomUUID()}`; + const phase = row.phase; + const resumePhase = phase === "suspending" ? "suspended" : phase; + const resume = record.provider.resume; + const resource = row.resource; + const operation = runTrackedOperation({ + map: operations(resumeOperations, deps.db), + key: hostId, + run: async (signal) => { + updateHost(deps.db, deps.hub, hostId, { + machineOperationId: operationId, + phase: resumePhase, + statusMessage: "Resuming…", + }); + const invocation = await invokeMachineProvider( + record, + "machine resume", + () => + resume({ + hostId, + resource, + checkpoint: async (checkpoint) => { + const parsed = resourceSchema.parse(checkpoint); + const current = getHost(deps.db, hostId); + if ( + !lifecycleOwns( + current, + record.provider.id, + operationId, + resumePhase, + ) + ) { + throw new Error( + `Machine "${hostId}" resume no longer owns this resource`, + ); + } + updateHost(deps.db, deps.hub, hostId, { resource: parsed }); + }, + report: lifecycleReporter(deps, hostId), + signal, + }), + ); + if (!invocation.ok) throw new Error(invocation.error); + const result = resourceResultSchema.parse(invocation.value); + const current = getHost(deps.db, hostId); + if ( + !lifecycleOwns(current, record.provider.id, operationId, resumePhase) + ) { + return; + } + const keepRemoving = current.phase === "removing"; + updateHost(deps.db, deps.hub, hostId, { + phase: keepRemoving ? "removing" : "active", + resource: result.resource, + suspendedAt: null, + removeRetryAt: keepRemoving ? current.removeRetryAt : null, + statusMessage: null, + teardownStatus: null, + }); + deps.hub.notifyHost(hostId, ["host-connected"]); + }, + }); + try { + await operation.done; + updateHost(deps.db, deps.hub, hostId, { + statusMessage: null, + suspendRetryAt: null, + }); + } catch (error) { + const current = getHost(deps.db, hostId); + if ( + resumePhase !== phase && + lifecycleOwns(current, record.provider.id, operationId, resumePhase) + ) { + updateHost(deps.db, deps.hub, hostId, { phase }); + } + updateHost(deps.db, deps.hub, hostId, { + statusMessage: `Machine resume failed: ${errorMessage(error)}`, + suspendRetryAt: Date.now() + 10_000, + }); + throw error; + } +} + +async function resumeRemovingMachine( + deps: WorkSessionDeps, + hostId: string, +): Promise { + await resumeMachineWithIntent(deps, hostId, true); +} + +export function requestMachineRemoval(deps: Deps, hostId: string): boolean { + const row = getHost(deps.db, hostId); + if (row === null || row.destroyedAt !== null) return false; + if (row.machineProviderId === null) return false; + if (row.phase !== "creating" && machineHasLiveThreads(deps.db, hostId)) + return false; + updateHost(deps.db, deps.hub, hostId, { + phase: "removing", + machineOperationId: + row.phase === "suspending" || row.phase === "creating" + ? row.machineOperationId + : null, + removeRetryAt: Date.now(), + teardownStatus: null, + statusMessage: null, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + return true; +} + +export function requestAutomaticMachineRemoval( + deps: Deps, + hostId: string, +): boolean { + const row = getHost(deps.db, hostId); + if ( + row === null || + row.machineProviderId === null || + row.destroyedAt !== null || + row.phase === "creating" || + row.phase === "removing" + ) { + return false; + } + if (row.type !== "ephemeral") return false; + if ( + machineHasLiveThreadLaunch(deps.db, hostId) || + machineHasLiveThreads(deps.db, hostId) + ) { + return false; + } + return requestMachineRemoval(deps, hostId); +} + +export async function retryMachineCleanup( + deps: Deps, + hostId: string, +): Promise { + const row = getHost(deps.db, hostId); + if (row === null || row.destroyedAt !== null) { + throw new ApiError(404, "host_not_found", "Host not found"); + } + if ( + row.machineProviderId === null || + row.phase !== "removing" || + row.teardownStatus !== "failed" + ) { + throw new ApiError( + 409, + "machine_cleanup_not_failed", + "Cleanup can only be retried after machine teardown fails", + ); + } + updateHost(deps.db, deps.hub, hostId, { removeRetryAt: Date.now() }); + await sweepProviderMachine(deps, hostId); +} + +async function removeMachine(deps: Deps, hostId: string): Promise { + let removing = operations(removeOperations, deps.db).get(hostId); + if (removing !== undefined) { + await removing.done; + return; + } + const creating = operations(createOperations, deps.db).get(hostId); + if (creating !== undefined) { + creating.controller.abort(); + await creating.done.catch(() => {}); + } + const suspending = operations(suspendOperations, deps.db).get(hostId); + const resuming = operations(resumeOperations, deps.db).get(hostId); + await Promise.all([ + suspending?.done.catch(() => {}), + resuming?.done.catch(() => {}), + ]); + removing = operations(removeOperations, deps.db).get(hostId); + if (removing !== undefined) { + await removing.done; + return; + } + const row = getHost(deps.db, hostId); + if ( + row === null || + row.machineProviderId === null || + row.destroyedAt !== null || + row.phase !== "removing" + ) { + return; + } + const record = getMachineProvider(row.machineProviderId); + if (record === undefined) return; + const resource = row.resource; + const operationId = `${record.pluginId}:${randomUUID()}`; + const attempt = row.teardownAttempt + 1; + const creationFailureMessage = + row.teardownAttempt === 0 && row.teardownStatus === "failed" + ? row.statusMessage + : null; + updateHost(deps.db, deps.hub, hostId, { + machineOperationId: operationId, + teardownAttempt: attempt, + teardownStatus: "running", + statusMessage: creationFailureMessage, + }); + const operation = runTrackedOperation({ + map: operations(removeOperations, deps.db), + key: hostId, + run: async (signal) => { + try { + if (resource === null) { + const invocation = await invokeMachineProvider( + record, + "machine cleanup reconciliation", + () => + record.provider.reconcileCleanup({ + key: row.launchKey ?? hostId, + resource, + report: lifecycleReporter(deps, hostId), + signal, + }), + ); + if (!invocation.ok) throw new Error(invocation.error); + const result = removeResultSchema.parse(invocation.value); + if (result.status === "failed") throw new Error(result.message); + } else { + await removeResource(deps, record, { hostId, resource, signal }); + } + const current = getHost(deps.db, hostId); + if ( + !lifecycleOwns(current, record.provider.id, operationId, "removing") + ) + return; + if (current.type === "ephemeral") { + markHostEnvironmentsDestroyed(deps.db, deps.hub, hostId); + } + await deps.machineAuth.revokeHostEnrollKeys({ hostId }); + await serverAccess.release(deps, { key: hostId, hostId }); + deleteMachineProjectSources(deps, hostId); + await deps.machineAuth.revokeHostAuthKeys({ hostId }); + expireMachineSessions(deps, hostId); + const latest = getHost(deps.db, hostId); + if (!lifecycleOwns(latest, record.provider.id, operationId, "removing")) + return; + updateHost(deps.db, deps.hub, hostId, { + destroyedAt: Date.now(), + phase: "destroyed", + resource: null, + removeRetryAt: null, + suspendedAt: null, + teardownStatus: "removed", + statusMessage: creationFailureMessage, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + } catch (error) { + const current = getHost(deps.db, hostId); + if ( + !lifecycleOwns(current, record.provider.id, operationId, "removing") + ) + return; + updateHost(deps.db, deps.hub, hostId, { + teardownStatus: "failed", + statusMessage: errorMessage(error), + removeRetryAt: Date.now() + 60_000, + }); + } + }, + }); + await operation.done; +} + +export async function sweepProviderMachine( + deps: Deps, + hostId: string, +): Promise { + requestAutomaticMachineRemoval(deps, hostId); + let row = getHost(deps.db, hostId); + if ( + row === null || + row.machineProviderId === null || + row.phase === "destroyed" + ) { + return; + } + const record = getMachineProvider(row.machineProviderId); + if (record === undefined) return; + if (row.phase === "creating") { + await startCreate(deps, record, row).done; + return; + } + if ( + row.suspendedAt !== null && + listThreadIdsWithHostOfflineQueueWaits(deps.db, hostId).length > 0 + ) { + await resumeMachine(deps, hostId); + return; + } + if (row.phase === "suspending") { + const suspending = operations(suspendOperations, deps.db).get(hostId); + if (suspending !== undefined) { + await suspending.done; + return; + } + updateHost(deps.db, deps.hub, hostId, { + statusMessage: + row.suspendedAt !== null + ? null + : "Machine suspension was interrupted; recovery will use the last persisted provider resource.", + suspendRetryAt: row.suspendedAt !== null ? null : Date.now(), + }); + await resumeMachine(deps, hostId); + return; + } + const now = Date.now(); + if (operations(removeOperations, deps.db).has(hostId)) return; + if (operations(resumeOperations, deps.db).has(hostId)) return; + if ( + row.phase !== "removing" || + row.removeRetryAt === null || + row.removeRetryAt > now + ) { + return; + } + const suspending = operations(suspendOperations, deps.db).get(hostId); + const resuming = operations(resumeOperations, deps.db).get(hostId); + await Promise.all([ + suspending?.done.catch(() => {}), + resuming?.done.catch(() => {}), + ]); + row = getHost(deps.db, hostId); + if ( + row === null || + row.destroyedAt !== null || + row.phase !== "removing" || + row.removeRetryAt === null || + row.removeRetryAt > Date.now() + ) { + return; + } + const environments = listEnvironments(deps.db, { hostId }).filter( + (environment) => + environment.status !== "destroyed" || + environment.teardownStatus !== "removed", + ); + if (row.type === "persistent") { + if ( + environments.some((environment) => environment.providerOwnsPath) && + row.suspendedAt !== null + ) { + await resumeRemovingMachine(deps, hostId); + row = getHost(deps.db, hostId); + if (row === null || row.phase !== "removing") return; + } + let pendingEnvironment = false; + for (const environment of environments) { + requestEnvironmentRemoval(deps, environment.id); + await sweepProviderEnvironment(deps, environment.id); + const current = listEnvironments(deps.db, { + hostId, + limit: 1, + statuses: ["provisioning", "ready", "error"], + }); + if (current.length > 0) pendingEnvironment = true; + } + if (pendingEnvironment) return; + } + if ( + row.teardownStatus === "failed" && + row.removeRetryAt !== null && + row.removeRetryAt > now + ) { + return; + } + await removeMachine(deps, hostId); +} + +export async function sweepMachineLifecycles( + deps: Deps, + options?: { background: true }, +): Promise { + const pending: Promise[] = []; + for (const record of listMachineProviders()) { + for (const machine of listProviderMachines(deps.db, record.provider.id)) { + requestAutomaticMachineRemoval(deps, machine.id); + const sweeping = runTrackedOperation({ + map: operations(machineSweepOperations, deps.db), + key: machine.id, + run: async () => sweepProviderMachine(deps, machine.id), + }).done; + const settled = sweeping.catch((error: unknown) => { + const current = getHost(deps.db, machine.id); + if (current !== null && current.destroyedAt === null) { + updateHost(deps.db, deps.hub, machine.id, { + teardownAttempt: current.teardownAttempt + 1, + teardownStatus: "failed", + statusMessage: errorMessage(error), + ...(current.phase === "removing" + ? { + removeRetryAt: Date.now() + 60_000, + } + : {}), + }); + } + deps.logger.warn( + { hostId: machine.id, error: errorMessage(error) }, + "Machine lifecycle sweep will retry", + ); + }); + if (options?.background !== true) pending.push(settled); + } + } + await Promise.all(pending); +} diff --git a/apps/server/src/services/machines/server-access.ts b/apps/server/src/services/machines/server-access.ts new file mode 100644 index 0000000000..72bb4d690c --- /dev/null +++ b/apps/server/src/services/machines/server-access.ts @@ -0,0 +1,233 @@ +import { getAppSettings, getHost, hosts } from "@bb/db"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import type { + ServerAccessGrant, + ServerAccessSelection, +} from "@get-bb/plugin-sdk"; +import type { WorkSessionDeps } from "../../types.js"; +import { + invokeServerAccessProvider, + listServerAccessProviders, +} from "../plugins/plugin-server-access-registry.js"; +import { getMachineProvider } from "../plugins/plugin-machine-provider-registry.js"; + +type Dependencies = Pick; + +const reachableUrlSchema = z + .string() + .url() + .refine((value) => { + const url = new URL(value); + return ( + ["http:", "https:"].includes(url.protocol) && + !url.username && + !url.password + ); + }); +const grantSchema: z.ZodType = z + .object({ + id: z.string().min(1), + serverUrl: reachableUrlSchema, + headers: z.record(z.string(), z.string()).optional(), + }) + .strict(); +const acquireResultSchema = z.union([ + grantSchema, + z + .object({ status: z.literal("failed"), message: z.string().min(1) }) + .strict(), +]); +const availabilitySchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("available"), + serverUrl: reachableUrlSchema.optional(), + }), + z.object({ status: z.literal("setup-required"), message: z.string() }), + z.object({ status: z.literal("unavailable"), message: z.string() }), +]); + +export function machineServerUrl(deps: Dependencies) { + const configured = getAppSettings(deps.db).machineServerUrl; + const raw = configured ?? process.env.BB_EXTERNAL_URL ?? null; + const parsed = reachableUrlSchema.safeParse(raw); + return { + url: parsed.success ? parsed.data.replace(/\/$/u, "") : null, + source: + configured !== null + ? ("setting" as const) + : raw !== null + ? ("BB_EXTERNAL_URL" as const) + : null, + }; +} + +export async function serverAccessStatus(deps: Dependencies) { + const direct = machineServerUrl(deps); + const records = listServerAccessProviders(); + const providers: Array<{ + id: string; + displayName: string; + description: string; + pluginId: string | null; + }> = records.map((record) => ({ + id: record.provider.id, + displayName: record.provider.displayName, + description: record.provider.description, + pluginId: record.pluginId, + })); + providers.push({ + id: "direct", + displayName: "Manual", + description: "Use your own domain or network address.", + pluginId: null, + }); + const configured = getAppSettings(deps.db).defaultMachineAccess; + const defaultProviderId = configured ?? records[0]?.provider.id ?? "direct"; + return { + providers, + defaultProviderId, + effectiveUrl: direct.url, + urlSource: direct.source, + }; +} + +async function resolve( + deps: Dependencies, + args: { + key: string; + hostId: string; + access?: ServerAccessSelection; + signal: AbortSignal; + }, +): Promise { + args.signal.throwIfAborted(); + const host = getHost(deps.db, args.hostId); + if (!host || host.destroyedAt !== null) + throw new Error("Machine identity is unavailable"); + const status = await serverAccessStatus(deps); + const providerId = + host.serverAccessProviderId ?? + args.access?.providerId ?? + status.defaultProviderId; + if ( + args.access && + host.serverAccessProviderId && + args.access.providerId !== host.serverAccessProviderId + ) { + throw new Error("Machine already has a different server access provider"); + } + let grant: ServerAccessGrant; + if (providerId === "direct") { + const serverUrl = status.effectiveUrl; + if (serverUrl === null) + throw new Error("Set a server URL reachable by machines"); + grant = { id: args.hostId, serverUrl }; + } else { + const record = listServerAccessProviders().find( + (entry) => entry.provider.id === providerId, + ); + if (!record) throw new Error("Server access provider is unavailable"); + let availability: z.infer; + try { + availability = availabilitySchema.parse( + await invokeServerAccessProvider(record, async () => + record.provider.availability(), + ), + ); + } catch { + throw new Error("Server access provider is unavailable"); + } + if (availability.status !== "available") { + throw new Error(availability.message); + } + deps.db + .update(hosts) + .set({ serverAccessProviderId: providerId }) + .where(eq(hosts.id, args.hostId)) + .run(); + let result: unknown; + try { + result = await invokeServerAccessProvider(record, () => + record.provider.acquire(args), + ); + const parsed = acquireResultSchema.safeParse(result); + if (!parsed.success) + throw new Error("Server access provider returned an invalid grant"); + if ("status" in parsed.data) throw new Error(parsed.data.message); + grant = parsed.data; + } catch (error) { + deps.db + .update(hosts) + .set({ + statusMessage: error instanceof Error ? error.message : String(error), + }) + .where(eq(hosts.id, args.hostId)) + .run(); + deps.hub.notifyHost(args.hostId, ["host-connected"]); + throw error; + } + } + if ( + host.serverAccessGrantId !== null && + host.serverAccessGrantId !== grant.id + ) { + throw new Error("Server access provider changed its grant identity"); + } + deps.db + .update(hosts) + .set({ + serverAccessProviderId: providerId, + serverAccessGrantId: grant.id, + statusMessage: null, + }) + .where(eq(hosts.id, args.hostId)) + .run(); + args.signal.throwIfAborted(); + return grant; +} + +async function release( + deps: Dependencies, + args: { key: string; hostId: string }, +) { + const host = getHost(deps.db, args.hostId); + if (!host) return; + const owner = + host.machineProviderId === null + ? null + : (getMachineProvider(host.machineProviderId)?.pluginId ?? null); + const acquisitionKey = + owner !== null && host.launchKey !== null + ? JSON.stringify([owner, host.launchKey]) + : args.key; + const providerId = host.serverAccessProviderId; + const grantId = host.serverAccessGrantId; + if (providerId === null) return; + if (providerId !== "direct") { + const record = listServerAccessProviders().find( + (entry) => entry.provider.id === providerId, + ); + if (!record) { + deps.logger.warn( + { hostId: args.hostId, providerId }, + "Server access provider is not installed; skipping release during machine removal", + ); + } else { + await invokeServerAccessProvider(record, () => + record.provider.release({ + key: acquisitionKey, + grantId, + hostId: args.hostId, + }), + ); + } + } + deps.db + .update(hosts) + .set({ serverAccessProviderId: null, serverAccessGrantId: null }) + .where(eq(hosts.id, args.hostId)) + .run(); +} + +export const serverAccess = { resolve, release }; diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts index 77d59ad43b..2521937cf8 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -164,6 +164,11 @@ export const BUILTIN_PLUGINS = [ })); export const OFFICIAL_PLUGINS = [ + { + name: "environment-modal-sandbox", + pluginId: "environment-modal-sandbox", + defaultEnabled: true, + }, { name: "browser-automation", pluginId: "browser-automation", diff --git a/apps/server/src/services/plugins/plugin-agent-contributions.ts b/apps/server/src/services/plugins/plugin-agent-contributions.ts index f27c6e7863..2d32b0ce75 100644 --- a/apps/server/src/services/plugins/plugin-agent-contributions.ts +++ b/apps/server/src/services/plugins/plugin-agent-contributions.ts @@ -75,7 +75,12 @@ export async function resolvePluginProviderEnv(args: { }): Promise { const active = contributions; if (!active?.resolveProviderEnv) return []; - return (await active.resolveProviderEnv(args)).entries; + return (await active.resolveProviderEnv(args)).entries.map((entry) => ({ + name: entry.name, + value: entry.value, + source: entry.source, + reason: entry.reason, + })); } export async function resolvePluginProviderEnvHealth(args: { @@ -86,7 +91,9 @@ export async function resolvePluginProviderEnvHealth(args: { if (!active?.resolveProviderEnvHealth) return null; return active.resolveProviderEnvHealth({ providerId: args.providerId, - context: { hostId: args.hostId }, + context: { + hostId: args.hostId, + }, }); } diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index a7c634fdf3..f23319ba89 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -1,3 +1,10 @@ +import { + environmentCompositionSchema, + type NormalizedPluginEnvironmentComposition, +} from "@get-bb/plugin-sdk/internal/host-policy"; +import { createMachineBootstrapApi } from "../machines/bootstrap.js"; +import type { MachineEnrollments } from "../machines/enrollments.js"; +import { listServerAccessProviders } from "./plugin-server-access-registry.js"; import { createHash } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -6,6 +13,7 @@ import { CronExpressionParser } from "cron-parser"; import { deletePluginKvValue, getPluginKvValue, + getHost, listPluginKvKeys, setPluginKvValue, type DbConnection, @@ -42,6 +50,7 @@ import type { PluginMentionItem, PluginMentionSearchContext, PluginMentionTrigger, + PluginMachines, PluginAiServiceDeclaration, PluginAiServices, PluginProviderDeclaration, @@ -92,6 +101,7 @@ import { pluginHookAlreadyRegisteredMessage, storePluginHook, validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, providerAlreadyRegisteredMessage, providerIconRefusalMessage, undeclaredIconProblem, @@ -103,10 +113,12 @@ import { import type { AiServiceHostBinding, NormalizedPluginEnvironmentProvider, + NormalizedPluginMachineProvider, NormalizedPluginProviderDeclaration, } from "@get-bb/plugin-sdk/internal/host-policy"; import type { BbSdk, ThreadForkArgs, ThreadSpawnArgs } from "@bb/sdk"; import { requestEnvironmentProviderRecheck } from "./plugin-environment-provider-registry.js"; +import { requestServerAccessRecheck } from "./plugin-server-access-registry.js"; import type { ServerLogger } from "../../types.js"; import type { PluginInteractionResult } from "../interactions/pending-interactions.js"; import { appendPluginLogLine } from "./plugin-log.js"; @@ -249,7 +261,13 @@ export interface PluginApiHandle { threadEventHandlers: PluginThreadEventHandlers; /** Hook handlers recorded by `bb.experimental_hooks.on`. */ hooks: PluginHookRecords; + environmentCompositions: Map; environmentProviders: Map; + machineProviders: Map; + serverAccessProviders: Map< + string, + import("@get-bb/plugin-sdk").ServerAccessProviderDeclaration + >; /** HTTP routes recorded by `bb.http.route`; dropped with the handle. */ httpRoutes: PluginHttpRouteRecord[]; websocketRoutes: PluginWebSocketRouteRecord[]; @@ -423,6 +441,7 @@ export function createPluginApi(options: { db: DbConnection; dataDir: string; getSdk: () => BbSdk | undefined; + getMachineEnrollments: () => MachineEnrollments; getAppUrl: () => string | null; getLoopbackBaseUrl: () => string | undefined; publishSignal: (channel: string, payload: unknown) => void; @@ -430,6 +449,7 @@ export function createPluginApi(options: { reportNeedsConfiguration: (message: string) => void; isAgentToolNameTaken: (name: string) => string | undefined; isEnvironmentProviderIdTaken: (id: string) => string | undefined; + isMachineProviderIdTaken: (id: string) => string | undefined; reportAgentToolProblem: (message: string) => void; /** * Schedules a re-attempt of every plugin-queued row @@ -529,6 +549,8 @@ export function createPluginApi(options: { }; const databaseHandles: Database.Database[] = []; const threadEventHandlers: PluginThreadEventHandlers = { + "experimental_thread.events": [], + "experimental_terminal.input": [], "thread.created": [], "thread.active": [], "thread.idle": [], @@ -545,10 +567,19 @@ export function createPluginApi(options: { const hooks: PluginHookRecords = { "message.dispatch": null, }; + const environmentCompositions = new Map< + string, + NormalizedPluginEnvironmentComposition + >(); const environmentProviders = new Map< string, NormalizedPluginEnvironmentProvider >(); + const machineProviders = new Map(); + const serverAccessProviders = new Map< + string, + import("@get-bb/plugin-sdk").ServerAccessProviderDeclaration + >(); const httpRoutes: PluginHttpRouteRecord[] = []; const websocketRoutes: PluginWebSocketRouteRecord[] = []; const rpcHandlers = new Map(); @@ -1515,8 +1546,30 @@ export function createPluginApi(options: { }; const experimental_environments: PluginEnvironments = { - register(declaration) { + register( + declaration: + | import("@get-bb/plugin-sdk").PluginEnvironmentProviderDeclaration + | NormalizedPluginEnvironmentComposition, + ) { assertLive(); + if ("machineProviderId" in declaration) { + const composition = environmentCompositionSchema.parse(declaration); + const owner = options.isEnvironmentProviderIdTaken(composition.id); + if (owner !== undefined) + throw new Error( + `environment provider "${composition.id}" is already registered by plugin "${owner}"`, + ); + if (environmentProviders.has(composition.id)) + throw new Error( + "Environment ID is already registered as a concrete provider", + ); + environmentCompositions.set(composition.id, composition); + return; + } + if (environmentCompositions.has(declaration.id)) + throw new Error( + "Environment ID is already registered as a composition", + ); const provider = validatePluginEnvironmentProviderDeclaration(declaration); const problem = @@ -1539,6 +1592,88 @@ export function createPluginApi(options: { }, }; + const experimental_serverAccess: import("@get-bb/plugin-sdk").PluginServerAccess = + { + register(declaration) { + assertLive(); + if ( + !/^[a-z][a-z0-9-]*$/u.test(declaration.id) || + declaration.id === "direct" + ) { + throw new Error("Invalid or reserved server access provider id"); + } + if ( + typeof declaration.displayName !== "string" || + !declaration.displayName.trim() || + typeof declaration.description !== "string" || + !declaration.description.trim() || + typeof declaration.availability !== "function" || + typeof declaration.acquire !== "function" || + typeof declaration.release !== "function" + ) { + throw new Error("Invalid server access provider declaration"); + } + if ( + serverAccessProviders.has(declaration.id) || + listServerAccessProviders().some( + (entry) => + entry.provider.id === declaration.id && + entry.pluginId !== pluginId, + ) + ) { + throw new Error( + `Server access provider "${declaration.id}" is already registered`, + ); + } + serverAccessProviders.set(declaration.id, declaration); + }, + recheck() { + assertLive(); + requestServerAccessRecheck(options.pluginId); + }, + }; + + const enrollmentApi: MachineEnrollments = { + clearPending(key) { + assertLive(); + options.getMachineEnrollments().clearPending(key); + }, + prepare(request) { + assertLive(); + return options.getMachineEnrollments().prepare(request); + }, + waitForConnection(request) { + assertLive(); + return options.getMachineEnrollments().waitForConnection(request); + }, + }; + const experimental_machines: PluginMachines = { + ...createMachineBootstrapApi(enrollmentApi), + async getResource(hostId) { + assertLive(); + return getHost(db, hostId)?.resource ?? null; + }, + register(declaration) { + assertLive(); + const provider = validatePluginMachineProviderDeclaration(declaration); + const problem = undeclaredIconProblem( + pluginId, + declaredIconNames, + provider.icon, + ); + if (problem !== null) { + throw new Error(providerIconRefusalMessage(provider.id, problem)); + } + const owner = options.isMachineProviderIdTaken(provider.id); + if (owner !== undefined) { + throw new Error( + `machine provider "${provider.id}" is already registered by plugin "${owner}"`, + ); + } + machineProviders.set(provider.id, provider); + }, + }; + const aiServiceRegistrations = createStagedRegistrations({ validate: validatePluginAiServiceDeclaration, bind: assertAiServiceRegistrable, @@ -1569,6 +1704,8 @@ export function createPluginApi(options: { events, experimental_hooks, experimental_environments, + experimental_machines, + experimental_serverAccess, status, server, hosts, @@ -1598,7 +1735,10 @@ export function createPluginApi(options: { databaseHandles, threadEventHandlers, hooks, + environmentCompositions, environmentProviders, + machineProviders, + serverAccessProviders, httpRoutes, websocketRoutes, rpcHandlers, diff --git a/apps/server/src/services/plugins/plugin-environment-provider-registry.ts b/apps/server/src/services/plugins/plugin-environment-provider-registry.ts index 828dca374d..23f3518e5b 100644 --- a/apps/server/src/services/plugins/plugin-environment-provider-registry.ts +++ b/apps/server/src/services/plugins/plugin-environment-provider-registry.ts @@ -1,3 +1,4 @@ +import type { NormalizedPluginEnvironmentComposition } from "@get-bb/plugin-sdk/internal/host-policy"; import type { NormalizedPluginEnvironmentProvider } from "@get-bb/plugin-sdk/internal/host-policy"; import type { PluginHookInvocation } from "./plugin-hook-registry.js"; @@ -7,7 +8,12 @@ export interface PluginEnvironmentProviderRecord { icon?: { bytes: Uint8Array; contentType: string; hash: string }; } +export interface PluginEnvironmentCompositionRecord { + pluginId: string; + composition: NormalizedPluginEnvironmentComposition; +} export interface PluginEnvironmentProviderBridge { + listEnvironmentCompositions?(): PluginEnvironmentCompositionRecord[]; listEnvironmentProviders(): PluginEnvironmentProviderRecord[]; getEnvironmentProvider( id: string, @@ -29,6 +35,10 @@ export function setPluginEnvironmentProviderBridge( bridge = next; } +export function listEnvironmentCompositions(): PluginEnvironmentCompositionRecord[] { + return bridge?.listEnvironmentCompositions?.() ?? []; +} + export function listEnvironmentProviders(): PluginEnvironmentProviderRecord[] { return bridge?.listEnvironmentProviders() ?? []; } diff --git a/apps/server/src/services/plugins/plugin-host-rpc.ts b/apps/server/src/services/plugins/plugin-host-rpc.ts index 3cdc58da9d..233fab9988 100644 --- a/apps/server/src/services/plugins/plugin-host-rpc.ts +++ b/apps/server/src/services/plugins/plugin-host-rpc.ts @@ -1,3 +1,4 @@ +import { resolveHostEnvironment } from "../hosts/host-environment.js"; import { randomUUID } from "node:crypto"; import { listPublicHosts } from "@bb/db"; import type { @@ -86,6 +87,10 @@ export async function callPluginHostRpc( timeoutMs: timeoutMs + HOST_RPC_TRANSPORT_GRACE_MS, command: { type: "plugin.host.call", + contributedEnv: await resolveHostEnvironment(deps, { + hostId: args.hostId, + projectId: null, + }), pluginId: args.pluginId, generation: args.artifact.generation, artifact: { diff --git a/apps/server/src/services/plugins/plugin-machine-provider-registry.ts b/apps/server/src/services/plugins/plugin-machine-provider-registry.ts new file mode 100644 index 0000000000..fa74135848 --- /dev/null +++ b/apps/server/src/services/plugins/plugin-machine-provider-registry.ts @@ -0,0 +1,52 @@ +import type { NormalizedPluginMachineProvider } from "@get-bb/plugin-sdk/internal/host-policy"; +import type { PluginHookInvocation } from "./plugin-hook-registry.js"; + +export interface PluginMachineProviderRecord { + pluginId: string; + provider: NormalizedPluginMachineProvider; + icon?: { bytes: Uint8Array; contentType: string; hash: string }; +} + +export interface PluginMachineProviderBridge { + listMachineProviders(): PluginMachineProviderRecord[]; + getMachineProvider(id: string): PluginMachineProviderRecord | undefined; + invokeProvider( + pluginId: string, + label: string, + run: () => Promise, + ): Promise>; + readonly decisionTimeoutMs: number; +} + +let bridge: PluginMachineProviderBridge | undefined; + +export function setPluginMachineProviderBridge( + next: PluginMachineProviderBridge | undefined, +): void { + bridge = next; +} + +export function listMachineProviders(): PluginMachineProviderRecord[] { + return bridge?.listMachineProviders() ?? []; +} + +export function getMachineProvider( + id: string, +): PluginMachineProviderRecord | undefined { + return bridge?.getMachineProvider(id); +} + +export async function invokeMachineProvider( + record: PluginMachineProviderRecord, + label: string, + run: () => Promise, +): Promise> { + if (bridge === undefined) { + return { ok: false, error: "plugin runtime is not available" }; + } + return bridge.invokeProvider(record.pluginId, label, run); +} + +export function machineProviderDecisionTimeoutMs(): number { + return bridge?.decisionTimeoutMs ?? 10_000; +} diff --git a/apps/server/src/services/plugins/plugin-runtime.ts b/apps/server/src/services/plugins/plugin-runtime.ts index f76f2f8216..96f2840efc 100644 --- a/apps/server/src/services/plugins/plugin-runtime.ts +++ b/apps/server/src/services/plugins/plugin-runtime.ts @@ -1,3 +1,4 @@ +import type { MachineEnrollmentService } from "../machines/machine-services.js"; import { AsyncLocalStorage } from "node:async_hooks"; import { assertAiServiceRegistrable, @@ -67,6 +68,7 @@ import type { } from "@get-bb/plugin-sdk"; import type { PluginHookRegistration } from "./plugin-hook-registry.js"; import type { PluginEnvironmentProviderRecord } from "./plugin-environment-provider-registry.js"; +import type { PluginMachineProviderRecord } from "./plugin-machine-provider-registry.js"; import { isPluginSdkRangeSatisfied, pluginSdkRangeProblem, @@ -273,6 +275,7 @@ interface ServiceInstance { } interface PluginRuntimeContext { + machineEnrollments: MachineEnrollmentService | null; deps: PluginServiceDeps; settingsChanged?: () => void; nextCronRunAt: (cron: string, now: number) => number; @@ -636,6 +639,15 @@ export function createPluginRuntime(context: PluginRuntimeContext) { return registrations; } + function listPluginEnvironmentCompositions() { + return Array.from(loaded).flatMap(([pluginId, plugin]) => + Array.from( + plugin.handle.environmentCompositions.values(), + (composition) => ({ pluginId, composition }), + ), + ); + } + function listPluginEnvironmentProviders(): PluginEnvironmentProviderRecord[] { const records: PluginEnvironmentProviderRecord[] = []; const seen = new Set(); @@ -671,6 +683,50 @@ export function createPluginRuntime(context: PluginRuntimeContext) { ); } + function listPluginServerAccessProviders() { + return [...loaded].flatMap(([pluginId, plugin]) => + [...plugin.handle.serverAccessProviders.values()].map((provider) => ({ + pluginId, + provider, + })), + ); + } + + function listPluginMachineProviders(): PluginMachineProviderRecord[] { + const records: PluginMachineProviderRecord[] = []; + const seen = new Set(); + for (const [pluginId, plugin] of loaded) { + for (const provider of plugin.handle.machineProviders.values()) { + if (seen.has(provider.id)) { + logger.warn( + `[plugin:${pluginId}] machine provider "${provider.id}" is already registered by another plugin; ignoring`, + ); + continue; + } + seen.add(provider.id); + const declared = + provider.icon === null ? null : parseNamespacedGlyph(provider.icon); + const icon = + declared !== null + ? brandingAssets.get(pluginId)?.icons.get(declared.name) + : readPluginProviderIcon( + plugin.manifest.rootDir, + provider.icon ?? undefined, + ); + records.push({ pluginId, provider, ...(icon == null ? {} : { icon }) }); + } + } + return records; + } + + function getPluginMachineProvider( + id: string, + ): PluginMachineProviderRecord | undefined { + return listPluginMachineProviders().find( + (record) => record.provider.id === id, + ); + } + function hasThreadEventHandlers(event: PluginThreadEventName): boolean { if (loaded.size === 0) return false; for (const plugin of loaded.values()) { @@ -1349,6 +1405,13 @@ export function createPluginRuntime(context: PluginRuntimeContext) { db: deps.db, dataDir: deps.dataDir, getSdk: () => boundSdk, + getMachineEnrollments: () => { + if (!context.machineEnrollments) + throw new Error( + "Machine enrollment is unavailable in this plugin host", + ); + return context.machineEnrollments.forOwner(row.id); + }, getAppUrl: deps.getAppUrl ?? (() => null), getLoopbackBaseUrl: () => boundLoopbackBaseUrl, publishSignal: (channel, payload) => { @@ -1366,13 +1429,22 @@ export function createPluginRuntime(context: PluginRuntimeContext) { for (const [pluginId, plugin] of loaded) { if ( pluginId !== row.id && - plugin.handle.environmentProviders.has(id) + (plugin.handle.environmentProviders.has(id) || + plugin.handle.environmentCompositions.has(id)) ) { return pluginId; } } return undefined; }, + isMachineProviderIdTaken: (id) => { + for (const [pluginId, plugin] of loaded) { + if (pluginId !== row.id && plugin.handle.machineProviders.has(id)) { + return pluginId; + } + } + return undefined; + }, reportAgentToolProblem: (message) => { reportAgentToolProblem(row.id, message); }, @@ -1782,8 +1854,12 @@ export function createPluginRuntime(context: PluginRuntimeContext) { invokeWrapped, isBuiltinPluginId, listPluginHooks, + listPluginEnvironmentCompositions, listPluginEnvironmentProviders, getPluginEnvironmentProvider, + listPluginMachineProviders, + listPluginServerAccessProviders, + getPluginMachineProvider, identities, isPackagedBuiltinEntry, loadAll, diff --git a/apps/server/src/services/plugins/plugin-server-access-registry.ts b/apps/server/src/services/plugins/plugin-server-access-registry.ts new file mode 100644 index 0000000000..dc5fecb833 --- /dev/null +++ b/apps/server/src/services/plugins/plugin-server-access-registry.ts @@ -0,0 +1,43 @@ +import type { ServerAccessProviderDeclaration } from "@get-bb/plugin-sdk"; + +export interface ServerAccessProviderRecord { + pluginId: string; + provider: ServerAccessProviderDeclaration; +} + +export interface ServerAccessBridge { + list(): ServerAccessProviderRecord[]; + invoke(pluginId: string, run: () => Promise): Promise; +} + +let bridge: ServerAccessBridge | undefined; + +export function setServerAccessBridge( + value: ServerAccessBridge | undefined, +): void { + bridge = value; +} + +export function listServerAccessProviders(): ServerAccessProviderRecord[] { + return bridge?.list() ?? []; +} + +export async function invokeServerAccessProvider( + record: ServerAccessProviderRecord, + run: () => Promise, +): Promise { + if (!bridge) throw new Error("Server access provider is unavailable"); + return bridge.invoke(record.pluginId, run); +} + +let recheckHandler: ((pluginId: string) => void) | undefined; + +export function setServerAccessRecheckHandler( + handler: ((pluginId: string) => void) | undefined, +): void { + recheckHandler = handler; +} + +export function requestServerAccessRecheck(pluginId: string): void { + recheckHandler?.(pluginId); +} diff --git a/apps/server/src/services/plugins/plugin-service-internal.ts b/apps/server/src/services/plugins/plugin-service-internal.ts index 0f3a2f6793..554746e253 100644 --- a/apps/server/src/services/plugins/plugin-service-internal.ts +++ b/apps/server/src/services/plugins/plugin-service-internal.ts @@ -1,3 +1,4 @@ +import type { MachineEnrollmentService } from "../machines/machine-services.js"; import type { AiServiceRegistry } from "../ai/ai-service-registry.js"; import type { DbConnection } from "@bb/db"; import type { @@ -65,6 +66,7 @@ export interface PluginHostArtifactSnapshot { } export interface PluginServiceDeps { + machineEnrollments?: MachineEnrollmentService; db: DbConnection; sharedPorts?: Pick< HostSharedPortCoordinator, @@ -206,6 +208,10 @@ export type PluginMentionResolveResult = | { ok: false; error: string }; export interface PluginThreadEventEmitter { + emitThreadEvents(threadId: string): void; + emitTerminalInput( + terminal: import("@bb/server-contract").TerminalSession, + ): void; emitThreadCreated(thread: Thread): void; emitThreadActive(thread: Thread): void; emitThreadIdle(thread: Thread): void; diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index b4e3db6705..85ef884dd2 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -64,6 +64,8 @@ import { deleteInstalledPlugin, deletePluginSchedules, getInstalledPlugin, + getThread, + getLatestThreadSequence, listDuePluginSchedules, listInstalledPlugins, listPendingGitPluginArtifacts, @@ -156,6 +158,7 @@ import type { PluginResolvedProviderEnv, PluginResolvedProviderEnvHealth, } from "./plugin-service-internal.js"; +import type { PluginMachineProviderBridge } from "./plugin-machine-provider-registry.js"; export type { PluginAgentToolContribution, PluginMentionResolveResult, @@ -186,6 +189,8 @@ export interface PluginService { /** The hook chain the dispatch pipeline consults; registered in createApp. */ hooks: PluginHookProvider; environmentProviders: PluginEnvironmentProviderBridge; + machineProviders: PluginMachineProviderBridge; + serverAccessProviders: import("./plugin-server-access-registry.js").ServerAccessBridge; /** * Bind the in-process BB SDK to the running server. Call once the HTTP * listener is up, before start(): bb.sdk throws until this runs. @@ -917,8 +922,12 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { invokeWrapped, isBuiltinPluginId, listPluginHooks, + listPluginEnvironmentCompositions, listPluginEnvironmentProviders, getPluginEnvironmentProvider, + listPluginMachineProviders, + listPluginServerAccessProviders, + getPluginMachineProvider, isPackagedBuiltinEntry, loadAll, loaded, @@ -936,6 +945,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { withPluginOperationLock, } = createPluginRuntime({ deps, + machineEnrollments: deps.machineEnrollments ?? null, nextCronRunAt, settingsChanged: notifyPluginsChanged, settledWithin, @@ -1535,6 +1545,20 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { }, events: { + emitThreadEvents(threadId) { + emitThreadEvent("experimental_thread.events", () => { + const thread = getThread(deps.db, threadId); + return thread === null + ? null + : { + thread: buildThreadDto(thread), + sequence: getLatestThreadSequence(deps.db, { threadId }), + }; + }); + }, + emitTerminalInput(terminal) { + emitThreadEvent("experimental_terminal.input", () => ({ terminal })); + }, emitThreadCreated(thread) { emitThreadEvent("thread.created", () => ({ thread: buildThreadDto(thread), @@ -1603,6 +1627,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { }, environmentProviders: { + listEnvironmentCompositions: listPluginEnvironmentCompositions, listEnvironmentProviders: listPluginEnvironmentProviders, getEnvironmentProvider: getPluginEnvironmentProvider, invokeProvider: async (pluginId, label, run) => { @@ -1614,6 +1639,27 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { decisionTimeoutMs: pluginHookTimeoutMs, }, + serverAccessProviders: { + list: listPluginServerAccessProviders, + invoke: async (pluginId, run) => { + const outcome = await invokeWrapped(pluginId, "server access", run); + if (!outcome.ok) throw new Error("Server access provider failed"); + return outcome.value; + }, + }, + + machineProviders: { + listMachineProviders: listPluginMachineProviders, + getMachineProvider: getPluginMachineProvider, + invokeProvider: async (pluginId, label, run) => { + const outcome = await invokeWrapped(pluginId, label, run); + return outcome.ok + ? { ok: true, value: outcome.value } + : { ok: false, error: outcome.error }; + }, + decisionTimeoutMs: pluginHookTimeoutMs, + }, + bindSdk: bindRuntimeSdk, async start() { diff --git a/apps/server/src/services/plugins/plugin-settings.ts b/apps/server/src/services/plugins/plugin-settings.ts index 83a98e6174..f00fef0e59 100644 --- a/apps/server/src/services/plugins/plugin-settings.ts +++ b/apps/server/src/services/plugins/plugin-settings.ts @@ -39,7 +39,7 @@ function isSecret(descriptor: PluginSettingDescriptor): boolean { return descriptor.type === "string" && descriptor.secret === true; } -async function readSecret( +export async function readSecret( dataDir: string, pluginId: string, key: string, diff --git a/apps/server/src/services/plugins/plugin-thread-events.ts b/apps/server/src/services/plugins/plugin-thread-events.ts index 6c0eb71bb1..719dc33b72 100644 --- a/apps/server/src/services/plugins/plugin-thread-events.ts +++ b/apps/server/src/services/plugins/plugin-thread-events.ts @@ -3,11 +3,15 @@ import type { PendingInteraction, Thread } from "@bb/domain"; import type { ThreadQueuedMessage } from "@bb/domain"; import type { PluginThreadEventEmitter } from "./plugin-service.js"; +const pendingThreadEvents = new Map>(); + let emitter: PluginThreadEventEmitter | undefined; export function setPluginThreadEventEmitter( next: PluginThreadEventEmitter | undefined, ): void { + for (const timer of pendingThreadEvents.values()) clearTimeout(timer); + pendingThreadEvents.clear(); emitter = next; } @@ -88,3 +92,19 @@ export function emitPluginThreadLifecycleOutcome( emitter?.emitThreadFailed(outcome.thread); } } + +export function emitPluginThreadEvents(threadId: string): void { + if (emitter === undefined || pendingThreadEvents.has(threadId)) return; + const timer = setTimeout(() => { + pendingThreadEvents.delete(threadId); + emitter?.emitThreadEvents(threadId); + }, 1_000); + timer.unref?.(); + pendingThreadEvents.set(threadId, timer); +} + +export function emitPluginTerminalInput( + terminal: import("@bb/server-contract").TerminalSession, +): void { + emitter?.emitTerminalInput(terminal); +} diff --git a/apps/server/src/services/projects/project-source-setup.ts b/apps/server/src/services/projects/project-source-setup.ts new file mode 100644 index 0000000000..6ac33b3e5f --- /dev/null +++ b/apps/server/src/services/projects/project-source-setup.ts @@ -0,0 +1,229 @@ +import { resolveHostEnvironment } from "../hosts/host-environment.js"; +import { + createProjectSource, + getProjectSourceByHost, + isSqliteUniqueConstraintOnColumns, + setProjectGitRemoteUrlIfMissing, +} from "@bb/db"; +import type { CommandResultSideEffectsDeps } from "../../internal/command-result-side-effects.js"; +import { ApiError } from "../../errors.js"; +import { COMMAND_TIMEOUT_MS } from "../../constants.js"; +import { callHostRetryableOnlineRpcForWork } from "../hosts/online-rpc.js"; +import { runLiveHostCommand } from "../hosts/live-command.js"; +import { randomUUID } from "node:crypto"; +import type { PluginEnvironmentProviderProgress } from "@get-bb/plugin-sdk/environment-provider"; +import { registerEnvironmentProgressReport } from "../environments/environment-hooks.js"; + +export function projectSourceHostConflict(): ApiError { + return new ApiError( + 409, + "project_source_host_conflict", + "Project already has a source on this host", + ); +} + +export function registerProjectSourceOnHost( + deps: Pick, + args: { + projectId: string; + hostId: string; + path: string; + gitRemoteUrl: string | null; + ownsPath?: boolean; + }, +) { + let source; + try { + source = createProjectSource(deps.db, deps.hub, { + projectId: args.projectId, + type: "local_path", + hostId: args.hostId, + path: args.path, + ownsPath: args.ownsPath ?? false, + }); + } catch (error) { + if ( + error instanceof Error && + isSqliteUniqueConstraintOnColumns(error, { + columnNames: ["project_id", "host_id"], + indexName: "project_sources_project_host_idx", + tableName: "project_sources", + }) + ) { + throw projectSourceHostConflict(); + } + throw error; + } + if (args.gitRemoteUrl !== null) { + setProjectGitRemoteUrlIfMissing( + deps.db, + deps.hub, + args.projectId, + args.gitRemoteUrl, + ); + } + return source; +} + +export async function cloneProjectSourceOnHost( + deps: CommandResultSideEffectsDeps, + args: { + projectId: string; + projectName: string; + hostId: string; + remoteUrl: string | null; + targetPath?: string; + report?: PluginEnvironmentProviderProgress; + }, +) { + if (!args.remoteUrl) { + throw new ApiError( + 400, + "missing_git_remote", + "A remoteUrl is required because this project has no git remote anchor", + ); + } + const operationId = `project-clone-${randomUUID()}`; + const unregister = + args.report === undefined + ? () => undefined + : registerEnvironmentProgressReport(deps, { + hostId: args.hostId, + operationId, + report: args.report, + }); + let resolved; + try { + resolved = await runLiveHostCommand(deps, { + hostId: args.hostId, + timeoutMs: 20 * 60 * 1000, + command: { + type: "project.clone", + operationId, + contributedEnv: await resolveHostEnvironment(deps, { + hostId: args.hostId, + projectId: args.projectId, + }), + remoteUrl: args.remoteUrl, + projectSlug: args.projectName, + ...(args.targetPath !== undefined + ? { targetPath: args.targetPath } + : {}), + }, + }); + } finally { + unregister(); + } + return registerProjectSourceOnHost(deps, { + projectId: args.projectId, + hostId: args.hostId, + ...resolved, + ownsPath: true, + }); +} + +interface EnsureProjectSourceArgs { + projectId: string; + projectName: string; + hostId: string; + remoteUrl: string | null; + report?: PluginEnvironmentProviderProgress; +} + +const pendingSetups = new WeakMap< + CommandResultSideEffectsDeps["db"], + Map< + string, + { + hostId: string; + promise: Promise>; + } + > +>(); + +export function hasPendingProjectSourceSetupOnHost( + db: CommandResultSideEffectsDeps["db"], + hostId: string, +): boolean { + const pending = pendingSetups.get(db); + return ( + pending !== undefined && + [...pending.values()].some((setup) => setup.hostId === hostId) + ); +} + +export async function ensureProjectSourceOnHost( + deps: CommandResultSideEffectsDeps, + args: EnsureProjectSourceArgs, +) { + let pending = pendingSetups.get(deps.db); + if (pending === undefined) { + pending = new Map(); + pendingSetups.set(deps.db, pending); + } + const key = JSON.stringify([args.projectId, args.hostId]); + const active = pending.get(key); + if (active !== undefined) return active.promise; + const setup = recoverOrCloneProjectSource(deps, args); + pending.set(key, { hostId: args.hostId, promise: setup }); + try { + return await setup; + } finally { + if (pending.get(key)?.promise === setup) pending.delete(key); + } +} + +async function recoverOrCloneProjectSource( + deps: CommandResultSideEffectsDeps, + args: EnsureProjectSourceArgs, +) { + const source = getProjectSourceByHost(deps.db, args.projectId, args.hostId); + if (source !== null) return source; + if (args.remoteUrl === null) { + throw new ApiError( + 400, + "missing_git_remote", + "This project needs a Git remote to set up its checkout on a new machine", + ); + } + const { path } = await callHostRetryableOnlineRpcForWork(deps, { + hostId: args.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "project.clone_default_path", + projectSlug: `project-${args.projectId}`, + }, + }); + const { existence } = await callHostRetryableOnlineRpcForWork(deps, { + hostId: args.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { type: "host.paths_exist", paths: [path] }, + }); + if (existence[path] === true) { + const inspected = await callHostRetryableOnlineRpcForWork(deps, { + hostId: args.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { type: "project.inspect", path }, + }); + if (inspected.path !== path || inspected.gitRemoteUrl !== args.remoteUrl) { + throw new ApiError( + 409, + "project_source_target_conflict", + "The project setup target does not match this project's Git remote", + ); + } + return registerProjectSourceOnHost(deps, { + projectId: args.projectId, + hostId: args.hostId, + ...inspected, + }); + } + if (existence[path] !== false) { + throw new ApiError( + 502, + "invalid_host_response", + "The machine did not report whether the project setup target exists", + ); + } + return cloneProjectSourceOnHost(deps, { ...args, targetPath: path }); +} diff --git a/apps/server/src/services/skills/global-skill-install.ts b/apps/server/src/services/skills/global-skill-install.ts index ecbd2655a4..089337d15c 100644 --- a/apps/server/src/services/skills/global-skill-install.ts +++ b/apps/server/src/services/skills/global-skill-install.ts @@ -11,7 +11,10 @@ import type { import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import { ApiError } from "../../errors.js"; import type { AppDeps } from "../../types.js"; -import { callHostOnlineRpc } from "../hosts/online-rpc.js"; +import { + callHostOnlineRpc, + callHostOnlineRpcForWork, +} from "../hosts/online-rpc.js"; import { resolveServerOwnedSkillCatalogEntries } from "./injected-skills.js"; const GLOBAL_CLI_SKILL_NAMES: readonly string[] = ["bb-cli"]; @@ -161,7 +164,7 @@ export async function installGlobalCliSkills( const results = await Promise.all( hosts.map(async (host) => { try { - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId: host.id, timeoutMs: COMMAND_TIMEOUT_MS, command: { type: "host.install_global_skills", skills }, diff --git a/apps/server/src/services/skills/skill-listing.ts b/apps/server/src/services/skills/skill-listing.ts index f13f5afa54..7e7def98de 100644 --- a/apps/server/src/services/skills/skill-listing.ts +++ b/apps/server/src/services/skills/skill-listing.ts @@ -14,7 +14,7 @@ import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import { ApiError } from "../../errors.js"; import type { AppDeps } from "../../types.js"; import { - callHostOnlineRpc, + callHostOnlineRpcForWork, callHostRetryableOnlineRpc, } from "../hosts/online-rpc.js"; import type { ProjectCommandWorkspace as CommandWorkspace } from "../projects/project-workspace.js"; @@ -514,7 +514,7 @@ export async function writeProjectSkill( return { filePath: skillFilePath, revision }; } if (editableScope.data !== "bb-user" && editableScope.data !== "bb-project") { - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId: args.workspace.hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { @@ -532,7 +532,7 @@ export async function writeProjectSkill( } return { filePath: skill.filePath, revision: result.sha256 }; } - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId: args.workspace.hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { @@ -608,7 +608,7 @@ export async function deleteProjectSkill( daemonName = hostPathBasename(skillDirPath); rootPath = hostPathDirname(skillDirPath); } - const result = await callHostOnlineRpc(deps, { + const result = await callHostOnlineRpcForWork(deps, { hostId: args.workspace.hostId, timeoutMs: COMMAND_TIMEOUT_MS, command: { diff --git a/apps/server/src/services/system/execution-options.ts b/apps/server/src/services/system/execution-options.ts index dc8c0facec..ea82c76ccc 100644 --- a/apps/server/src/services/system/execution-options.ts +++ b/apps/server/src/services/system/execution-options.ts @@ -20,7 +20,11 @@ import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import { ApiError } from "../../errors.js"; import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; import { getHostPermissionCeiling } from "../hosts/permission-ceiling.js"; -import { requireEnvironment } from "../lib/entity-lookup.js"; +import { + requireConnectedHostSession, + requireEnvironment, +} from "../lib/entity-lookup.js"; +import { isSuspendedHostUnavailableError } from "../lib/lifecycle-api-errors.js"; import { createProviderListingBudget } from "../providers/native-roots.js"; import type { ProviderHealthCacheKey, @@ -212,14 +216,16 @@ async function listInstalledPluginProviderInfos( if (!canOmitProviderDiscoveryForError(error)) { throw error; } - deps.logger.warn( - { - ...expectedFallbackErrorLogFields(error), - hostId, - providerId: registration.info.id, - }, - "Failed to resolve installed-only provider status", - ); + if (!isSuspendedHostUnavailableError(error)) { + deps.logger.warn( + { + ...expectedFallbackErrorLogFields(error), + hostId, + providerId: registration.info.id, + }, + "Failed to resolve installed-only provider status", + ); + } return null; } }, @@ -256,6 +262,7 @@ function resolveSystemProviderInfosPlan( ): ResolveSystemProviderInfosPlanResult { try { const hostId = resolveSystemLookupHostId(deps, query); + requireConnectedHostSession(deps, hostId); return { hostId, hostLookupError: null, @@ -269,10 +276,12 @@ function resolveSystemProviderInfosPlan( if (!canOmitProviderDiscoveryForError(error)) { throw error; } - deps.logger.warn( - expectedFallbackErrorLogFields(error), - "Failed to resolve host for provider discovery", - ); + if (!isSuspendedHostUnavailableError(error)) { + deps.logger.warn( + expectedFallbackErrorLogFields(error), + "Failed to resolve host for provider discovery", + ); + } return { hostId: null, hostLookupError: error, diff --git a/apps/server/src/services/system/periodic-sweeps.ts b/apps/server/src/services/system/periodic-sweeps.ts index 3356f9d5a7..d8e7f3be46 100644 --- a/apps/server/src/services/system/periodic-sweeps.ts +++ b/apps/server/src/services/system/periodic-sweeps.ts @@ -1,5 +1,6 @@ import { sweepProviderLifecycles } from "../environments/environment-engine.js"; import { and, eq, isNull, inArray } from "drizzle-orm"; +import { sweepMachineLifecycles } from "../machines/provider-orchestration.js"; import { CLOSED_SESSION_ROW_RETENTION_MS, compactDatabase, @@ -345,6 +346,7 @@ export async function runThreadLifecycleSweep( ): Promise { await runThreadProvisioningOrphanCleanupSweep(deps); await sweepProviderLifecycles(deps); + await sweepMachineLifecycles(deps); } async function runMachineAuthPruneSweep( @@ -481,6 +483,12 @@ const PERIODIC_SWEEP_JOBS: PeriodicSweepJob[] = [ name: "environment-provider-lifecycle", run: sweepProviderLifecycles, }, + { + cadenceMs: 0, + category: "durable-intent-retry", + name: "machine-provider-lifecycle", + run: (deps) => sweepMachineLifecycles(deps, { background: true }), + }, { cadenceMs: 0, category: "retention", diff --git a/apps/server/src/services/system/provider-installations.ts b/apps/server/src/services/system/provider-installations.ts index 0786c085ee..1495619967 100644 --- a/apps/server/src/services/system/provider-installations.ts +++ b/apps/server/src/services/system/provider-installations.ts @@ -4,7 +4,7 @@ import type { } from "@bb/host-daemon-contract"; import type { ProviderInfo } from "@bb/domain"; import { ZodError } from "zod"; -import type { AppDeps } from "../../types.js"; +import type { WorkSessionDeps } from "../../types.js"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import { ApiError } from "../../errors.js"; import { @@ -69,7 +69,7 @@ export async function aggregateProviderInstallations( } export async function getProviderInstallations( - deps: AppDeps, + deps: WorkSessionDeps, args: { hostId: string }, ): Promise { const deadline = Date.now() + PROVIDER_INSTALLATION_STATUS_TIMEOUT_MS; @@ -132,3 +132,29 @@ export async function getProviderInstallations( }, }); } + +const installationTails = new WeakMap>>(); +export async function serializeProviderInstallation( + deps: WorkSessionDeps, + hostId: string, + run: () => Promise, +): Promise { + let hosts = installationTails.get(deps.db); + if (!hosts) { + hosts = new Map(); + installationTails.set(deps.db, hosts); + } + const previous = hosts.get(hostId) ?? Promise.resolve(); + let release: () => void = () => {}; + const tail = new Promise((resolve) => { + release = resolve; + }); + hosts.set(hostId, tail); + await previous; + try { + return await run(); + } finally { + release(); + if (hosts.get(hostId) === tail) hosts.delete(hostId); + } +} diff --git a/apps/server/src/services/system/provider-states.ts b/apps/server/src/services/system/provider-states.ts index 0a413acdd7..4b601d24fe 100644 --- a/apps/server/src/services/system/provider-states.ts +++ b/apps/server/src/services/system/provider-states.ts @@ -6,13 +6,20 @@ import type { import type { ProviderInfo } from "@bb/domain"; import type { AppDeps } from "../../types.js"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; -import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; -import { requireEnvironment } from "../lib/entity-lookup.js"; +import { + callHostRetryableOnlineRpc, + isHostUnavailableApiError, +} from "../hosts/online-rpc.js"; +import { + requireConnectedHostSession, + requireEnvironment, +} from "../lib/entity-lookup.js"; import { listSystemProviderInfos } from "./execution-options.js"; import { resolveSystemLookupHostId } from "./host-lookup.js"; import { resolveBridgeLaunchForProviderId } from "./provider-bridge-launch.js"; import { mapProviderMaintenanceRequests } from "./provider-maintenance-concurrency.js"; import { resolvePluginProviderEnvHealth } from "../plugins/plugin-agent-contributions.js"; +import { isSuspendedHostUnavailableError } from "../lib/lifecycle-api-errors.js"; function unknownProviderState( provider: ProviderInfo, @@ -107,6 +114,21 @@ export async function getProviderStates( ? undefined : (requireEnvironment(deps.db, query.environmentId).path ?? undefined); const providers = await listSystemProviderInfos(deps, { hostId }); + try { + requireConnectedHostSession(deps, hostId); + } catch (error) { + if (!isHostUnavailableApiError(error)) { + throw error; + } + const statusMessage = isSuspendedHostUnavailableError(error) + ? "Machine is paused." + : "Provider readiness could not be checked."; + return { + providers: providers.map((provider) => + unknownProviderState(provider, statusMessage), + ), + }; + } return { providers: await mapProviderMaintenanceRequests(providers, (provider) => getProviderState(deps, { diff --git a/apps/server/src/services/terminals/terminal-session-lifecycle.ts b/apps/server/src/services/terminals/terminal-session-lifecycle.ts index 332a646697..174d3d6ae4 100644 --- a/apps/server/src/services/terminals/terminal-session-lifecycle.ts +++ b/apps/server/src/services/terminals/terminal-session-lifecycle.ts @@ -1,3 +1,5 @@ +import { emitPluginTerminalInput } from "../plugins/plugin-thread-events.js"; +import { resolveHostEnvironment } from "../hosts/host-environment.js"; import { randomUUID } from "node:crypto"; import { createTerminalSession, @@ -681,6 +683,14 @@ export class TerminalSessionLifecycle { const requestId = randomUUID(); const openMessage: HostDaemonServerWsMessage = { type: "terminal.open", + contributedEnv: await resolveHostEnvironment(this.options, { + hostId: launchTarget.hostId, + projectId: + launchTarget.environmentId === null + ? null + : requireEnvironment(this.options.db, launchTarget.environmentId) + .projectId, + }), requestId, terminalId: startingSession.id, ...(args.threadId !== null ? { threadId: args.threadId } : {}), @@ -1057,6 +1067,8 @@ export class TerminalSessionLifecycle { }); throw new ApiError(502, "host_disconnected", "Host is not connected"); } + if (args.payload.dataBase64.length > 0) + emitPluginTerminalInput(toTerminalSession(session)); return toTerminalSession(session); } @@ -1603,6 +1615,8 @@ export class TerminalSessionLifecycle { this.disconnectDaemonSessionTerminals({ daemonSessionId: current.daemonSessionId, }); + } else if (args.message.dataBase64.length > 0) { + emitPluginTerminalInput(toTerminalSession(markedInput ?? current)); } } diff --git a/apps/server/src/services/threads/dispatch-attempt.ts b/apps/server/src/services/threads/dispatch-attempt.ts index 663edc57be..c91d58fafe 100644 --- a/apps/server/src/services/threads/dispatch-attempt.ts +++ b/apps/server/src/services/threads/dispatch-attempt.ts @@ -1,3 +1,8 @@ +import { requestQueuedMachineReadiness } from "./queued-message-dispatch.js"; +import { + cancelPreparingMachinePause, + isMachineWaitingForExecution, +} from "../machines/lifecycle.js"; import { deleteClaimedQueuedThreadMessageBatchInTransaction, getEnvironment, @@ -109,7 +114,7 @@ export function hostIdForEnvironmentIntent( if (intent.type === "reuse") { return getEnvironment(deps.db, intent.environmentId)?.hostId ?? null; } - return intent.machine.hostId; + return intent.machine.type === "existing" ? intent.machine.hostId : null; } function toPluginEnvironmentIntent( @@ -286,7 +291,14 @@ async function runDispatchAttempt( reattempted: boolean, ): Promise { const { payload, thread } = args; - ensureThreadIsWritable(thread); + const initialHost = dispatchEnvironmentAndHost( + deps, + thread.environmentId, + ).host; + ensureThreadIsWritable( + thread, + initialHost !== null && isMachineWaitingForExecution(deps, initialHost.id), + ); if (args.trigger === "user" && args.source.kind === "inline") { // Reject what can never deliver while the sender is still listening; a // drain has nobody to tell, and its rows were validated when they were queued. @@ -365,6 +377,21 @@ async function runDispatchAttempt( return waitOn({ kind: "time" }, sendAt); } + const { environment: dispatchEnvironment, host: dispatchHost } = + dispatchEnvironmentAndHost(deps, thread.environmentId); + if ( + dispatchHost !== null && + isMachineWaitingForExecution(deps, dispatchHost.id) + ) { + cancelPreparingMachinePause(deps, dispatchHost.id); + const outcome = waitOn( + { kind: "host-offline", hostName: dispatchHost.name }, + null, + ); + requestQueuedMachineReadiness(deps, dispatchHost.id); + return outcome; + } + if (thread.status === "active" && attempt === "start-turn") { if (payload.mode === "start") { // `start` asks for a FRESH turn specifically, so a running one is a @@ -378,8 +405,6 @@ async function runDispatchAttempt( return waitOn({ kind: "thread-busy" }, null); } - const { environment: dispatchEnvironment, host: dispatchHost } = - dispatchEnvironmentAndHost(deps, thread.environmentId); if ( dispatchEnvironment !== null && goneThreadEnvironmentDetails(dispatchEnvironment) === null && diff --git a/apps/server/src/services/threads/queued-message-dispatch.ts b/apps/server/src/services/threads/queued-message-dispatch.ts index 657956d953..5cd0a7f91f 100644 --- a/apps/server/src/services/threads/queued-message-dispatch.ts +++ b/apps/server/src/services/threads/queued-message-dispatch.ts @@ -1,3 +1,6 @@ +import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js"; +import { isMachineWaitingForExecution } from "../machines/lifecycle.js"; +import { waitForMachineMaintenance } from "../machines/provider-orchestration.js"; import { getQueuedThreadMessage, getThread, @@ -92,6 +95,7 @@ function prepareQueuedMessageDispatchWake( }); return []; case "host-connected": { + if (isMachineWaitingForExecution(deps, wake.hostId)) return []; const prepared: PreparedQueuedMessageDispatchWake[] = []; for (const threadId of listThreadIdsWithHostOfflineQueueWaits( deps.db, @@ -156,10 +160,50 @@ function schedulePreparedQueuedMessageDispatch( }); } +const queuedMachineReadiness = new WeakMap< + QueueDispatchDeps["db"], + Set +>(); + +export function requestQueuedMachineReadiness( + deps: QueueDispatchDeps, + hostId: string, +): void { + const pending = queuedMachineReadiness.get(deps.db) ?? new Set(); + queuedMachineReadiness.set(deps.db, pending); + if (pending.has(hostId)) return; + pending.add(hostId); + void waitForMachineMaintenance(deps, hostId) + .then(() => { + return ensureHostSessionReadyForWork(deps, { hostId }); + }) + .then(() => { + requestQueuedMessageDispatch(deps, { kind: "host-connected", hostId }); + }) + .catch((error) => { + deps.logger.warn( + { hostId, error }, + "Could not prepare machine for queued messages", + ); + }) + .finally(() => pending.delete(hostId)); +} + export function requestQueuedMessageDispatch( deps: QueueDispatchDeps, wake: QueuedMessageDispatchWake, ): void { + if ( + wake.kind === "host-connected" && + isMachineWaitingForExecution(deps, wake.hostId) + ) { + if ( + listThreadIdsWithHostOfflineQueueWaits(deps.db, wake.hostId).length > 0 + ) { + requestQueuedMachineReadiness(deps, wake.hostId); + } + return; + } for (const prepared of prepareQueuedMessageDispatchWake(deps, wake)) { schedulePreparedQueuedMessageDispatch(deps, prepared); } diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index 5fe7bdf964..867b781a3f 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -729,7 +729,7 @@ function describeCoreWait(waitingOn: QueuedMessageWaitingOn | null): string { case "provisioning": return "the thread's workspace is still being prepared"; case "host-offline": - return `the "${waitingOn.hostName}" host is not connected`; + return `the "${waitingOn.hostName}" host is not ready`; case "interaction": return "the thread is waiting for you to answer a pending interaction"; case "turn-starting": diff --git a/apps/server/src/services/threads/thread-archive.ts b/apps/server/src/services/threads/thread-archive.ts index 8ecad1caae..e7a3fd9993 100644 --- a/apps/server/src/services/threads/thread-archive.ts +++ b/apps/server/src/services/threads/thread-archive.ts @@ -2,6 +2,10 @@ import { cancelProviderEnvironmentCreation, sweepProviderEnvironment, } from "../environments/environment-engine.js"; +import { + removeCreatingMachine, + sweepProviderMachine, +} from "../machines/provider-orchestration.js"; import { listLiveThreadsInEnvironment, listNonDeletedChildThreads, @@ -105,10 +109,18 @@ function archiveThreadWithLifecycleEffects( (error) => deps.logger.warn({ error }, "Environment launch cancellation failed"), ); + void removeCreatingMachine(deps, archivedThread.id).catch((error) => + deps.logger.warn({ error }, "Machine launch cancellation failed"), + ); if (archivedThread.environmentId !== null) void sweepProviderEnvironment(deps, archivedThread.environmentId).catch( (error) => deps.logger.warn({ error }, "Environment retirement failed"), ); + if (args.environment !== null) { + void sweepProviderMachine(deps, args.environment.hostId).catch((error) => + deps.logger.warn({ error }, "Machine retirement failed"), + ); + } emitPluginThreadArchived(archivedThread); return archivedThread; diff --git a/apps/server/src/services/threads/thread-create.ts b/apps/server/src/services/threads/thread-create.ts index 5765048b9a..88d5f7b6f9 100644 --- a/apps/server/src/services/threads/thread-create.ts +++ b/apps/server/src/services/threads/thread-create.ts @@ -59,7 +59,10 @@ import { import { deriveTitleFallback } from "./title-generation.js"; import type { ThreadProvisionEnvironmentIntent } from "./thread-startup-store.js"; import { resolveSystemProviderModels } from "../system/execution-options.js"; -import { getEnvironmentProvider } from "../plugins/plugin-environment-provider-registry.js"; +import { + getEnvironmentProvider, + listEnvironmentCompositions, +} from "../plugins/plugin-environment-provider-registry.js"; type ThreadCreateDeps = LoggedPendingInteractionWorkSessionDeps; @@ -597,7 +600,11 @@ export async function createThreadFromRequest( if ( requestedEnvironment.type === "provider" && getEnvironmentProvider(requestedEnvironment.environmentProviderId) === - undefined + undefined && + !listEnvironmentCompositions().some( + (record) => + record.composition.id === requestedEnvironment.environmentProviderId, + ) ) { throw new ApiError(400, "invalid_request", "unknown environment provider"); } @@ -629,7 +636,9 @@ export async function createThreadFromRequest( resolvedEnvironment !== null ? childHostIdForResolvedEnvironment(resolvedEnvironment) : request.environment.type === "provider" - ? request.environment.machine.hostId + ? request.environment.machine?.type === "existing" + ? request.environment.machine.hostId + : null : null; assertForkSourceHost(deps, { childHostId, @@ -642,7 +651,8 @@ export async function createThreadFromRequest( const modelCatalogCwd = resolvedEnvironment !== null ? modelCatalogCwdForResolvedEnvironment(resolvedEnvironment) - : request.environment.type === "provider" + : request.environment.type === "provider" && + request.environment.machine?.type === "existing" ? projectCheckoutPathOnHost( deps, request.projectId, diff --git a/apps/server/src/services/threads/thread-default-policy.ts b/apps/server/src/services/threads/thread-default-policy.ts index da90de552f..0021d004e0 100644 --- a/apps/server/src/services/threads/thread-default-policy.ts +++ b/apps/server/src/services/threads/thread-default-policy.ts @@ -9,7 +9,10 @@ import type { } from "@bb/domain"; import { getEnvironment } from "@bb/db"; import { DEFAULT_ENVIRONMENT_PROVIDER_ID } from "../environments/environment-provider-ids.js"; -import { PERSONAL_PROJECT_ID, clampPermissionModeToCeiling } from "@bb/domain"; +import { + PERSONAL_PROJECT_ID, + clampPermissionModeToCeiling, +} from "@bb/domain"; import type { EnvironmentArgs, ProviderEnvironmentArgs, diff --git a/apps/server/src/services/threads/thread-environment-placement.ts b/apps/server/src/services/threads/thread-environment-placement.ts index a526e4acf9..942204170c 100644 --- a/apps/server/src/services/threads/thread-environment-placement.ts +++ b/apps/server/src/services/threads/thread-environment-placement.ts @@ -1,10 +1,16 @@ import { getAppSettings, getEnvironment, + getNonDestroyedHostByLaunchKey, getThread, + projectSourceOwnsPath, recordEnvironmentCurrentBranch, } from "@bb/db"; -import { type Environment, type Thread } from "@bb/domain"; +import { + type Environment, + type ProvisioningTranscriptEntry, + type Thread, +} from "@bb/domain"; import { type ThreadProvisionContext } from "./thread-startup-store.js"; import { type ThreadProvisioningDeps } from "./thread-provisioning-environment.js"; import { buildSuggestedBranchName } from "./thread-create-helpers.js"; @@ -15,7 +21,11 @@ import { cancelProviderEnvironmentCreation, type ProviderOperationContext, } from "../environments/environment-engine.js"; -import { getPreparingEnvironment, reserveEnvironment } from "@bb/db"; +import { + getPreparingEnvironment, + reserveEnvironment, + updatePreparingEnvironment, +} from "@bb/db"; import { appendThreadProvisioningEvent } from "./thread-events.js"; import { scheduleEnvironmentProvisioning } from "./thread-environment-providers.js"; import { @@ -32,6 +42,7 @@ import { jsonValueSchema, PERSONAL_PROJECT_ID, isLocalPathProjectSource, + type EnvironmentMachineSelection, type GitBranchSelection, type JsonValue, } from "@bb/domain"; @@ -46,9 +57,16 @@ import { ApiError } from "../../errors.js"; import { environmentProviderDecisionTimeoutMs, getEnvironmentProvider, + listEnvironmentCompositions, invokeEnvironmentProvider, type PluginEnvironmentProviderRecord, } from "../plugins/plugin-environment-provider-registry.js"; +import { getMachineProvider } from "../plugins/plugin-machine-provider-registry.js"; +import { + askMachineLaunch, + prepareMachineProviderSelection, +} from "../machines/provider-orchestration.js"; +import { ensureProjectSourceOnHost } from "../projects/project-source-setup.js"; import { requireSourceForHost } from "./thread-create-helpers.js"; import { foreignProviderOwnedPathRefusal } from "./workspace-path-claims.js"; import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js"; @@ -165,10 +183,29 @@ export async function completeProviderSelection( ): Promise { const environmentProviderId = record.provider.id; const requires = record.provider.requires; - const machine = selection.machine; - requireNonDestroyedHostWithStatus(deps, machine.hostId); - if (requires.projectCheckout) { - requireSourceForHost(deps, projectId, machine.hostId); + let machine: EnvironmentMachineSelection; + if (selection.machine.type === "existing") { + const host = requireNonDestroyedHostWithStatus( + deps, + selection.machine.hostId, + ); + if (host.lifecycle.phase === "removing") { + throw new ApiError( + 409, + "machine_removing", + "Machine is being removed and cannot accept new environments", + ); + } + if (requires.projectCheckout) { + requireSourceForHost(deps, projectId, selection.machine.hostId); + } + machine = selection.machine; + } else { + const prepared = await prepareMachineProviderSelection(deps, { + machineProviderId: selection.machine.machineProviderId, + inputs: selection.machine.inputs, + }); + machine = { ...selection.machine, inputs: prepared.inputs }; } if (requires.projectless && projectId !== PERSONAL_PROJECT_ID) { refuseProviderSelection( @@ -394,11 +431,56 @@ export async function resolveThreadEnvironmentPlacement( ): Promise { if (args.requestedEnvironment.type === "provider") { const requested = args.requestedEnvironment; + const composition = listEnvironmentCompositions().find( + (record) => record.composition.id === requested.environmentProviderId, + )?.composition; + const environmentProviderId = + composition?.environmentProviderId ?? requested.environmentProviderId; + if ( + composition && + requested.machine !== undefined && + (requested.machine.type !== "new" || + requested.machine.machineProviderId !== composition.machineProviderId) + ) { + refuseProviderSelection( + requested.environmentProviderId, + `creates a new machine with provider "${composition.machineProviderId}"; machine must select that provider`, + ); + } + const machine = composition + ? (requested.machine ?? { + type: "new" as const, + machineProviderId: composition.machineProviderId, + inputs: null, + }) + : requested.machine; + if (machine === undefined) + refuseProviderSelection( + environmentProviderId, + "requires a machine selection", + ); + if (composition) { + const target = getEnvironmentProvider(environmentProviderId); + if (!target) + refuseProviderSelection( + requested.environmentProviderId, + "requires an environment provider that is not registered", + ); + if ( + (target.provider.requires.projectCheckout || + target.provider.requires.gitRemote) && + requirePublicProject(deps.db, args.projectId).gitRemoteUrl === null + ) + refuseProviderSelection( + requested.environmentProviderId, + "requires a project with a Git remote", + ); + } return providerPlacement( deps, args.projectId, - requested.environmentProviderId, - requested, + environmentProviderId, + { machine, inputs: requested.inputs }, ); } const resolvedEnvironment = resolveStableThreadRequestEnvironment(deps, { @@ -489,7 +571,7 @@ export async function resolveProviderOperationContext( { type: "provider" } >, record: NonNullable>, -) { +): Promise { const project = requirePublicProject(deps.db, thread.projectId); let selection; try { @@ -506,7 +588,28 @@ export async function resolveProviderOperationContext( error instanceof Error ? error.message : String(error), ); } - const host = getNonDestroyedHostWithStatus(deps, selection.machine.hostId); + const machineDecision = + selection.machine.type === "new" + ? askMachineLaunch(deps, { + key: thread.id, + record: + getMachineProvider(selection.machine.machineProviderId) ?? + (() => { + throw providerFailure( + intent.environmentProviderId, + record.pluginId, + `needs the "${selection.machine.machineProviderId}" machine provider, which is not registered`, + ); + })(), + inputs: selection.machine.inputs, + }) + : null; + const hostId = + selection.machine.type === "existing" + ? selection.machine.hostId + : getNonDestroyedHostByLaunchKey(deps.db, thread.id)?.id; + const host = + hostId === undefined ? null : getNonDestroyedHostWithStatus(deps, hostId); if (host === null) { throw providerFailure( intent.environmentProviderId, @@ -514,6 +617,30 @@ export async function resolveProviderOperationContext( "runs on a machine that no longer exists", ); } + if (machineDecision?.action === "reject") { + reportMachineProgress(deps, thread.id, { + step: null, + log: machineFailureLog(machineDecision.log, machineDecision.message), + failed: true, + }); + failPreparingEnvironment(deps, thread.id, machineDecision.message); + throw new ApiError( + 409, + "machine_provider_rejected", + machineDecision.message, + ); + } + if (machineDecision?.action === "wait") { + reportMachineProgress(deps, thread.id, { + step: machineDecision.reason, + log: machineDecision.log, + }); + updatePreparingEnvironmentProgress(deps, thread.id, { + statusMessage: machineDecision.reason, + }); + scheduleEnvironmentProvisioning(deps, thread.id, machineDecision.sendAt); + return null; + } const requires = record.provider.requires; if (requires.gitRemote && project.gitRemoteUrl === null) { throw new ApiError( @@ -523,10 +650,37 @@ export async function resolveProviderOperationContext( { details: { environmentProviderId: intent.environmentProviderId } }, ); } - const checkout = getProjectSourceByHost(deps.db, thread.projectId, host.id); + let checkout = getProjectSourceByHost(deps.db, thread.projectId, host.id); + if ( + selection.machine.type === "new" && + requires.projectCheckout && + checkout === null + ) { + reportMachineProgress(deps, thread.id, { + step: "Setting up project on machine", + log: machineDecision?.log ?? "", + }); + updatePreparingEnvironmentProgress(deps, thread.id, { + statusMessage: "Setting up project on machine", + }); + checkout = await ensureProjectSourceOnHost(deps, { + projectId: project.id, + projectName: project.name, + hostId: host.id, + remoteUrl: project.gitRemoteUrl, + }); + } const projectCheckout = checkout !== null && isLocalPathProjectSource(checkout) - ? { path: checkout.path } + ? { + path: checkout.path, + experimental_ownsPath: projectSourceOwnsPath( + deps.db, + project.id, + host.id, + checkout.path, + ), + } : null; if (requires.projectCheckout && projectCheckout === null) { throw providerFailure( @@ -555,6 +709,101 @@ export async function resolveProviderOperationContext( }; return provisionContext; } + +function machineFailureLog(log: string, message: string): string { + const trimmedLog = log.trimEnd(); + if (trimmedLog.length === 0 || message.includes(trimmedLog)) { + return `${message}\n`; + } + if (trimmedLog.includes(message)) return `${trimmedLog}\n`; + return `${trimmedLog}\n${message}\n`; +} + +function reportMachineProgress( + deps: ThreadProvisioningDeps, + threadId: string, + args: { step: string | null; log: string; failed?: boolean }, +): void { + const startup = getThreadProvisionContext(deps.db, threadId); + if (startup === null) return; + const row = getPreparingEnvironment(deps.db, threadId); + const attempt = row?.attempt ?? 0; + const previous = row?.statusMessage ?? null; + const entries: ProvisioningTranscriptEntry[] = []; + if (args.step !== null && args.step !== previous) { + if (previous !== null) + entries.push({ + type: "step", + key: `provider-step-${attempt}-${previous}`, + text: previous, + status: "completed", + }); + entries.push({ + type: "step", + key: `provider-step-${attempt}-${args.step}`, + text: args.step, + status: "started", + }); + } + if (args.log.length > 0) + entries.push({ + type: "output", + key: `provider-output-${attempt}-${Date.now()}`, + text: args.log, + }); + if (args.failed && previous !== null) + entries.push({ + type: "step", + key: `provider-step-${attempt}-${previous}`, + text: previous, + status: "failed", + }); + if (entries.length === 0) return; + appendThreadProvisioningEvent(deps, { + threadId, + environmentId: row?.id ?? null, + provisioningId: startup.state.provisioningId, + status: "active", + entries, + }); + deps.hub.notifyThread(threadId, ["events-appended"], { + eventTypes: ["system/thread-provisioning"], + }); +} + +function updatePreparingEnvironmentProgress( + deps: ThreadProvisioningDeps, + threadId: string, + change: { statusMessage: string }, +): void { + const row = getPreparingEnvironment(deps.db, threadId); + if (row === null || row.status !== "creating") return; + if (row.statusMessage === change.statusMessage) return; + if ( + updatePreparingEnvironment(deps.db, { + ...row, + statusMessage: change.statusMessage, + }) + ) + deps.hub.notifyEnvironment(row.id, ["metadata-changed"]); +} + +function failPreparingEnvironment( + deps: ThreadProvisioningDeps, + threadId: string, + statusMessage: string, +): void { + const row = getPreparingEnvironment(deps.db, threadId); + if (row === null || row.status !== "creating") return; + if ( + updatePreparingEnvironment(deps.db, { + ...row, + status: "error", + statusMessage, + }) + ) + deps.hub.notifyEnvironment(row.id, ["metadata-changed"]); +} function threadProvisionContextEnvironment( deps: Pick, environmentId: string | null, @@ -600,6 +849,105 @@ export async function refreshAttachedEnvironmentBranch( } } +async function reserveEnvironmentBeforeMachine( + deps: ThreadProvisioningDeps, + args: { + context: ThreadProvisionContext; + intent: Extract; + record: PluginEnvironmentProviderRecord; + thread: Thread; + }, +): Promise { + const selection = args.intent.selectionResolved + ? { machine: args.intent.machine, inputs: args.intent.inputs } + : await completeProviderSelection( + deps, + args.record, + args.thread.projectId, + args.intent, + ); + if (selection.machine.type !== "new") + throw new Error("Expected a new machine selection"); + const machineRecord = getMachineProvider(selection.machine.machineProviderId); + if (machineRecord === undefined) + throw providerFailure( + args.intent.environmentProviderId, + args.record.pluginId, + `needs the "${selection.machine.machineProviderId}" machine provider, which is not registered`, + ); + const machineDecision = askMachineLaunch(deps, { + key: args.thread.id, + record: machineRecord, + inputs: selection.machine.inputs, + }); + const hostRow = getNonDestroyedHostByLaunchKey(deps.db, args.thread.id); + if (hostRow === null) + throw new ApiError( + 409, + "machine_provider_rejected", + machineDecision.action === "reject" + ? machineDecision.message + : "Machine was removed", + ); + const host = getNonDestroyedHostWithStatus(deps, hostRow.id); + if (host === null) + throw new ApiError(409, "machine_provider_rejected", "Machine was removed"); + args.intent.machine = selection.machine; + args.intent.inputs = selection.inputs; + args.intent.selectionResolved = true; + saveThreadProvisionContext({ + db: deps.db, + replace: false, + threadId: args.thread.id, + context: args.context, + }); + const project = requirePublicProject(deps.db, args.thread.projectId); + const context: ProviderOperationContext = { + thread: toThreadResponseFromThread(deps, { thread: args.thread }), + project, + host, + machine: selection.machine, + projectCheckout: null, + gitRemote: args.record.provider.requires.gitRemote + ? project.gitRemoteUrl + : null, + inputs: selection.inputs, + suggestedBranchName: buildSuggestedBranchName({ + branchPrefix: getAppSettings(deps.db).managedBranchPrefix, + title: args.thread.title ?? args.thread.titleFallback, + threadId: args.thread.id, + }), + environment: threadProvisionContextEnvironment( + deps, + args.thread.environmentId, + ), + }; + const statusMessage = + machineDecision.action === "ready" + ? "Setting up project on machine" + : machineDecision.action === "wait" + ? machineDecision.reason + : machineDecision.message; + const decision = prepareProviderEnvironment(deps, args.record, context, { + advance: machineDecision.action !== "reject", + contextReady: false, + statusMessage, + }); + if (machineDecision.action === "reject") { + reportMachineProgress(deps, args.thread.id, { + step: null, + log: machineFailureLog(machineDecision.log, machineDecision.message), + failed: true, + }); + failPreparingEnvironment(deps, args.thread.id, machineDecision.message); + } else if (machineDecision.log.length > 0) + reportMachineProgress(deps, args.thread.id, { + step: null, + log: machineDecision.log, + }); + return decision; +} + export async function resolveEnvironmentProvider( deps: ThreadProvisioningDeps, args: { context: ThreadProvisionContext; thread: Thread }, @@ -618,12 +966,37 @@ export async function resolveEnvironmentProvider( scheduleEnvironmentProvisioning(deps, thread.id, Date.now() + 30_000); return { kind: "waiting" }; } + if ( + intent.machine.type === "new" && + getPreparingEnvironment(deps.db, thread.id) === null + ) { + const decision = await reserveEnvironmentBeforeMachine(deps, { + context, + intent, + record, + thread, + }); + if (decision.action === "reject") + throw new ApiError( + 409, + "environment_provider_rejected", + decision.message, + { details: { environmentProviderId: intent.environmentProviderId } }, + ); + scheduleEnvironmentProvisioning( + deps, + thread.id, + decision.action === "wait" ? decision.sendAt : Date.now(), + ); + return { kind: "waiting" }; + } const operation = await resolveProviderOperationContext( deps, thread, intent, record, ); + if (operation === null) return { kind: "waiting" }; const current = getThreadProvisionContext(deps.db, thread.id); if ( current === null || @@ -683,6 +1056,11 @@ export function prepareProviderEnvironment( deps: ThreadProvisioningDeps, record: PluginEnvironmentProviderRecord, context: ProviderOperationContext, + options: { + advance?: boolean; + contextReady?: boolean; + statusMessage?: string; + } = {}, ): ProviderEnvironmentCreationDecision { const now = Date.now(); const policy = record.provider.policy; @@ -769,7 +1147,9 @@ export function prepareProviderEnvironment( status: "creating", environmentProviderInstanceKey: pathKey, hostId: context.host.id, - statusMessage: `${context.environment === null ? "Preparing" : "Restoring"} ${record.provider.displayName}…`, + statusMessage: + options.statusMessage ?? + `${context.environment === null ? "Preparing" : "Restoring"} ${record.provider.displayName}…`, environmentProviderSelection: selected, }); deps.hub.notifyEnvironment(row.id, ["environment-created"]); @@ -793,10 +1173,14 @@ export function prepareProviderEnvironment( eventTypes: ["system/thread-provisioning"], }); } - void advanceEnvironmentProvisioning(deps, { - environmentId: row.id, - creation: { record, context }, - }); + if (options.advance !== false) + void advanceEnvironmentProvisioning(deps, { + environmentId: row.id, + creation: { + record, + ...(options.contextReady === false ? {} : { context }), + }, + }); } if (row === null) throw new Error("Missing environment provisioning"); if ( @@ -807,7 +1191,10 @@ export function prepareProviderEnvironment( if (row.status === "creating") void advanceEnvironmentProvisioning(deps, { environmentId: row.id, - creation: { record, context }, + creation: { + record, + ...(options.contextReady === false ? {} : { context }), + }, }); return { action: "wait", diff --git a/apps/server/src/services/threads/thread-environment-providers.ts b/apps/server/src/services/threads/thread-environment-providers.ts index 6af2ea687d..00ce2ab060 100644 --- a/apps/server/src/services/threads/thread-environment-providers.ts +++ b/apps/server/src/services/threads/thread-environment-providers.ts @@ -8,6 +8,7 @@ import { } from "./thread-startup-store.js"; import { advanceThreadProvisioning } from "./thread-provisioning.js"; import type { ThreadProvisioningDeps } from "./thread-provisioning-environment.js"; +import { removeCreatingMachine } from "../machines/provider-orchestration.js"; export function scheduleEnvironmentProvisioning( deps: ThreadProvisioningDeps, @@ -70,6 +71,9 @@ export function cancelAbandonedProviderCreations( void cancelProviderEnvironmentCreation(deps, threadId).catch((error) => deps.logger.warn({ threadId, error }, "Environment cancellation failed"), ); + void removeCreatingMachine(deps, threadId).catch((error) => + deps.logger.warn({ threadId, error }, "Machine cancellation failed"), + ); } export function cancelEnvironmentProviderCreation( diff --git a/apps/server/src/services/threads/thread-lifecycle.ts b/apps/server/src/services/threads/thread-lifecycle.ts index 2a8694ebe6..4a70a6daa6 100644 --- a/apps/server/src/services/threads/thread-lifecycle.ts +++ b/apps/server/src/services/threads/thread-lifecycle.ts @@ -1350,6 +1350,7 @@ export async function stopThreadForCurrentState( deps: RequestThreadStopForCurrentStateDeps, thread: RequestThreadStopForCurrentStateThread, environment: RequestThreadStopForCurrentStateEnvironment | null, + options?: { requireStopped: true }, ): Promise { await revokeThreadDesktopBrowserControl(deps, thread.id); const hasLiveRuntime = @@ -1368,6 +1369,7 @@ export async function stopThreadForCurrentState( }; if (markThreadStopRequested(deps, args)) { await runAwaitedThreadStopCommand(deps, { + requireStopped: options?.requireStopped, command: buildThreadStopCommand({ ...args, intent: "interrupt" }), hostId: args.hostId, threadId: thread.id, @@ -1421,6 +1423,7 @@ async function runAwaitedThreadStopCommand( deps: RequestThreadStopForCurrentStateDeps, args: { command: ThreadStopCommand; + requireStopped?: boolean; hostId: string; threadId: string; }, @@ -1438,6 +1441,7 @@ async function runAwaitedThreadStopCommand( { err: error, intent: args.command.intent, threadId: args.threadId }, "Awaited thread stop command failed", ); + if (args.requireStopped) throw error; if ( args.command.intent === "release" && !isHostUnavailableApiError(error) diff --git a/apps/server/src/services/threads/thread-metadata-inference.ts b/apps/server/src/services/threads/thread-metadata-inference.ts index abe885b863..2c2a4edba9 100644 --- a/apps/server/src/services/threads/thread-metadata-inference.ts +++ b/apps/server/src/services/threads/thread-metadata-inference.ts @@ -82,7 +82,9 @@ export async function inferThreadMetadata( environmentId: args.environmentId, provisioningId, status: "active", - entries: [metadataCompletedEntry({ outcome, startedAt })], + entries: [ + metadataCompletedEntry({ outcome, startedAt }), + ], }); } diff --git a/apps/server/src/services/threads/thread-runtime-config.ts b/apps/server/src/services/threads/thread-runtime-config.ts index 472181638a..7c6e3d661f 100644 --- a/apps/server/src/services/threads/thread-runtime-config.ts +++ b/apps/server/src/services/threads/thread-runtime-config.ts @@ -1,3 +1,7 @@ +import { + resolveHostEnvironment, + mergeHostAndProviderEnvironment, +} from "../hosts/host-environment.js"; import { getEnvironment, getHost, getProject } from "@bb/db"; import type { DynamicTool, @@ -18,7 +22,7 @@ import type { import { ApiError } from "../../errors.js"; import type { AppDeps, LoggedWorkSessionDeps } from "../../types.js"; import { throwEnvironmentNotReady } from "../lib/lifecycle-api-errors.js"; -import { requireThreadStoragePath } from "./thread-storage.js"; +import { requireLiveThreadStoragePath } from "./thread-storage.js"; import { buildExistingThreadExecutionInput, resolveExistingThreadExecutionPlan, @@ -223,14 +227,20 @@ export async function resolveThreadRuntimeCommandConfig( }, skillIdsByPlugin, }); - const contributedEnv = await resolvePluginProviderEnv({ - providerId: args.thread.providerId, - context: { - threadId: args.thread.id, - projectId: project.id, + const contributedEnv = mergeHostAndProviderEnvironment( + await resolveHostEnvironment(deps, { hostId: host.id, - }, - }); + projectId: project.id, + }), + await resolvePluginProviderEnv({ + providerId: args.thread.providerId, + context: { + threadId: args.thread.id, + projectId: project.id, + hostId: host.id, + }, + }), + ); const injectedSkillSources = resolveSkillCatalog(deps, { projectSkillSources, sharedSkillSources: sharedSkills.runtimeSources, @@ -304,7 +314,7 @@ export async function resolveThreadRuntimeCommandConfig( ); } const instructions = instructionSections.join("\n\n"); - const threadStoragePath = await requireThreadStoragePath(deps, { + const threadStoragePath = await requireLiveThreadStoragePath(deps, { hostId: args.environment.hostId, threadId: args.thread.id, }); diff --git a/apps/server/src/services/threads/thread-send.ts b/apps/server/src/services/threads/thread-send.ts index 5169195d4b..e4aa9365dd 100644 --- a/apps/server/src/services/threads/thread-send.ts +++ b/apps/server/src/services/threads/thread-send.ts @@ -55,7 +55,7 @@ import { startLiveHostCommand, } from "../hosts/live-command.js"; import { - disconnectedHostUnavailableDetails, + inactiveHostUnavailableDetails, threadNotWritableReasonForStatus, throwHostUnavailable, throwSenderThreadInvalid, @@ -172,11 +172,14 @@ export function ensureThreadIsNotAwaitingUserInteraction( ); } -export function ensureThreadIsWritable(thread: Thread): void { +export function ensureThreadIsWritable( + thread: Thread, + allowStopping = false, +): void { if (thread.archivedAt) { throwThreadNotWritable(thread, "archived", "Thread is archived"); } - if (thread.status === "stopping") { + if (thread.status === "stopping" && !allowStopping) { throwThreadNotWritable(thread, "stopping", "Thread is stopping"); } if (thread.deletedAt !== null) { @@ -249,7 +252,7 @@ function ensureRuntimeCanAcceptActiveSend( throwHostUnavailable( 502, "Host daemon is not connected", - disconnectedHostUnavailableDetails(), + inactiveHostUnavailableDetails(), ); } diff --git a/apps/server/src/services/threads/thread-storage.ts b/apps/server/src/services/threads/thread-storage.ts index c11c017860..cb1ef5d9f7 100644 --- a/apps/server/src/services/threads/thread-storage.ts +++ b/apps/server/src/services/threads/thread-storage.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import { getLatestSessionForHost } from "@bb/db"; +import { ApiError } from "../../errors.js"; import type { WorkSessionDeps } from "../../types.js"; import { ensureHostSessionReadyForWork } from "../hosts/host-lifecycle.js"; @@ -10,6 +12,22 @@ interface RequireThreadStoragePathArgs { export async function requireThreadStoragePath( deps: WorkSessionDeps, args: RequireThreadStoragePathArgs, +): Promise { + const session = getLatestSessionForHost(deps.db, { hostId: args.hostId }); + if (session === null) { + throw new ApiError( + 502, + "host_unavailable", + "The host has no known storage location", + false, + ); + } + return path.join(session.dataDir, "thread-storage", args.threadId); +} + +export async function requireLiveThreadStoragePath( + deps: WorkSessionDeps, + args: RequireThreadStoragePathArgs, ): Promise { const session = await ensureHostSessionReadyForWork(deps, { hostId: args.hostId, diff --git a/apps/server/src/ws/daemon-protocol.ts b/apps/server/src/ws/daemon-protocol.ts index 6962a4d1d7..65f9299af3 100644 --- a/apps/server/src/ws/daemon-protocol.ts +++ b/apps/server/src/ws/daemon-protocol.ts @@ -231,6 +231,9 @@ export function onDaemonSocketMessage( args.socket.send(JSON.stringify({ type: "heartbeat-ack" })); return; } + if (result.data.type === "machine.shutdown-ack") { + return; + } deps.terminalSessions.handleDaemonTerminalMessage({ hostId: args.hostId, message: result.data, diff --git a/apps/server/src/ws/hub.ts b/apps/server/src/ws/hub.ts index 96c40bcf15..41deb9bbdc 100644 --- a/apps/server/src/ws/hub.ts +++ b/apps/server/src/ws/hub.ts @@ -1,3 +1,4 @@ +import { emitPluginThreadEvents } from "../services/plugins/plugin-thread-events.js"; import { Buffer } from "node:buffer"; import { realtimeSubscriptionTargetKey as subscriptionKey, @@ -129,6 +130,11 @@ interface DaemonRegistrationWaiter { timeout: ReturnType; } +interface DaemonSessionCloseWaiter { + resolve: (closed: boolean) => void; + timeout: ReturnType; +} + interface HostOnlineRpcWaiter { reject: (reason?: Error) => void; resolve: (message: HostDaemonOnlineRpcResponseMessage) => void; @@ -188,6 +194,10 @@ export class NotificationHub implements DbNotifier { string, Set >(); + private readonly daemonSessionCloseWaiters = new Map< + string, + Set + >(); private readonly daemonSessionIdsByHost = new Map(); private readonly hostOnlineRpcWaiters = new Map< string, @@ -526,6 +536,14 @@ export class NotificationHub implements DbNotifier { if (this.daemonSessionIdsByHost.get(entry.hostId) === sessionId) { this.daemonSessionIdsByHost.delete(entry.hostId); } + const waiters = this.daemonSessionCloseWaiters.get(sessionId); + if (waiters !== undefined) { + this.daemonSessionCloseWaiters.delete(sessionId); + for (const waiter of waiters) { + clearTimeout(waiter.timeout); + waiter.resolve(true); + } + } } hasDaemonForHost(hostId: string): boolean { @@ -583,6 +601,42 @@ export class NotificationHub implements DbNotifier { }); } + async waitForDaemonSessionClose( + sessionId: string, + timeoutMs: number, + ): Promise { + if (!this.daemonSessions.has(sessionId)) { + return true; + } + return new Promise((resolve) => { + const waiter: DaemonSessionCloseWaiter = { + resolve, + timeout: setTimeout(() => { + const waiters = this.daemonSessionCloseWaiters.get(sessionId); + waiters?.delete(waiter); + if (waiters?.size === 0) { + this.daemonSessionCloseWaiters.delete(sessionId); + } + resolve(false); + }, timeoutMs), + }; + const waiters = + this.daemonSessionCloseWaiters.get(sessionId) ?? + new Set(); + waiters.add(waiter); + this.daemonSessionCloseWaiters.set(sessionId, waiters); + }); + } + + requestDaemonShutdown(sessionId: string): boolean { + const entry = this.daemonSessions.get(sessionId); + if (entry === undefined) { + return false; + } + entry.socket.send(JSON.stringify({ type: "machine.shutdown" })); + return true; + } + closeDaemonSession( sessionId: string, reason: HostDaemonSessionCloseReason, @@ -740,6 +794,7 @@ export class NotificationHub implements DbNotifier { changes: ThreadChangeKind[], metadata?: ThreadChangeMetadata, ): void { + if (changes.includes("events-appended")) emitPluginThreadEvents(threadId); const message: ThreadChangedMessage = { type: "changed", entity: "thread", diff --git a/apps/server/test/app/host-shared-ports.test.ts b/apps/server/test/app/host-shared-ports.test.ts index 330662db64..fee02e9792 100644 --- a/apps/server/test/app/host-shared-ports.test.ts +++ b/apps/server/test/app/host-shared-ports.test.ts @@ -35,7 +35,6 @@ function setup(args: { enrolled?: boolean; online?: boolean } = {}) { const host = upsertHost(db, noopNotifier, { id: "host-1", name: "test-host", - type: "persistent", ...(args.enrolled === false ? {} : { connectMachineId: "machine-1" }), }); if (args.enrolled !== false && args.online !== false) { @@ -43,7 +42,6 @@ function setup(args: { enrolled?: boolean; online?: boolean } = {}) { hostId: host.id, instanceId: "instance-1", hostName: host.name, - hostType: host.type, dataDir: "/tmp/host-data", protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, heartbeatIntervalMs: 30_000, @@ -238,7 +236,6 @@ describe("HostSharedPortCoordinator", () => { hostId: host.id, instanceId: "reconnected-instance", hostName: host.name, - hostType: host.type, dataDir: "/tmp/host-data", protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, heartbeatIntervalMs: 30_000, @@ -309,7 +306,6 @@ describe("HostSharedPortCoordinator", () => { hostId: offline.host.id, instanceId: "reconnected-instance", hostName: offline.host.name, - hostType: offline.host.type, dataDir: "/tmp/host-data", protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, heartbeatIntervalMs: 30_000, @@ -415,14 +411,12 @@ describe("daemon session connect shares", () => { upsertHost(harness.db, harness.hub, { id: "host-1", name: "Host", - type: "persistent", connectMachineId: "machine-1", }); const previousSession = openSession(harness.db, { hostId: "host-1", instanceId: "previous-instance", hostName: "Host", - hostType: "persistent", dataDir: "/tmp/host-data", protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, heartbeatIntervalMs: 30_000, @@ -449,7 +443,6 @@ describe("daemon session connect shares", () => { hostId: "host-1", instanceId: "instance-1", hostName: "Host", - hostType: "persistent", hasMachineCredential: true, platform: "darwin", dataDir: "/tmp/host-data", @@ -471,7 +464,6 @@ describe("daemon session connect shares", () => { upsertHost(harness.db, harness.hub, { id: "host-1", name: "Host", - type: "persistent", connectMachineId: "machine-1", }); const response = await harness.app.request("/internal/session/open", { @@ -484,7 +476,6 @@ describe("daemon session connect shares", () => { hostId: "host-1", instanceId: "instance-1", hostName: "Host", - hostType: "persistent", hasMachineCredential: true, platform: "darwin", dataDir: "/tmp/host-data", @@ -539,7 +530,6 @@ describe("daemon session connect shares", () => { upsertHost(harness.db, harness.hub, { id: "host-1", name: "Host", - type: "persistent", connectMachineId: "machine-1", }); const response = await harness.app.request("/internal/session/open", { @@ -552,7 +542,6 @@ describe("daemon session connect shares", () => { hostId: "host-1", instanceId: "restarted-without-credential", hostName: "Host", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-data", diff --git a/apps/server/test/app/install-machine-script.test.ts b/apps/server/test/app/install-machine-script.test.ts index 977a2e49f0..46d7218f72 100644 --- a/apps/server/test/app/install-machine-script.test.ts +++ b/apps/server/test/app/install-machine-script.test.ts @@ -1,10 +1,11 @@ -import { spawn, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { chmodSync, existsSync, mkdtempSync, mkdirSync, + readdirSync, readFileSync, realpathSync, rmSync, @@ -13,7 +14,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { createServer as createNetServer } from "node:net"; -import { delimiter, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; const SCRIPT_PATH = new URL( @@ -48,7 +49,7 @@ type Fixture = ReturnType; function createScriptEnv( fixture: Fixture, - env: Record, + env: Record, ): NodeJS.ProcessEnv { return { ...process.env, @@ -62,7 +63,7 @@ function createScriptEnv( function runScript( args: string[], fixture: Fixture, - env: Record = {}, + env: Record = {}, ) { return spawnSync("sh", [SCRIPT_PATH.pathname, ...args], { encoding: "utf8", @@ -70,31 +71,6 @@ function runScript( }); } -async function runScriptAsync( - args: string[], - fixture: Fixture, - env: Record = {}, -): Promise<{ status: number | null; stderr: string; stdout: string }> { - const child = spawn("sh", [SCRIPT_PATH.pathname, ...args], { - env: createScriptEnv(fixture, env), - }); - child.stderr.setEncoding("utf8"); - child.stdout.setEncoding("utf8"); - let stderr = ""; - let stdout = ""; - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - const status = await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("close", resolve); - }); - return { status, stderr, stdout }; -} - const JOIN_ARGS = [ "--join-code", "join-secret", @@ -145,7 +121,7 @@ const serverUrl = option("--server-url"); const statusServerUrl = ${JSON.stringify(args.statusServerUrl)} ?? serverUrl; fs.writeFileSync( path.join(dataDir, "auth.json"), - JSON.stringify({ hostId, hostKey: "secret", hostType: "persistent" }) + "\\n", + JSON.stringify({ hostId, hostKey: "secret" }) + "\\n", ); const configPath = path.join(dataDir, "config.json"); if (!fs.existsSync(configPath)) { @@ -272,6 +248,41 @@ afterEach(() => { }); describe("machine install script", () => { + it.each([ + { uid: 0, unset: true }, + { uid: 501, unset: true }, + { uid: 501, unset: false }, + ])( + "resolves an unset HOME and preserves explicit HOME: %j", + ({ uid, unset }) => { + const fixture = createFixture(); + const homeScript = + 'const home = require("node:os").homedir(); if (!require("node:path").isAbsolute(home)) process.exit(1); process.stdout.write(home);'; + rmSync(join(fixture.binDir, "node")); + writeExecutable( + join(fixture.binDir, "node"), + `#!/bin/sh +if [ "$1" = -e ] && [ "$2" = '${homeScript}' ]; then + : >'${join(fixture.dataDir, "resolved-home")}' + printf '%s' '${fixture.homeDir}' + exit 0 +fi +exec '${process.execPath}' "$@" +`, + ); + writeExecutable(join(fixture.binDir, "id"), `#!/bin/sh\necho ${uid}\n`); + writeCurlArtifactMock(fixture, 404); + const result = runScript(JOIN_ARGS, fixture, { + HOME: unset ? undefined : fixture.homeDir, + }); + expect(result.status).not.toBe(0); + expect(result.stderr).not.toContain("HOME"); + expect(existsSync(join(fixture.dataDir, "resolved-home"))).toBe(unset); + expect(existsSync(join(fixture.homeDir, ".local/bin/bb"))).toBe(true); + expect(existsSync(join(fixture.dataDir, "auth.json"))).toBe(false); + }, + ); + it("rejects missing required flags with usage", () => { const fixture = createFixture(); const result = runScript(["--join-code", "code-only"], fixture); @@ -282,6 +293,53 @@ describe("machine install script", () => { ); }); + it("stops and uninstalls an owned Linux service through installer flags", () => { + const fixture = createFixture(); + mkdirSync(join(fixture.homeDir, ".bb-machines", "owned"), { + recursive: true, + }); + const dataDir = realpathSync( + join(fixture.homeDir, ".bb-machines", "owned"), + ); + writeJoinedState({ ...fixture, dataDir }); + writeFileSync(join(dataDir, "host-daemon-port"), "40000\n"); + const serviceDir = join(fixture.homeDir, ".config", "systemd", "user"); + const serviceName = "bb-host-daemon-machine-getbb-app-host-test.service"; + const servicePath = join(serviceDir, serviceName); + mkdirSync(serviceDir, { recursive: true }); + writeFileSync( + servicePath, + `[Service]\nEnvironment="BB_DATA_DIR=${dataDir}"\n`, + ); + writeExecutable( + join(fixture.binDir, "uname"), + "#!/bin/sh\nprintf '%s\\n' Linux\n", + ); + const systemctlLog = join(fixture.homeDir, "systemctl.log"); + writeExecutable( + join(fixture.binDir, "systemctl"), + `#!/bin/sh\nprintf '%s\\n' "$*" >>${JSON.stringify(systemctlLog)}\n`, + ); + const stopped = runScript( + ["--stop", "--host-id", "host-test", "--data-dir", dataDir], + fixture, + ); + expect(stopped.status, stopped.stderr).toBe(0); + expect(existsSync(dataDir)).toBe(true); + expect(readFileSync(systemctlLog, "utf8")).toContain( + `--user stop ${serviceName}`, + ); + const uninstalled = runScript( + ["--uninstall", "--host-id", "host-test", "--data-dir", dataDir], + fixture, + ); + expect(uninstalled.status, uninstalled.stderr).toBe(0); + expect(existsSync(dataDir)).toBe(false); + expect(readFileSync(systemctlLog, "utf8")).toContain( + `--user disable --now ${serviceName}`, + ); + }); + it("rejects an invalid explicit host-daemon port", () => { const fixture = createFixture(); const result = runScript( @@ -316,6 +374,147 @@ describe("machine install script", () => { expect(result.stderr).not.toContain("TypeError"); }); + it("prefers the newly installed CLI and honors an explicit machine directory", () => { + const fixture = createFixture(); + writeServerInstallTools(fixture, 200); + writeExecutable(join(fixture.binDir, "npm"), "#!/bin/sh\nexit 19\n"); + runScript(JOIN_ARGS, fixture, { BB_INSTALL_SKIP_SERVICE: "1" }); + const olderCli = join( + fixture.homeDir, + ".bb-machines", + "older", + "npm", + "bin", + "bb", + ); + mkdirSync(dirname(olderCli), { recursive: true }); + writeExecutable(olderCli, "#!/bin/sh\necho wrong-installation\n"); + const installedCli = join(fixture.dataDir, "npm", "bin", "bb"); + mkdirSync(dirname(installedCli), { recursive: true }); + writeExecutable(installedCli, '#!/bin/sh\nprintf "%s" "$BB_DATA_DIR"\n'); + const shim = join(fixture.homeDir, ".local", "bin", "bb"); + const explicit = spawnSync( + shim, + ["machine", "uninstall", "--host-id", "host-test"], + { env: createScriptEnv(fixture, {}), encoding: "utf8" }, + ); + expect(explicit.status).toBe(0); + expect(explicit.stdout).toBe(fixture.dataDir); + writeExecutable(installedCli, "#!/bin/sh\necho current-installation\n"); + const env = createScriptEnv(fixture, {}); + delete env.BB_DATA_DIR; + const selected = spawnSync(shim, ["machine", "enroll"], { + env, + encoding: "utf8", + }); + expect(selected.status).toBe(0); + expect(selected.stdout.trim()).toBe("current-installation"); + }); + + it("publishes cleanup before package installation can fail", () => { + const fixture = createFixture(); + writeServerInstallTools(fixture, 200); + writeExecutable(join(fixture.binDir, "npm"), "#!/bin/sh\nexit 19\n"); + const result = runScript(JOIN_ARGS, fixture, { + BB_INSTALL_SKIP_SERVICE: "1", + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Could not install bb-app"); + const shim = join(fixture.homeDir, ".local", "bin", "bb"); + expect(existsSync(shim)).toBe(true); + const cleanup = spawnSync( + shim, + ["machine", "uninstall", "--host-id", "host-test"], + { env: createScriptEnv(fixture, {}), encoding: "utf8" }, + ); + expect(cleanup.status, cleanup.stderr).toBe(0); + expect(existsSync(join(fixture.dataDir, "auth.json"))).toBe(false); + expect(existsSync(join(fixture.dataDir, "install-daemon.pid"))).toBe(false); + }); + + it.each([{ container: false }, { container: true }])( + "enrolls privately with portable service selection (%j)", + ({ container }) => { + const fixture = createFixture(); + writeCurlArtifactMock(fixture, 404); + writeEnrollingBbApp( + fixture, + join(fixture.dataDir, "daemon-invocation"), + "host-test", + ); + writeExecutable( + join(fixture.binDir, "bb"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const bundle = JSON.parse(process.env.BB_ENROLLMENT); +fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "enrollment-argv"), JSON.stringify(process.argv.slice(2))); +fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "auth.json"), JSON.stringify({hostId: bundle.hostId, hostKey: "durable-test"})); +fs.writeFileSync(path.join(process.env.BB_DATA_DIR, "config.json"), JSON.stringify({serverUrl: bundle.serverUrl})); +`, + ); + if (container) { + writeExecutable( + join(fixture.binDir, "uname"), + "#!/bin/sh\necho Linux\n", + ); + writeExecutable(join(fixture.binDir, "id"), "#!/bin/sh\necho 0\n"); + writeExecutable( + join(fixture.binDir, "ps"), + "#!/bin/sh\necho systemd\n", + ); + writeExecutable( + join(fixture.binDir, "systemd-detect-virt"), + "#!/bin/sh\nexit 0\n", + ); + writeExecutable( + join(fixture.binDir, "systemctl"), + "#!/bin/sh\nexit 1\n", + ); + } + const result = runScript(["--bootstrap-env", "TEST_BUNDLE"], fixture, { + BB_INSTALL_SKIP_SERVICE: container ? "0" : "1", + TEST_BUNDLE: JSON.stringify({ + hostId: "host-test", + serverUrl: "https://machine.getbb.app", + credential: "private-bootstrap-test", + expiresAt: Date.now() + 60_000, + }), + }); + const pidPath = join(fixture.dataDir, "install-daemon.pid"); + try { + expect(result.status, result.stderr).toBe(0); + expect( + JSON.parse( + readFileSync(join(fixture.dataDir, "enrollment-argv"), "utf8"), + ), + ).toEqual(["machine", "enroll", "--bootstrap-env", "BB_ENROLLMENT"]); + expect(result.stdout + result.stderr).not.toContain( + "private-bootstrap-test", + ); + expect( + spawnSync("sh", ["-n", join(fixture.homeDir, ".local/bin/bb")]) + .status, + ).toBe(0); + } finally { + if (existsSync(pidPath)) { + try { + process.kill(Number(readFileSync(pidPath, "utf8")), "SIGTERM"); + } catch (error) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "ESRCH" + ) + ) + throw error; + } + } + } + }, + ); + it("uses bb-app from PATH and passes the launcher join flags verbatim", () => { const fixture = createFixture(); const invocationPath = join(fixture.dataDir, "invocation"); @@ -346,6 +545,9 @@ describe("machine install script", () => { "--server-url", "https://machine.getbb.app", ]); + expect( + JSON.parse(readFileSync(join(fixture.dataDir, "auth.json"), "utf8")), + ).toEqual({ hostId: "host-test", hostKey: "secret" }); const daemonPid = Number( readFileSync(join(fixture.dataDir, "install-daemon.pid"), "utf8"), ); @@ -651,59 +853,6 @@ describe("machine install script", () => { } }); - it("atomically reserves different ports for concurrent custom data directories", async () => { - const fixture = createFixture(); - const firstDataDir = join(fixture.homeDir, "custom-machine-one"); - const secondDataDir = join(fixture.homeDir, "custom-machine-two"); - mkdirSync(firstDataDir, { recursive: true }); - mkdirSync(secondDataDir, { recursive: true }); - const firstFixture = { ...fixture, dataDir: firstDataDir }; - const secondFixture = { ...fixture, dataDir: secondDataDir }; - writeJoinedState(firstFixture); - writeJoinedState(secondFixture); - writeCurlArtifactMock(fixture, 404); - writeExecutable( - join(fixture.binDir, "bb-app"), - createEnrollingBbAppScript({ hostId: "host-test" }), - ); - - const [firstResult, secondResult] = await Promise.all([ - runScriptAsync(JOIN_ARGS, firstFixture, { BB_INSTALL_SKIP_SERVICE: "1" }), - runScriptAsync(JOIN_ARGS, secondFixture, { - BB_INSTALL_SKIP_SERVICE: "1", - }), - ]); - - expect(firstResult.status, firstResult.stderr).toBe(0); - expect(secondResult.status, secondResult.stderr).toBe(0); - const firstPort = readFileSync( - join(firstDataDir, "host-daemon-port"), - "utf8", - ).trim(); - const secondPort = readFileSync( - join(secondDataDir, "host-daemon-port"), - "utf8", - ).trim(); - expect(firstPort).not.toBe(secondPort); - const registryDir = join(fixture.homeDir, ".bb-machines/host-daemon-ports"); - expect( - new Set([ - readFileSync(join(registryDir, firstPort, "data-dir"), "utf8").trim(), - readFileSync(join(registryDir, secondPort, "data-dir"), "utf8").trim(), - ]), - ).toEqual( - new Set([realpathSync(firstDataDir), realpathSync(secondDataDir)]), - ); - process.kill( - Number(readFileSync(join(firstDataDir, "install-daemon.pid"), "utf8")), - "SIGTERM", - ); - process.kill( - Number(readFileSync(join(secondDataDir, "install-daemon.pid"), "utf8")), - "SIGTERM", - ); - }); - it("redeems and persists a connect machine code before joining through the tunnel", () => { const fixture = createFixture(); const invocationPath = join(fixture.dataDir, "invocation"); @@ -832,18 +981,18 @@ fi "service " + join( fixture.homeDir, - "Library/LaunchAgents/app.getbb.host-daemon.machine-getbb-app.plist", + "Library/LaunchAgents/app.getbb.host-daemon.machine-getbb-app-host-test.plist", ), ); const plist = readFileSync( join( fixture.homeDir, - "Library/LaunchAgents/app.getbb.host-daemon.machine-getbb-app.plist", + "Library/LaunchAgents/app.getbb.host-daemon.machine-getbb-app-host-test.plist", ), "utf8", ); expect(plist).toContain( - "app.getbb.host-daemon.machine-getbb-app", + "app.getbb.host-daemon.machine-getbb-app-host-test", ); expect(plist).toContain("RunAtLoad"); expect(plist).toContain("KeepAlive"); @@ -862,7 +1011,7 @@ fi ); const serviceFile = join( fixture.homeDir, - "Library/LaunchAgents/app.getbb.host-daemon.machine-getbb-app.plist", + "Library/LaunchAgents/app.getbb.host-daemon.machine-getbb-app-host-test.plist", ); const domain = `gui/${process.getuid?.()}`; expect(readFileSync(join(fixture.dataDir, "launchctl.log"), "utf8")).toBe( @@ -873,6 +1022,54 @@ fi ).toBe("start\nstart\n"); }); + it("replaces a matching legacy macOS launch agent with exactly one host service", () => { + const fixture = createFixture(); + writeJoinedState(fixture); + writeServerInstallTools(fixture, 200); + writeExecutable(join(fixture.binDir, "uname"), "#!/bin/sh\necho Darwin\n"); + const serviceDir = join(fixture.homeDir, "Library/LaunchAgents"); + mkdirSync(serviceDir, { recursive: true }); + const legacyServiceFile = join( + serviceDir, + "app.getbb.host-daemon.machine-getbb-app.plist", + ); + writeFileSync(join(fixture.dataDir, "host-daemon-port"), "45123\n"); + writeFileSync( + legacyServiceFile, + ` +ProgramArgumentshost-daemon--host-daemon-port45123 +EnvironmentVariablesBB_DATA_DIR${fixture.dataDir} + +`, + ); + writeExecutable( + join(fixture.binDir, "launchctl"), + `#!/bin/sh +printf '%s\n' "$*" >>"${join(fixture.dataDir, "launchctl.log")}" +if [ "$1" = bootstrap ]; then + BB_DATA_DIR="${fixture.dataDir}" "${join(fixture.dataDir, "npm/bin/bb-app")}" host-daemon --host-daemon-port 45123 --server-url https://machine.getbb.app >/dev/null 2>&1 & + echo $! >"${join(fixture.dataDir, "service-daemon.pid")}" +fi +`, + ); + + const result = runScript(JOIN_ARGS, fixture); + + expect(result.status, result.stderr).toBe(0); + expect(existsSync(legacyServiceFile)).toBe(false); + expect( + readdirSync(serviceDir).filter((file) => file.endsWith(".plist")), + ).toEqual(["app.getbb.host-daemon.machine-getbb-app-host-test.plist"]); + const serviceFile = join( + serviceDir, + "app.getbb.host-daemon.machine-getbb-app-host-test.plist", + ); + const domain = `gui/${process.getuid?.()}`; + expect(readFileSync(join(fixture.dataDir, "launchctl.log"), "utf8")).toBe( + `bootout ${domain} ${legacyServiceFile}\nbootout ${domain} ${serviceFile}\nbootstrap ${domain} ${serviceFile}\n`, + ); + }); + it("reports launchctl bootstrap failures", () => { const fixture = createFixture(); writeJoinedState(fixture); @@ -893,7 +1090,7 @@ fi expect(result.status).toBe(1); expect(result.stderr).toContain( - "Could not register the bb host-daemon launch agent app.getbb.host-daemon.machine-getbb-app.", + "Could not register the bb host-daemon launch agent app.getbb.host-daemon.machine-getbb-app-host-test.", ); expect(result.stderr).toContain("launchctl: fixture bootstrap failure"); }); @@ -934,7 +1131,7 @@ printf '%s\n' "$*" >>"${join(fixture.dataDir, "launchctl.log")}" join(fixture.binDir, "systemctl"), `#!/bin/sh printf '%s\n' "$*" >>"${join(fixture.dataDir, "systemctl.log")}" -if [ "$*" = "--user restart bb-host-daemon-machine-getbb-app.service" ]; then +if [ "$*" = "--user restart bb-host-daemon-machine-getbb-app-host-test.service" ]; then port=$(sed -n '1p' "${join(fixture.dataDir, "host-daemon-port")}") BB_DATA_DIR="${fixture.dataDir}" "${join(fixture.dataDir, "npm/bin/bb-app")}" host-daemon --host-daemon-port "$port" --server-url https://machine.getbb.app >/dev/null 2>&1 & echo $! >"${join(fixture.dataDir, "service-daemon.pid")}" @@ -962,7 +1159,7 @@ fi const unit = readFileSync( join( fixture.homeDir, - ".config/systemd/user/bb-host-daemon-machine-getbb-app.service", + ".config/systemd/user/bb-host-daemon-machine-getbb-app-host-test.service", ), "utf8", ); @@ -977,7 +1174,129 @@ fi `Environment="BB_APP_NPM_PREFIX=${realpathSync(fixture.dataDir)}/npm"`, ); expect(readFileSync(join(fixture.dataDir, "systemctl.log"), "utf8")).toBe( - "--user daemon-reload\n--user enable bb-host-daemon-machine-getbb-app.service\n--user restart bb-host-daemon-machine-getbb-app.service\n", + "--user daemon-reload\n--user enable bb-host-daemon-machine-getbb-app-host-test.service\n--user restart bb-host-daemon-machine-getbb-app-host-test.service\n", + ); + }); + + it.each([false, true])( + "installs a persistent root system unit (container=%s)", + (container) => { + const fixture = createFixture(); + writeJoinedState(fixture); + writeServerInstallTools(fixture, 200); + writeExecutable(join(fixture.binDir, "uname"), "#!/bin/sh\necho Linux\n"); + writeExecutable(join(fixture.binDir, "id"), "#!/bin/sh\necho 0\n"); + writeExecutable(join(fixture.binDir, "ps"), "#!/bin/sh\necho systemd\n"); + writeExecutable( + join(fixture.binDir, "systemd-detect-virt"), + `#!/bin/sh\nexit ${container ? 0 : 1}\n`, + ); + const scope = container ? "--user" : "--system"; + writeExecutable( + join(fixture.binDir, "systemctl"), + `#!/bin/sh +printf '%s\n' "$*" >>"${join(fixture.dataDir, "systemctl.log")}" +if [ "$*" = "${scope} restart bb-host-daemon-machine-getbb-app-host-test.service" ]; then + port=$(sed -n '1p' "${join(fixture.dataDir, "host-daemon-port")}") + BB_DATA_DIR="${fixture.dataDir}" "${join(fixture.dataDir, "npm/bin/bb-app")}" host-daemon --host-daemon-port "$port" --server-url https://machine.getbb.app >/dev/null 2>&1 & + echo $! >"${join(fixture.dataDir, "service-daemon.pid")}" +fi +`, + ); + + const result = runScript( + [ + "--join-code", + "unused-fresh-code", + "--host-id", + "host-test", + "--server", + "https://machine.getbb.app", + ], + fixture, + ); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("already joined"); + expect(result.stdout).toContain( + "Waiting for the systemd service to connect", + ); + const unit = readFileSync( + container + ? join( + fixture.homeDir, + ".config/systemd/user/bb-host-daemon-machine-getbb-app-host-test.service", + ) + : join( + fixture.dataDir, + "systemd/bb-host-daemon-machine-getbb-app-host-test.service", + ), + "utf8", + ); + const selectedPort = readFileSync( + join(fixture.dataDir, "host-daemon-port"), + "utf8", + ).trim(); + expect(unit).toContain( + `host-daemon --auto-update --host-daemon-port "${selectedPort}" --server-url "https://machine.getbb.app"`, + ); + expect(unit).toContain( + `Environment="BB_APP_NPM_PREFIX=${realpathSync(fixture.dataDir)}/npm"`, + ); + expect(unit).toContain( + container ? "WantedBy=default.target" : "WantedBy=multi-user.target", + ); + const enableUnit = container + ? "bb-host-daemon-machine-getbb-app-host-test.service" + : join( + realpathSync(fixture.dataDir), + "systemd/bb-host-daemon-machine-getbb-app-host-test.service", + ); + expect(readFileSync(join(fixture.dataDir, "systemctl.log"), "utf8")).toBe( + `${scope} daemon-reload\n${scope} enable ${enableUnit}\n${scope} restart bb-host-daemon-machine-getbb-app-host-test.service\n`, + ); + }, + ); + + it("replaces a matching legacy systemd unit with exactly one host service", () => { + const fixture = createFixture(); + writeJoinedState(fixture); + writeServerInstallTools(fixture, 200); + writeExecutable(join(fixture.binDir, "uname"), "#!/bin/sh\necho Linux\n"); + const serviceDir = join(fixture.homeDir, ".config/systemd/user"); + mkdirSync(serviceDir, { recursive: true }); + const legacyServiceFile = join( + serviceDir, + "bb-host-daemon-machine-getbb-app.service", + ); + writeFileSync(join(fixture.dataDir, "host-daemon-port"), "45123\n"); + writeFileSync( + legacyServiceFile, + `[Service] +ExecStart="node" "bb-app" host-daemon --auto-update --host-daemon-port "45123" --server-url "https://machine.getbb.app" +Environment="BB_DATA_DIR=${fixture.dataDir}" +`, + ); + writeExecutable( + join(fixture.binDir, "systemctl"), + `#!/bin/sh +printf '%s\n' "$*" >>"${join(fixture.dataDir, "systemctl.log")}" +if [ "$*" = "--user restart bb-host-daemon-machine-getbb-app-host-test.service" ]; then + BB_DATA_DIR="${fixture.dataDir}" "${join(fixture.dataDir, "npm/bin/bb-app")}" host-daemon --host-daemon-port 45123 --server-url https://machine.getbb.app >/dev/null 2>&1 & + echo $! >"${join(fixture.dataDir, "service-daemon.pid")}" +fi +`, + ); + + const result = runScript(JOIN_ARGS, fixture); + + expect(result.status, result.stderr).toBe(0); + expect(existsSync(legacyServiceFile)).toBe(false); + expect( + readdirSync(serviceDir).filter((file) => file.endsWith(".service")), + ).toEqual(["bb-host-daemon-machine-getbb-app-host-test.service"]); + expect(readFileSync(join(fixture.dataDir, "systemctl.log"), "utf8")).toBe( + "--user disable --now bb-host-daemon-machine-getbb-app.service\n--user daemon-reload\n--user enable bb-host-daemon-machine-getbb-app-host-test.service\n--user restart bb-host-daemon-machine-getbb-app-host-test.service\n", ); }); }); diff --git a/apps/server/test/app/skeleton.test.ts b/apps/server/test/app/skeleton.test.ts index ec0d7113df..86077bd4ee 100644 --- a/apps/server/test/app/skeleton.test.ts +++ b/apps/server/test/app/skeleton.test.ts @@ -148,7 +148,6 @@ describe("server skeleton", () => { hostId: "host-1", instanceId: "instance-1", hostName: "Host", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-data", diff --git a/apps/server/test/app/watch-interests.test.ts b/apps/server/test/app/watch-interests.test.ts index 2fe6a4c738..6855ebf9fb 100644 --- a/apps/server/test/app/watch-interests.test.ts +++ b/apps/server/test/app/watch-interests.test.ts @@ -25,7 +25,6 @@ function setup() { const hub = new NotificationHub(); const watchInterests = new WatchInterestCoordinator({ db, hub }); const host = upsertHost(db, noopNotifier, { - type: "persistent", name: "test-host", }); const { project } = createProject(db, noopNotifier, { diff --git a/apps/server/test/environments/workspace-read-cache-routes.test.ts b/apps/server/test/environments/workspace-read-cache-routes.test.ts index 51bf709e9e..1ff719aef7 100644 --- a/apps/server/test/environments/workspace-read-cache-routes.test.ts +++ b/apps/server/test/environments/workspace-read-cache-routes.test.ts @@ -1,3 +1,4 @@ +import { updateHost } from "@bb/db"; import { describe, expect, it } from "vitest"; import type { GitHostPullRequest, WorkspaceWorkingTree } from "@bb/domain"; import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; @@ -330,3 +331,25 @@ describe("workspace read caches on the environment routes", () => { }); }); }); + +it.each(["suspended", "suspending"] as const)( + "does not send passive status or PR RPCs to a %s machine", + async (phase) => { + await withTestHarness(async (h) => { + const { host, environment } = seedGitEnvironment(h, phase); + updateHost(h.db, h.hub, host.id, { + phase, + machineProviderId: "test-paused-provider", + suspendedAt: phase === "suspended" ? Date.now() : null, + }); + for (const path of ["status", "pull-request"]) { + const response = await h.app.request( + `/api/v1/environments/${environment.id}/${path}`, + ); + expect(response.status).not.toBe(404); + expect(listQueuedCommands(h, "workspace.status")).toHaveLength(0); + expect(listQueuedCommands(h, "workspace.pull_request")).toHaveLength(0); + } + }); + }, +); diff --git a/apps/server/test/helpers/commands.ts b/apps/server/test/helpers/commands.ts index 019e6debf3..f78df6071d 100644 --- a/apps/server/test/helpers/commands.ts +++ b/apps/server/test/helpers/commands.ts @@ -12,7 +12,7 @@ import { hostDaemonServerWsMessageSchema, parseHostDaemonRpcResultForCommand, } from "@bb/host-daemon-contract"; -import { type HostType, type ThreadEvent } from "@bb/domain"; +import { type ThreadEvent } from "@bb/domain"; import type { HostDaemonCommand, HostDaemonEventEnvelope, @@ -300,12 +300,11 @@ export function createTestDaemonEventEnvelope( export function internalAuthHeaders( harness: TestAppHarness, - args: { hostId?: string; hostType?: HostType } = {}, + args: { hostId?: string } = {}, ): HeadersInit { const activeSessions = harness.db .select({ hostId: hostDaemonSessions.hostId, - hostType: hostDaemonSessions.hostType, }) .from(hostDaemonSessions) .where(eq(hostDaemonSessions.status, "active")) @@ -316,7 +315,6 @@ export function internalAuthHeaders( return { authorization: `Bearer ${createTestDaemonHostKey({ hostId: args.hostId ?? inferredHost?.hostId ?? "host-1", - hostType: args.hostType ?? inferredHost?.hostType ?? "persistent", })}`, "content-type": "application/json", }; @@ -353,6 +351,10 @@ export function registerTestHostRpcCapture( close() {}, send(data) { const message = hostDaemonServerWsMessageSchema.parse(JSON.parse(data)); + if (message.type === "machine.shutdown") { + deps.hub.unregisterDaemon(args.sessionId); + return; + } if (message.type !== "host-rpc.request") { return; } @@ -554,7 +556,7 @@ export async function reportQueuedCommandSuccess< harness: TestAppHarness, queued: QueuedCommand, result: QueuedCommandResult, - args: { hostId?: string; hostType?: HostType } = {}, + args: { hostId?: string } = {}, ): Promise { const sessionId = queued.row.sessionId; if (!sessionId) { @@ -610,7 +612,7 @@ export async function reportQueuedCommandError( harness: TestAppHarness, queued: QueuedCommand, args: { errorCode: string; errorMessage: string }, - auth: { hostId?: string; hostType?: HostType } = {}, + auth: { hostId?: string } = {}, ): Promise { const sessionId = queued.row.sessionId; if (!sessionId) { diff --git a/apps/server/test/helpers/seed.ts b/apps/server/test/helpers/seed.ts index 485a4e1695..76426290ff 100644 --- a/apps/server/test/helpers/seed.ts +++ b/apps/server/test/helpers/seed.ts @@ -82,7 +82,6 @@ export function seedHost( } = {}, ) { return upsertHost(deps.db, deps.hub, { - type: "persistent", ...(args.connectMachineId !== undefined ? { connectMachineId: args.connectMachineId } : {}), @@ -112,7 +111,6 @@ export function seedSession(deps: Pick, hostId: string) { hostId, instanceId: "instance-1", hostName: "Test Host", - hostType: "persistent", dataDir: `/tmp/bb-host-data/${hostId}`, protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, heartbeatIntervalMs: 5_000, diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 9541a713f0..1964d12f3a 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { serve } from "@hono/node-server"; import type { AddressInfo } from "node:net"; import { createConnection, getAppSettings, type DbConnection } from "@bb/db"; -import { defaultFeatureFlags, type HostType } from "@bb/domain"; +import { defaultFeatureFlags } from "@bb/domain"; import { initDb } from "../../src/db.js"; import { createApp } from "../../src/server.js"; import { PendingInteractionLifecycle } from "../../src/services/interactions/pending-interactions.js"; @@ -89,29 +89,24 @@ export const testLogger = { interface TestDaemonKeyParts { hostId: string; - hostType: HostType; } function encodeTestDaemonKey(args: TestDaemonKeyParts): string { - return `${TEST_MACHINE_KEY_PREFIX}:${args.hostType}:${args.hostId}`; + return `${TEST_MACHINE_KEY_PREFIX}:${args.hostId}`; } function decodeTestDaemonKey(token: string): TestDaemonKeyParts | null { const parts = token.split(":"); - if (parts.length !== 3 || parts[0] !== TEST_MACHINE_KEY_PREFIX) { + if (parts.length !== 2 || parts[0] !== TEST_MACHINE_KEY_PREFIX) { return null; } - const hostType = parts[1]; - const hostId = parts[2]; - if (hostType !== "persistent" || hostId.length === 0) { + const hostId = parts[1]; + if (hostId.length === 0) { return null; } - return { - hostId, - hostType, - }; + return { hostId }; } export function createTestDaemonHostKey( @@ -119,7 +114,6 @@ export function createTestDaemonHostKey( ): string { return encodeTestDaemonKey({ hostId: args.hostId ?? "host-1", - hostType: args.hostType ?? "persistent", }); } diff --git a/apps/server/test/host-join-enroll.test.ts b/apps/server/test/host-join-enroll.test.ts index e315c64e91..f30515915c 100644 --- a/apps/server/test/host-join-enroll.test.ts +++ b/apps/server/test/host-join-enroll.test.ts @@ -157,7 +157,6 @@ describe("host enroll routes", () => { body: JSON.stringify({ hostId: enrollKeyBody.hostId, hostName: "real-host-name", - hostType: "persistent", }), }, ); @@ -186,7 +185,6 @@ describe("host enroll routes", () => { body: JSON.stringify({ hostId: enrollKeyBody.hostId, hostName: "real-host-name", - hostType: "persistent", }), }, ); @@ -211,7 +209,6 @@ describe("host enroll routes", () => { body: JSON.stringify({ hostId: "host_other", hostName: "wrong-host", - hostType: "persistent", }), }); @@ -245,7 +242,6 @@ describe("host enroll routes", () => { body: JSON.stringify({ hostId: firstEnrollKeyBody.hostId, hostName: "stale-enroll-key-host", - hostType: "persistent", }), }, ); @@ -263,7 +259,6 @@ describe("host enroll routes", () => { body: JSON.stringify({ hostId: secondEnrollKeyBody.hostId, hostName: "fresh-enroll-key-host", - hostType: "persistent", }), }, ); @@ -310,7 +305,6 @@ describe("host enroll routes", () => { body: JSON.stringify({ hostId: enrollKeyBody.hostId, hostName: "expired-host", - hostType: "persistent", }), }, ); diff --git a/apps/server/test/hosts/online-rpc.test.ts b/apps/server/test/hosts/online-rpc.test.ts index de666f43e3..767756d43a 100644 --- a/apps/server/test/hosts/online-rpc.test.ts +++ b/apps/server/test/hosts/online-rpc.test.ts @@ -4,13 +4,14 @@ import { type HostDaemonOnlineRpcRequestMessage, type HostDaemonOnlineRpcResult, } from "@bb/host-daemon-contract"; -import { hostDaemonSessions } from "@bb/db"; +import { hostDaemonSessions, updateHost } from "@bb/db"; import { eq } from "drizzle-orm"; import { describe, expect, it, vi } from "vitest"; import { ApiError } from "../../src/errors.js"; import { callHostOnlineRpc, callHostRetryableOnlineRpc, + callHostRetryableOnlineRpcForWork, } from "../../src/services/hosts/online-rpc.js"; import type { NotificationHub } from "../../src/ws/hub.js"; import { @@ -75,6 +76,45 @@ function registerDropThenReplaceSocket(args: DropThenReplaceSocketArgs): void { } describe("host online RPC retry semantics", () => { + it("classifies suspended hosts before sending read-only RPCs", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-online-rpc-suspended-read", + }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: 123, + }); + const request = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + + await expect( + callHostRetryableOnlineRpc(harness.deps, { + hostId: host.id, + timeoutMs: 1_000, + command: { + type: "provider.list_models", + providerId: "codex", + bridgeLaunch: TRANSPORT_TEST_BRIDGE_LAUNCH, + }, + }), + ).rejects.toMatchObject({ + status: 502, + body: { + code: "host_unavailable", + message: "Host is suspended", + details: { + reason: "suspended", + hostStatus: "disconnected", + suspendedAt: 123, + destroyedAt: null, + }, + retryable: false, + }, + }); + expect(request).not.toHaveBeenCalled(); + }); + }); + it("runs a retryable RPC when the daemon websocket is still registered with a stale lease", async () => { await withTestHarness(async (harness) => { const { host, session } = seedHostSession(harness.deps, { @@ -130,7 +170,7 @@ describe("host online RPC retry semantics", () => { }); }); - it("waits briefly for retryable RPCs when the session is active before the daemon websocket registers", async () => { + it("waits briefly for retryable work when the session is active before the daemon websocket registers", async () => { await withTestHarness(async (harness) => { const { host, session } = seedHostSession(harness.deps, { id: "host-online-rpc-registration-race", @@ -160,7 +200,7 @@ describe("host online RPC retry semantics", () => { }, 10); await expect( - callHostRetryableOnlineRpc(harness.deps, { + callHostRetryableOnlineRpcForWork(harness.deps, { hostId: host.id, timeoutMs: 1_000, command: { @@ -176,7 +216,7 @@ describe("host online RPC retry semantics", () => { }); }); - it("retries read-only online RPCs when the current websocket session disappears", async () => { + it("retries admitted work when the current websocket session disappears", async () => { await withTestHarness(async (harness) => { const { host, session } = seedHostSession(harness.deps, { id: "host-online-rpc-read-retry", @@ -191,7 +231,7 @@ describe("host online RPC retry semantics", () => { }); await expect( - callHostRetryableOnlineRpc(harness.deps, { + callHostRetryableOnlineRpcForWork(harness.deps, { hostId: host.id, timeoutMs: 1_000, command: { diff --git a/apps/server/test/internal/background-task-reconciliation.test.ts b/apps/server/test/internal/background-task-reconciliation.test.ts index d1110f96a1..0894e245e0 100644 --- a/apps/server/test/internal/background-task-reconciliation.test.ts +++ b/apps/server/test/internal/background-task-reconciliation.test.ts @@ -301,13 +301,11 @@ describe("background-task lifecycle reconciliation triggers", () => { method: "POST", headers: internalAuthHeaders(harness, { hostId: host.id, - hostType: host.type, }), body: JSON.stringify({ hostId: host.id, instanceId: "instance-restarted", hostName: host.name, - hostType: host.type, hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-daemon-task-settle-restart", @@ -339,13 +337,11 @@ describe("background-task lifecycle reconciliation triggers", () => { method: "POST", headers: internalAuthHeaders(harness, { hostId: host.id, - hostType: host.type, }), body: JSON.stringify({ hostId: host.id, instanceId: "instance-1", hostName: host.name, - hostType: host.type, hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-daemon-task-settle-same-instance", @@ -370,13 +366,11 @@ describe("background-task lifecycle reconciliation triggers", () => { method: "POST", headers: internalAuthHeaders(harness, { hostId: host.id, - hostType: host.type, }), body: JSON.stringify({ hostId: host.id, instanceId: session.instanceId, hostName: host.name, - hostType: host.type, hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-daemon-task-live-same-instance", @@ -405,13 +399,11 @@ describe("background-task lifecycle reconciliation triggers", () => { method: "POST", headers: internalAuthHeaders(harness, { hostId: host.id, - hostType: host.type, }), body: JSON.stringify({ hostId: host.id, instanceId: "instance-restarted", hostName: host.name, - hostType: host.type, hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-daemon-task-live-restarted", @@ -466,13 +458,11 @@ describe("active thread disconnect reconciliation triggers", () => { method: "POST", headers: internalAuthHeaders(harness, { hostId: host.id, - hostType: host.type, }), body: JSON.stringify({ hostId: host.id, instanceId: "instance-1", hostName: host.name, - hostType: host.type, hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-daemon-active-same-instance", @@ -504,13 +494,11 @@ describe("active thread disconnect reconciliation triggers", () => { method: "POST", headers: internalAuthHeaders(harness, { hostId: host.id, - hostType: host.type, }), body: JSON.stringify({ hostId: host.id, instanceId, hostName: host.name, - hostType: host.type, hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-daemon-active-restarted-instance", diff --git a/apps/server/test/internal/internal-session-protocol-version.test.ts b/apps/server/test/internal/internal-session-protocol-version.test.ts index 837183a16c..4680ab86a1 100644 --- a/apps/server/test/internal/internal-session-protocol-version.test.ts +++ b/apps/server/test/internal/internal-session-protocol-version.test.ts @@ -3,26 +3,105 @@ import { createHostDaemonClient, } from "@bb/host-daemon-contract"; import { describe, expect, it } from "vitest"; -import { getHost, upsertHost } from "@bb/db"; +import { getHost, updateHost, upsertHost } from "@bb/db"; import { createTestDaemonHostKey, startTestServer, } from "../helpers/test-app.js"; describe("internal session protocol version", () => { + it.each(["suspending", "suspended"] as const)( + "rejects a session open while the machine is %s", + async (phase) => { + const server = await startTestServer(); + try { + const hostId = `host-${phase}`; + const hostKey = createTestDaemonHostKey({ hostId }); + upsertHost(server.db, server.hub, { id: hostId, name: "Paused Host" }); + updateHost(server.db, server.hub, hostId, { phase }); + + const response = await fetch( + `${server.baseUrl}/internal/session/open`, + { + method: "POST", + headers: { + authorization: `Bearer ${hostKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + hostId, + instanceId: `instance-${phase}`, + hostName: "Paused Host", + hasMachineCredential: true, + platform: "linux", + dataDir: `/tmp/${hostId}`, + localApiPort: 38_888, + protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, + activeThreads: [], + loadedEnvironments: [], + }), + }, + ); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: "machine_suspended", + message: + "Machine daemon sessions are disabled while the machine is suspending or suspended", + }); + } finally { + await server.close(); + } + }, + ); + + it("requires a PR-1 version 191 daemon to upgrade before accepting its session", async () => { + const server = await startTestServer(); + try { + const hostId = "host-pr2-only"; + upsertHost(server.db, server.hub, { id: hostId, name: "PR 2 daemon" }); + const daemon = createHostDaemonClient( + server.baseUrl, + createTestDaemonHostKey({ hostId }), + ); + const response = await daemon.session.open.$post({ + json: { + hostId, + instanceId: "instance-pr2", + hostName: "PR 2 daemon", + hasMachineCredential: true, + platform: "linux", + dataDir: "/tmp/pr2-machine", + localApiPort: 38888, + protocolVersion: 191, + activeThreads: [], + loadedEnvironments: [], + }, + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + code: "protocol_version_mismatch", + details: { serverProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION }, + message: `Daemon protocol version 191 does not match server protocol version ${HOST_DAEMON_PROTOCOL_VERSION}`, + }); + expect(getHost(server.db, hostId)?.lastRejectedProtocolVersion).toBe(191); + } finally { + await server.close(); + } + }); + it("rejects a session open whose protocol version does not match the server", async () => { const server = await startTestServer(); try { const hostKey = createTestDaemonHostKey({ hostId: "host-protocol" }); upsertHost(server.db, server.hub, { - type: "persistent", id: "host-protocol", name: "Protocol Host", }); const daemonClient = createHostDaemonClient(server.baseUrl, hostKey); const staleProtocolVersion = HOST_DAEMON_PROTOCOL_VERSION - 1; - const protocol186Response = await fetch( + const priorProtocolResponse = await fetch( `${server.baseUrl}/internal/session/open`, { method: "POST", @@ -32,31 +111,31 @@ describe("internal session protocol version", () => { }, body: JSON.stringify({ hostId: "host-protocol", - instanceId: "instance-protocol-186", + instanceId: "instance-protocol-pr1", hostName: "Protocol Host", hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-protocol-data", localApiPort: 38_888, - protocolVersion: 186, + protocolVersion: 188, activeThreads: [], loadedEnvironments: [], }), }, ); - expect(protocol186Response.status).toBe(400); - expect(await protocol186Response.json()).toMatchObject({ + expect(priorProtocolResponse.status).toBe(400); + expect(await priorProtocolResponse.json()).toMatchObject({ code: "protocol_version_mismatch", details: { retryUpdate: false, serverProtocolVersion: HOST_DAEMON_PROTOCOL_VERSION, }, - message: `Daemon protocol version 186 does not match server protocol version ${HOST_DAEMON_PROTOCOL_VERSION}`, + message: `Daemon protocol version 188 does not match server protocol version ${HOST_DAEMON_PROTOCOL_VERSION}`, }); expect( getHost(server.db, "host-protocol")?.lastRejectedProtocolVersion, - ).toBe(186); + ).toBe(188); const preLocalApiPortProtocolVersion = 139; const oldDaemonResponse = await fetch( @@ -71,7 +150,6 @@ describe("internal session protocol version", () => { hostId: "host-protocol", instanceId: "instance-pre-local-api-port", hostName: "Protocol Host", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-protocol-data", @@ -99,7 +177,6 @@ describe("internal session protocol version", () => { hostId: "host-protocol", instanceId: "instance-1", hostName: "Protocol Host", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-protocol-data", @@ -136,7 +213,6 @@ describe("internal session protocol version", () => { hostId: "host-protocol", instanceId: "instance-retry", hostName: "Protocol Host", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-protocol-data", @@ -155,7 +231,6 @@ describe("internal session protocol version", () => { hostId: "host-protocol", instanceId: "instance-retry-consumed", hostName: "Protocol Host", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-protocol-data", @@ -174,7 +249,6 @@ describe("internal session protocol version", () => { hostId: "host-protocol", instanceId: "instance-2", hostName: "Protocol Host", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/host-protocol-data", diff --git a/apps/server/test/lifecycle-policy.test.ts b/apps/server/test/lifecycle-policy.test.ts new file mode 100644 index 0000000000..66219fb082 --- /dev/null +++ b/apps/server/test/lifecycle-policy.test.ts @@ -0,0 +1,277 @@ +import { getHost, updateHost } from "@bb/db"; +import type { HostDaemonOnlineRpcRequestMessage } from "@bb/host-daemon-contract"; +import { validatePluginMachineProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setPluginMachineProviderBridge } from "../src/services/plugins/plugin-machine-provider-registry.js"; +import { registerHostRpcResponder } from "./helpers/host-rpc.js"; +import { readJson } from "./helpers/json.js"; +import { + seedEnvironment, + seedHostSession, + seedProjectWithSource, + seedThread, + seedThreadFixture, + seedThreadRuntimeState, +} from "./helpers/seed.js"; +import { type TestAppHarness, withTestHarness } from "./helpers/test-app.js"; + +function installMachineProvider(harness: TestAppHarness, hostId: string) { + const resume = vi.fn(async () => ({ resource: { id: "owned" } })); + const record = { + pluginId: "test-machine", + provider: validatePluginMachineProviderDeclaration({ + description: "Provision a test machine.", + icon: "Terminal", + id: "test-machine", + displayName: "Test machine", + reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + name: "Test machine", + hostId, + resource: { id: "owned" }, + }), + suspend: async () => ({ resource: { id: "owned" } }), + resume, + remove: async () => ({ status: "removed" }), + }), + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [record], + getMachineProvider: () => record, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + updateHost(harness.db, harness.hub, hostId, { + machineProviderId: "test-machine", + resource: { id: "owned" }, + }); + return { + resume, + suspend() { + updateHost(harness.db, harness.hub, hostId, { + phase: "suspended", + suspendedAt: Date.now(), + }); + }, + }; +} + +afterEach(() => { + setPluginMachineProviderBridge(undefined); + vi.useRealTimers(); +}); + +describe.sequential("suspended machine lifecycle policy", () => { + it("does not resume for execution options", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const machine = installMachineProvider(harness, host.id); + machine.suspend(); + const rpc = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + + const response = await harness.app.request( + `/api/v1/system/execution-options?hostId=${host.id}&providerId=codex`, + ); + + expect(response.status).toBe(200); + expect(machine.resume).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + }); + }); + + it("does not resume while computing environment provider availability", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const machine = installMachineProvider(harness, host.id); + machine.suspend(); + const rpc = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + + const response = await harness.app.request( + `/api/v1/system/environment-providers?projectId=${project.id}&hostId=${host.id}`, + ); + + expect(response.status).toBe(200); + expect(machine.resume).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + }); + }); + + it("uses the persisted daemon data directory for thread storage location", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + }); + const thread = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + }); + const machine = installMachineProvider(harness, host.id); + machine.suspend(); + const rpc = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/thread-storage/location`, + ); + + expect(response.status).toBe(200); + expect(await readJson(response)).toEqual({ + hostId: host.id, + storageRootPath: `/tmp/bb-host-data/${host.id}/thread-storage/${thread.id}`, + }); + expect(machine.resume).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + }); + }); + + it("fails file reads fast without resuming", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const machine = installMachineProvider(harness, host.id); + machine.suspend(); + const rpc = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + + const response = await harness.app.request("/api/v1/files/read", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostId: host.id, path: "/tmp/read-me" }), + }); + + expect(response.status).toBe(502); + expect(await readJson(response)).toMatchObject({ + code: "host_unavailable", + }); + expect(machine.resume).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + }); + }); + + it("does not resume when the desktop browser lease release timer expires", async () => { + await withTestHarness(async (harness) => { + const { host, session, thread } = seedThreadFixture(harness); + const machine = installMachineProvider(harness, host.id); + const commands: HostDaemonOnlineRpcRequestMessage["command"][] = []; + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: ({ command }) => { + commands.push(command); + if (command.type === "desktop.browser.list_tabs") { + return { + ok: true, + result: { + tabs: [ + { + tabId: "tab-1", + threadId: thread.id, + url: "https://example.com", + title: "Example", + profile: { kind: "automation", id: "profile-1" }, + presentation: "hidden", + control: null, + }, + ], + }, + }; + } + if (command.type === "desktop.browser.acquire_control") { + return { + ok: true, + result: { + lease: { + leaseId: command.leaseId, + controllerLabel: command.controllerLabel, + expiresAt: command.expiresAt, + }, + }, + }; + } + if (command.type === "desktop.browser.reveal_tab") { + return { ok: true, result: { ok: true } }; + } + throw new Error(`Unexpected host RPC ${command.type}`); + }, + }); + vi.useFakeTimers(); + const response = await harness.app.request( + "/api/v1/desktop-browsers/acquire", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + hostId: host.id, + instanceId: "desktop-1", + generation: "generation-1", + threadId: thread.id, + tabIds: ["tab-1"], + controllerLabel: "Test", + ttlMs: 1000, + }), + }, + ); + expect(response.status).toBe(200); + machine.suspend(); + const commandCount = commands.length; + + await vi.advanceTimersByTimeAsync(1000); + + expect(machine.resume).not.toHaveBeenCalled(); + expect(commands).toHaveLength(commandCount); + }); + }); + + it("resumes a suspended machine for thread send", async () => { + await withTestHarness(async (harness) => { + const { host, session, environment, thread } = seedThreadFixture( + harness, + { + thread: { status: "idle" }, + }, + ); + seedThreadRuntimeState(harness.deps, { + environmentId: environment.id, + providerThreadId: "provider-thread-1", + threadId: thread.id, + }); + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: ({ command }) => { + if (command.type === "turn.submit") { + return { ok: true, result: { appliedAs: "new-turn" } }; + } + throw new Error(`Unexpected host RPC ${command.type}`); + }, + }); + const machine = installMachineProvider(harness, host.id); + machine.suspend(); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: [{ type: "text", text: "Resume for this work" }], + mode: "auto", + }), + }, + ); + + expect(response.status).toBe(200); + expect(machine.resume).toHaveBeenCalledTimes(1); + expect(getHost(harness.db, host.id)?.phase).toBe("active"); + }); + }); +}); diff --git a/apps/server/test/machine-auth.test.ts b/apps/server/test/machine-auth.test.ts index 93f7a45094..3d17a3ff7e 100644 --- a/apps/server/test/machine-auth.test.ts +++ b/apps/server/test/machine-auth.test.ts @@ -50,7 +50,6 @@ describe("machine auth service", () => { const issuedKey = await harness.machineAuth.issueDaemonHostKey({ hostId: "host_hashed", - hostType: "persistent", }); const storedKey = harness.db @@ -71,22 +70,18 @@ describe("machine auth service", () => { const hostId = "host_reenroll"; const olderKey = await harness.machineAuth.issueDaemonHostKey({ hostId, - hostType: "persistent", }); const staleKey = await harness.machineAuth.issueDaemonHostKey({ hostId, - hostType: "persistent", }); const joinMaterial = await harness.machineAuth.issueHostEnrollKey({ enrollSource: "loopback", hostId, - hostType: "persistent", }); const reenrolled = await harness.machineAuth.enrollHost({ allowPublicEnrollment: true, hostId, - hostType: "persistent", token: joinMaterial.key, }); @@ -105,7 +100,6 @@ describe("machine auth service", () => { ).resolves.toMatchObject({ metadata: { hostId, - hostType: "persistent", }, }); }); @@ -115,7 +109,6 @@ describe("machine auth service", () => { await harness.machineAuth.issueHostEnrollKey({ enrollSource: "loopback", hostId: "host_expired_key", - hostType: "persistent", }); const createdKey = harness.db diff --git a/apps/server/test/provider-corpus/corpus-harness.ts b/apps/server/test/provider-corpus/corpus-harness.ts index 87dcbe62df..8b4af6bc95 100644 --- a/apps/server/test/provider-corpus/corpus-harness.ts +++ b/apps/server/test/provider-corpus/corpus-harness.ts @@ -81,7 +81,6 @@ export function loadCorpusThreadIntoDb( if (connection === undefined) migrate(db); const host = upsertHost(db, noopNotifier, { name: "provider-corpus-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "provider-corpus", diff --git a/apps/server/test/provider-corpus/synthetic-thread.ts b/apps/server/test/provider-corpus/synthetic-thread.ts index b49a8f329d..7d7c8da08e 100644 --- a/apps/server/test/provider-corpus/synthetic-thread.ts +++ b/apps/server/test/provider-corpus/synthetic-thread.ts @@ -456,7 +456,6 @@ export function createSyntheticThread(minimumEvents: number): SyntheticThread { migrate(db); const host = upsertHost(db, noopNotifier, { name: "synthetic-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "synthetic-project", diff --git a/apps/server/test/public/machine-environment.test.ts b/apps/server/test/public/machine-environment.test.ts new file mode 100644 index 0000000000..b206bba86a --- /dev/null +++ b/apps/server/test/public/machine-environment.test.ts @@ -0,0 +1,129 @@ +import { stat } from "node:fs/promises"; +import { join } from "node:path"; +import { appSettingsValues, upsertHost, updateHost } from "@bb/db"; +import { createBbSdk } from "@bb/sdk/core"; +import { createHttpTransport } from "@bb/sdk/node"; +import { describe, expect, it, vi } from "vitest"; +import { withTestHarness } from "../helpers/test-app.js"; +import { resolveHostEnvironment } from "../../src/services/hosts/host-environment.js"; +import * as gitCredentials from "../../src/services/machines/git-credentials.js"; + +describe("machine environment settings", () => { + it("round trips through the SDK while keeping secrets out of APIs and the real database", async () => { + const resolver = vi + .spyOn(gitCredentials, "resolveGitCredentials") + .mockResolvedValue([ + { + name: "GH_TOKEN", + value: "builtin-token", + source: { core: "machine-git" }, + reason: "Git", + }, + ]); + try { + await withTestHarness(async (harness) => { + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://localhost", + runtime: "node", + fetch: async (input, init) => + harness.app.fetch(new Request(input, init)), + }), + }); + const result = await sdk.system.replaceMachineEnvironment({ + variables: [ + { + name: "DEPLOY_REGION", + value: "test-region", + note: "Gate", + }, + { + name: "GH_TOKEN", + value: "user-private-token", + note: null, + }, + ], + }); + expect(result.builtInGit.status).toBe("overridden"); + expect(result.variables).toContainEqual({ + name: "GH_TOKEN", + secret: true, + value: null, + note: null, + }); + expect( + JSON.stringify(await sdk.system.machineEnvironment()), + ).not.toContain("user-private-token"); + expect( + JSON.stringify(harness.db.select().from(appSettingsValues).all()), + ).not.toContain("user-private-token"); + const path = join( + harness.config.dataDir, + "secrets", + "machine-environment", + "GH_TOKEN", + ); + await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); + expect( + (await stat(join(harness.config.dataDir, "machine-environment-key"))) + .mode & 0o777, + ).toBe(0o600); + expect( + JSON.stringify(harness.db.select().from(appSettingsValues).all()), + ).not.toContain("test-region"); + expect(JSON.stringify(result)).not.toContain("test-region"); + expect( + await resolveHostEnvironment(harness.deps, { + hostId: "local", + projectId: null, + }), + ).toEqual([]); + upsertHost(harness.db, harness.hub, { + id: "machine", + name: "Machine", + }); + updateHost(harness.db, harness.hub, "machine", { + machineProviderId: "manual", + }); + const env = await resolveHostEnvironment(harness.deps, { + hostId: "machine", + projectId: null, + }); + expect(env.filter((entry) => entry.name === "GH_TOKEN")).toEqual([ + expect.objectContaining({ + value: "user-private-token", + }), + ]); + expect(env).toContainEqual( + expect.objectContaining({ + name: "DEPLOY_REGION", + value: "test-region", + }), + ); + resolver.mockResolvedValueOnce([]); + expect( + await resolveHostEnvironment(harness.deps, { + hostId: "machine", + projectId: null, + }), + ).toContainEqual( + expect.objectContaining({ name: "GIT_CONFIG_COUNT", value: "4" }), + ); + await sdk.system.replaceMachineEnvironment({ + variables: [{ name: "DEPLOY_REGION", value: null, note: "Gate" }], + }); + await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); + expect( + await resolveHostEnvironment(harness.deps, { + hostId: "machine", + projectId: null, + }), + ).toContainEqual( + expect.objectContaining({ name: "GH_TOKEN", value: "builtin-token" }), + ); + }); + } finally { + resolver.mockRestore(); + } + }); +}); diff --git a/apps/server/test/public/public-host-management.test.ts b/apps/server/test/public/public-host-management.test.ts index 6ebd92bd24..fbfad51037 100644 --- a/apps/server/test/public/public-host-management.test.ts +++ b/apps/server/test/public/public-host-management.test.ts @@ -13,8 +13,9 @@ import { HOST_DAEMON_PROTOCOL_VERSION, hostDaemonSessionOpenResponseSchema, } from "@bb/host-daemon-contract"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { setPluginMachineProviderBridge } from "../../src/services/plugins/plugin-machine-provider-registry.js"; import { readJson } from "../helpers/json.js"; import { seedEnvironment, @@ -28,6 +29,10 @@ import { withTestHarness } from "../helpers/test-app.js"; const API = "/api/v1"; +afterEach(() => { + setPluginMachineProviderBridge(undefined); +}); + async function createJoinCode( app: Parameters[0], ): Promise { @@ -49,6 +54,35 @@ function requestJoinCode(app: { } describe("public host management", () => { + it("enrolls a host from a public join code", async () => { + await withTestHarness(async (harness) => { + const issued = await createJoinCode(harness.app); + const response = await harness.app.request("/internal/hosts/enroll", { + method: "POST", + headers: { + authorization: `Bearer ${issued.joinCode}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + hostId: issued.hostId, + hostName: "Modal abc1", + }), + }); + + expect(response.status).toBe(201); + expect(getHost(harness.db, issued.hostId)).toMatchObject({ + name: "Modal abc1", + }); + const hostsResponse = await harness.app.request("/api/v1/hosts"); + expect(hostsResponse.status).toBe(200); + expect(await readJson(hostsResponse)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: issued.hostId }), + ]), + ); + }); + }); + it("preserves a renamed host across a daemon reconnect", async () => { await withTestHarness(async (harness) => { const issued = await createJoinCode(harness.app); @@ -64,12 +98,12 @@ describe("public host management", () => { headers: { authorization: `Bearer ${issued.joinCode}`, "content-type": "application/json", + "x-bb-gate-auth": "machine", + "x-bb-gate-machine-id": "machine-cloud-1", }, body: JSON.stringify({ - connectMachineId: "machine-cloud-1", hostId: issued.hostId, hostName: "Build Machine", - hostType: "persistent", }), }, ); @@ -79,7 +113,6 @@ describe("public host management", () => { expect(getHost(harness.db, issued.hostId)).toMatchObject({ connectMachineId: "machine-cloud-1", name: "Build Machine", - type: "persistent", }); const renameResponse = await harness.app.request( @@ -103,15 +136,15 @@ describe("public host management", () => { headers: { authorization: `Bearer ${enrolled.hostKey}`, "content-type": "application/json", + "x-bb-gate-auth": "machine", + "x-bb-gate-machine-id": "machine-cloud-2", }, body: JSON.stringify({ activeThreads: [], - connectMachineId: "machine-cloud-2", dataDir: "/tmp/remote-bb", hasMachineCredential: true, hostId: issued.hostId, hostName: "Build Machine", - hostType: "persistent", instanceId: "instance-cloud-2", loadedEnvironments: [], localApiPort: 38_888, @@ -158,12 +191,11 @@ describe("public host management", () => { connectMachineId: "machine-forged", hostId: issued.hostId, hostName: "Forged Machine", - hostType: "persistent", }), }); - expect(response.status).toBe(403); + expect(response.status).toBe(400); expect(await readJson(response)).toMatchObject({ - code: "connect_machine_id_mismatch", + code: "invalid_request", }); expect(getHost(harness.db, issued.hostId)).toBeNull(); }); @@ -197,6 +229,18 @@ describe("public host management", () => { method: "POST", headers: { "x-bb-gate-auth": "machine" }, }), + harness.app.request(`${API}/hosts/${host.id}/suspend`, { + method: "POST", + headers: { "x-bb-gate-auth": "machine" }, + }), + harness.app.request(`${API}/hosts/${host.id}/resume`, { + method: "POST", + headers: { "x-bb-gate-auth": "machine" }, + }), + harness.app.request(`${API}/hosts/${host.id}/retry-cleanup`, { + method: "POST", + headers: { "x-bb-gate-auth": "machine" }, + }), harness.app.request(`${API}/hosts/${host.id}/permission-ceiling`, { method: "PATCH", headers: { @@ -367,12 +411,10 @@ describe("public host management", () => { }); const hostKey = await harness.deps.machineAuth.issueDaemonHostKey({ hostId: host.id, - hostType: "persistent", }); const enrollKey = await harness.deps.machineAuth.issueHostEnrollKey({ enrollSource: "loopback", hostId: host.id, - hostType: "persistent", }); const response = await harness.app.request(`${API}/hosts/${host.id}`, { @@ -413,7 +455,6 @@ describe("public host management", () => { body: JSON.stringify({ hostId: host.id, hostName: host.name, - hostType: "persistent", }), }, ); diff --git a/apps/server/test/public/public-project-clone-sources.test.ts b/apps/server/test/public/public-project-clone-sources.test.ts index dc5808b51b..7af7a941bb 100644 --- a/apps/server/test/public/public-project-clone-sources.test.ts +++ b/apps/server/test/public/public-project-clone-sources.test.ts @@ -1,6 +1,9 @@ +import { updateMachineEnvironment } from "../../src/services/machines/environment-settings.js"; +import * as gitCredentials from "../../src/services/machines/git-credentials.js"; +import { updateHost } from "@bb/db"; import { countProjectSources, getProject, setExperiments } from "@bb/db"; import { defaultExperiments } from "@bb/domain"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { listQueuedCommands, reportQueuedCommandError, @@ -39,6 +42,72 @@ function cloneSourceRequest(args: { } describe("project clone sources", () => { + it("contributes machine credentials to setup clones without persisting them", async () => { + const resolve = vi + .spyOn(gitCredentials, "resolveGitCredentials") + .mockResolvedValue([ + { + name: "GH_TOKEN", + value: "clone-secret", + source: { core: "machine-git" }, + reason: "Server gh login", + }, + ]); + try { + await withTestHarness(async (harness) => { + const first = seedHostSession(harness.deps, { id: "host-source" }); + const machine = seedHostSession(harness.deps, { id: "host-machine" }); + seedPrimaryHost(harness.deps, first.host.id); + updateHost(harness.db, harness.hub, machine.host.id, { + machineProviderId: "manual", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: first.host.id, + }); + await updateMachineEnvironment( + harness.db, + harness.config.dataDir, + "CUSTOM_SETUP", + { + name: "CUSTOM_SETUP", + value: "setup-value", + note: null, + }, + ); + const response = harness.app.fetch( + cloneSourceRequest({ + projectId: project.id, + hostId: machine.host.id, + remoteUrl: "git@github.com:octocat/private.git", + }), + ); + const queued = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone", + ); + expect(queued.command).toMatchObject({ + contributedEnv: [ + ...(await resolve()), + expect.objectContaining({ + name: "CUSTOM_SETUP", + value: "setup-value", + }), + ], + }); + await reportQueuedCommandSuccess(harness, queued, { + path: "/private", + gitRemoteUrl: "git@github.com:octocat/private.git", + }); + expect((await response).status).toBe(201); + expect( + JSON.stringify(getProject(harness.db, project.id)), + ).not.toContain("clone-secret"); + }); + } finally { + resolve.mockRestore(); + } + }); + it("rejects an already-sourced host before dispatching clone", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps, { @@ -91,6 +160,8 @@ describe("project clone sources", () => { ); expect(firstCommand.command).toEqual({ type: "project.clone", + operationId: expect.any(String), + contributedEnv: [], projectSlug: "Clone Me", remoteUrl: "ssh://git.example.test/team/repo.git", }); diff --git a/apps/server/test/public/public-project-commands.test.ts b/apps/server/test/public/public-project-commands.test.ts index 748a2b2205..ccac3968fd 100644 --- a/apps/server/test/public/public-project-commands.test.ts +++ b/apps/server/test/public/public-project-commands.test.ts @@ -317,6 +317,7 @@ describe("public project command typeahead route", () => { expect(stub.resolveRequests.map((request) => request.command)).toEqual([ expect.objectContaining({ type: "plugin.host.call", + contributedEnv: [], pluginId: provider.pluginId, method: "resolveNativeRoots", input: { providerId: "resolving", cwd: "/tmp/resolving-project" }, diff --git a/apps/server/test/public/public-provider-installations.test.ts b/apps/server/test/public/public-provider-installations.test.ts index 13429469a0..168844eef9 100644 --- a/apps/server/test/public/public-provider-installations.test.ts +++ b/apps/server/test/public/public-provider-installations.test.ts @@ -1,10 +1,15 @@ +import { getHost, updateHost } from "@bb/db"; +import { setPluginMachineProviderBridge } from "../../src/services/plugins/plugin-machine-provider-registry.js"; import type { HostDaemonOnlineRpcRequestMessage, ProviderCliStatusResponse, } from "@bb/host-daemon-contract"; import { systemProviderInfoSchema } from "@bb/server-contract"; import { DEFAULT_BB_REQUEST_TIMEOUT_MS } from "@bb/sdk"; -import { validatePluginProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import { + validatePluginProviderDeclaration, + validatePluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk/internal/host-policy"; import { describe, expect, it, vi } from "vitest"; import { COMMAND_TIMEOUT_MS } from "../../src/constants.js"; import { buildPluginProviderRegistration } from "../../src/services/providers/plugin-provider-registration.js"; @@ -263,6 +268,73 @@ describe("public provider installation routes", () => { }); }); + it.each(["suspended", "suspending"] as const)( + "does not resume a %s machine when reading provider status", + async (phase) => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "provider-installation-suspended-host", + }); + registerInstallationProviders( + harness, + ["suspended-installed-provider"], + "installed", + ); + const resume = vi.fn(async () => ({ resource: { id: "owned" } })); + const record = { + pluginId: "test-machine", + provider: validatePluginMachineProviderDeclaration({ + description: "Provision a test machine.", + icon: "Terminal", + id: "test-machine", + displayName: "Test machine", + + reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + name: "Test machine", + hostId: host.id, + resource: { id: "owned" }, + }), + suspend: async () => ({ resource: { id: "owned" } }), + resume, + remove: async () => ({ status: "removed" }), + }), + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [record], + getMachineProvider: () => record, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + try { + updateHost(harness.db, harness.hub, host.id, { + machineProviderId: "test-machine", + phase, + suspendedAt: phase === "suspended" ? Date.now() : null, + resource: { id: "owned" }, + }); + const rpc = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + const response = await harness.app.request( + `${API}/hosts/${host.id}/provider-clis/status`, + ); + expect(response.status).toBe(502); + expect(await readJson(response)).toMatchObject({ + code: "host_unavailable", + }); + expect(getHost(harness.db, host.id)?.phase).toBe(phase); + expect(resume).not.toHaveBeenCalled(); + expect(rpc).not.toHaveBeenCalled(); + } finally { + setPluginMachineProviderBridge(undefined); + } + }); + }, + ); + it("finishes stalled provider aggregation before the SDK request timeout", async () => { const statusRequestBatchSize = 3; const expectedStatusRequestCount = 9; diff --git a/apps/server/test/public/public-terminals.test.ts b/apps/server/test/public/public-terminals.test.ts index cec3988f78..3491c49e04 100644 --- a/apps/server/test/public/public-terminals.test.ts +++ b/apps/server/test/public/public-terminals.test.ts @@ -1,3 +1,6 @@ +import { updateMachineEnvironment } from "../../src/services/machines/environment-settings.js"; +import * as gitCredentials from "../../src/services/machines/git-credentials.js"; +import { updateHost } from "@bb/db"; import { createTerminalSession, getTerminalSession, @@ -385,6 +388,63 @@ describe("public terminal routes", () => { } }); + it("resolves host credentials for machine terminals and excludes local terminals", async () => { + const resolve = vi + .spyOn(gitCredentials, "resolveGitCredentials") + .mockResolvedValue([ + { + name: "GH_TOKEN", + value: "terminal-secret", + source: { core: "machine-git" }, + reason: "Server gh login", + }, + ]); + try { + for (const enrolled of [false, true]) { + const fixture = await createTerminalRouteFixture(); + harnesses.push(fixture.harness); + if (enrolled) + updateHost(fixture.harness.db, fixture.harness.hub, fixture.host.id, { + machineProviderId: "manual", + }); + await updateMachineEnvironment( + fixture.harness.db, + fixture.harness.config.dataDir, + "CUSTOM_TERMINAL", + { + name: "CUSTOM_TERMINAL", + value: "terminal-value", + note: null, + }, + ); + const pending = await startPendingTerminalOpen(fixture); + expect(pending.openMessage.contributedEnv).toEqual( + enrolled + ? [ + ...(await resolve()), + expect.objectContaining({ + name: "CUSTOM_TERMINAL", + value: "terminal-value", + }), + ] + : [], + ); + acknowledgeTerminalOpen(fixture, pending.openMessage); + expect((await pending.responsePromise).status).toBe(201); + expect( + JSON.stringify( + listTerminalSessions(fixture.harness.db, { + scope: { threadId: fixture.thread.id, kind: "thread" }, + visible: true, + }), + ), + ).not.toContain("terminal-secret"); + } + } finally { + resolve.mockRestore(); + } + }); + it("lists terminal sessions for a thread", async () => { const fixture = await createTerminalRouteFixture(); harnesses.push(fixture.harness); @@ -913,6 +973,10 @@ describe("public terminal routes", () => { title: "Terminal 1", }); + const notify = vi.spyOn( + fixture.harness.pluginService.events, + "emitTerminalInput", + ); const response = await fixture.harness.app.request( `/api/v1/terminals/${session.id}/input`, { @@ -925,6 +989,12 @@ describe("public terminal routes", () => { ); expect(response.status).toBe(200); + expect(notify).toHaveBeenCalledOnce(); + expect(notify.mock.calls[0]?.[0]).toMatchObject({ + id: session.id, + hostId: fixture.host.id, + }); + expect(notify.mock.calls[0]?.[0]).not.toHaveProperty("dataBase64"); const inputMessage = await waitForDaemonMessage(fixture.socket); expect(inputMessage).toMatchObject({ type: "terminal.input", diff --git a/apps/server/test/public/public-thread-offline-followup.test.ts b/apps/server/test/public/public-thread-offline-followup.test.ts index 84a7d6b879..e4d3a21138 100644 --- a/apps/server/test/public/public-thread-offline-followup.test.ts +++ b/apps/server/test/public/public-thread-offline-followup.test.ts @@ -47,7 +47,7 @@ describe("offline host follow-ups", () => { hostId: host.id, projectId: project.id, path: "/tmp/offline-followup", - }); + }); const thread = seedThread(harness.deps, { projectId: project.id, environmentId: environment.id, diff --git a/apps/server/test/public/public-thread-queue-gone-environment.test.ts b/apps/server/test/public/public-thread-queue-gone-environment.test.ts index 65883a7636..557d287a32 100644 --- a/apps/server/test/public/public-thread-queue-gone-environment.test.ts +++ b/apps/server/test/public/public-thread-queue-gone-environment.test.ts @@ -72,7 +72,7 @@ describe("queued message into a thread whose environment is gone (#1789)", () => }); const environment = seedEnvironment(harness.deps, { hostId: host.id, - projectId: project.id, + projectId: project.id, path: null, status, isGitRepo: false, @@ -201,7 +201,7 @@ describe("queued message into a thread whose environment is gone (#1789)", () => }); const environment = seedEnvironment(harness.deps, { hostId: host.id, - projectId: project.id, + projectId: project.id, status: "ready", isGitRepo: false, }); diff --git a/apps/server/test/services/database-maintenance-sweep.test.ts b/apps/server/test/services/database-maintenance-sweep.test.ts index 1b5e888292..bbbc359518 100644 --- a/apps/server/test/services/database-maintenance-sweep.test.ts +++ b/apps/server/test/services/database-maintenance-sweep.test.ts @@ -118,7 +118,6 @@ function createDeferredLegacyTables(db: DbConnection): void { function markDatabaseBusy(db: DbConnection): void { const host = upsertHost(db, noopNotifier, { name: "maintenance-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "maintenance-project", diff --git a/apps/server/test/services/entity-lookup.test.ts b/apps/server/test/services/entity-lookup.test.ts index 286b22036a..cb429af4aa 100644 --- a/apps/server/test/services/entity-lookup.test.ts +++ b/apps/server/test/services/entity-lookup.test.ts @@ -39,7 +39,6 @@ function setup(): SetupResult { const hostRow = upsertHost(db, noopNotifier, { id: "host_entity_lookup", name: "Entity Lookup Host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "Entity Lookup Project", @@ -184,6 +183,34 @@ describe("entity lookup lifecycle errors", () => { }, }); + updateHost(db, noopNotifier, host.id, { + phase: "suspended", + suspendedAt: 123, + }); + const suspendedError = captureApiError(() => { + requireConnectedHostSession({ db, hub }, host.id); + }); + expect(suspendedError.status).toBe(502); + expect(suspendedError.body).toEqual({ + code: "host_unavailable", + message: "Host is suspended", + details: { + reason: "suspended", + hostStatus: "disconnected", + suspendedAt: 123, + destroyedAt: null, + }, + }); + + updateHost(db, noopNotifier, host.id, { suspendedAt: null }); + const legacySuspendedError = captureApiError(() => { + requireConnectedHostSession({ db, hub }, host.id); + }); + expect(legacySuspendedError.body.details).toMatchObject({ + reason: "suspended", + suspendedAt: null, + }); + updateHost(db, noopNotifier, host.id, { destroyedAt: 456 }); const destroyedError = captureApiError(() => { requireNonDestroyedHostWithStatus({ db, hub }, host.id); diff --git a/apps/server/test/services/machines/manual-provider.test.ts b/apps/server/test/services/machines/manual-provider.test.ts new file mode 100644 index 0000000000..31145c543a --- /dev/null +++ b/apps/server/test/services/machines/manual-provider.test.ts @@ -0,0 +1,104 @@ +import { expect, it, vi } from "vitest"; +import { defaultAppSettings } from "@bb/domain"; +import { getHost, setAppSettings } from "@bb/db"; +import { withTestHarness } from "../../helpers/test-app.js"; +import { seedHost, seedPrimaryHost } from "../../helpers/seed.js"; +import { createManualMachineProviderRecord } from "../../../src/services/machines/manual-provider.js"; + +it("reports the manual uninstall hint during removal", async () => { + const step = vi.fn(); + const record = createManualMachineProviderRecord({ + bootstrap: vi.fn(async () => ({ hostId: "host-one" })), + }); + await record.provider.remove({ + hostId: "host-one", + resource: { key: "manual" }, + report: { step, log: vi.fn() }, + signal: new AbortController().signal, + }); + expect(step).toHaveBeenCalledWith( + "Uninstall the machine service with its original installer: install-machine.sh --uninstall --host-id host-one", + ); +}); + +it("creates, enrolls, and removes a manual machine by host id", async () => { + await withTestHarness(async (harness) => { + seedPrimaryHost(harness.deps, "local"); + seedHost(harness.deps, { id: "local" }); + setAppSettings(harness.db, { + ...defaultAppSettings, + defaultMachineAccess: "direct", + machineServerUrl: "https://machine.example.test", + }); + const response = await harness.app.request("/api/v1/hosts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + key: "manual-host", + machineProviderId: "manual", + inputs: null, + }), + }); + expect(response.status).toBe(201); + const created = (await response.json()) as { id: string }; + await vi.waitFor(() => + expect(getHost(harness.db, created.id)).toMatchObject({ + phase: "creating", + statusMessage: "Run the enrollment command shown below", + }), + ); + const commandResponse = await harness.app.request( + `/api/v1/hosts/${created.id}/enrollment-command`, + ); + expect(commandResponse.status).toBe(200); + const enrollment = (await commandResponse.json()) as { + command: string; + expiresAt: number; + }; + const credential = enrollment.command.match( + /X-BB-Enrollment: ([^']+)/u, + )?.[1]; + expect(credential).toBeTruthy(); + expect(JSON.stringify(getHost(harness.db, created.id))).not.toContain( + credential, + ); + + const enrolled = await harness.deps.machineAuth.enrollHost({ + hostId: created.id, + token: credential!, + allowPublicEnrollment: true, + }); + expect(enrolled).not.toBeNull(); + harness.hub.registerDaemon("manual-session", created.id, { + close() {}, + send() {}, + }); + await expect + .poll(() => getHost(harness.db, created.id)?.phase) + .toBe("active"); + expect(getHost(harness.db, created.id)).toMatchObject({ + launchKey: "manual-host", + machineProviderId: "manual", + resource: { key: "manual-host" }, + }); + expect( + await ( + await harness.app.request( + `/api/v1/hosts/${created.id}/enrollment-command`, + ) + ).json(), + ).toBeNull(); + + const removed = await harness.app.request(`/api/v1/hosts/${created.id}`, { + method: "DELETE", + }); + expect({ + status: removed.status, + body: await removed.clone().json(), + }).toEqual({ + status: 200, + body: { ok: true }, + }); + expect(getHost(harness.db, created.id)?.phase).toBe("destroyed"); + }); +}); diff --git a/apps/server/test/services/machines/provider-orchestration.test.ts b/apps/server/test/services/machines/provider-orchestration.test.ts new file mode 100644 index 0000000000..d40830422c --- /dev/null +++ b/apps/server/test/services/machines/provider-orchestration.test.ts @@ -0,0 +1,535 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createEnvironment, + createHostId, + environments, + getHost, + hosts, + archiveThread, + updateHost, +} from "@bb/db"; +import { createDeferredPromise } from "@bb/test-helpers"; +import { eq } from "drizzle-orm"; +import type { JsonValue } from "@bb/domain"; +import type { PluginMachineProviderDeclaration } from "@get-bb/plugin-sdk"; +import { validatePluginMachineProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import { + askMachineLaunch, + requestAutomaticMachineRemoval, + requestMachineRemoval, + requestMachineSuspension, + submitMachine, + sweepMachineLifecycles, + sweepProviderMachine, +} from "../../../src/services/machines/provider-orchestration.js"; +import { + ensureProjectSourceOnHost, + hasPendingProjectSourceSetupOnHost, +} from "../../../src/services/projects/project-source-setup.js"; +import { setPluginMachineProviderBridge } from "../../../src/services/plugins/plugin-machine-provider-registry.js"; +import { + reportQueuedCommandError, + waitForQueuedCommand, +} from "../../helpers/commands.js"; +import { readJson } from "../../helpers/json.js"; +import { + seedHostSession, + seedProjectWithSource, + seedThread, +} from "../../helpers/seed.js"; +import { withTestHarness } from "../../helpers/test-app.js"; + +function installMachineProvider( + overrides: Partial = {}, +) { + const record = { + pluginId: "test-machine-plugin", + provider: validatePluginMachineProviderDeclaration({ + id: "test-machine", + displayName: "Test machine", + description: "Provision a test machine.", + icon: "Terminal", + create: async ({ key }) => ({ + status: "created" as const, + name: "Created machine", + resource: { key }, + }), + reconcileCleanup: async () => ({ status: "removed" as const }), + remove: async () => ({ status: "removed" as const }), + ...overrides, + }), + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [record], + getMachineProvider: (id) => + id === record.provider.id ? record : undefined, + invokeProvider: async (_pluginId, _label, run) => { + try { + return { ok: true as const, value: await run() }; + } catch (error) { + return { + ok: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + decisionTimeoutMs: 10_000, + }); + return record; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + setPluginMachineProviderBridge(undefined); +}); + +describe("machine creation hosts", () => { + it("persists a creating host before allocation and resumes it after restart", async () => + withTestHarness(async (harness) => { + const started = createDeferredPromise(); + const release = createDeferredPromise(); + const calls: Array<{ attempt: number; key: string }> = []; + installMachineProvider({ + create: async ({ attempt, key }) => { + calls.push({ attempt, key }); + started.resolve(); + await release.promise; + return { + status: "created", + name: "Recovered machine", + resource: { allocation: "same" }, + }; + }, + }); + + const submitted = await submitMachine(harness.deps, { + key: "thread-machine", + machineProviderId: "test-machine", + inputs: null, + }); + await started.promise; + expect(submitted).toMatchObject({ + id: expect.stringMatching(/^host_/u), + lifecycle: { + phase: "creating", + message: "Creating Test machine…", + }, + }); + expect(getHost(harness.db, submitted.id)).toMatchObject({ + launchKey: "thread-machine", + attempt: 1, + phase: "creating", + }); + + release.resolve(); + await expect + .poll(() => getHost(harness.db, submitted.id)?.phase) + .toBe("active"); + expect(calls).toEqual([{ attempt: 1, key: "thread-machine" }]); + expect(getHost(harness.db, submitted.id)).toMatchObject({ + name: "Recovered machine", + inputs: null, + resource: { allocation: "same" }, + }); + + const restartedId = createHostId(); + const now = Date.now(); + harness.db + .insert(hosts) + .values({ + id: restartedId, + name: "Test machine restart", + type: "persistent", + machineProviderId: "test-machine", + machineOperationId: "test-machine-plugin:restart", + launchKey: "restart-key", + inputs: null, + attempt: 1, + phase: "creating", + statusMessage: "Creating Test machine…", + createdAt: now, + updatedAt: now, + }) + .run(); + await sweepMachineLifecycles(harness.deps); + expect(getHost(harness.db, restartedId)).toMatchObject({ + phase: "active", + resource: { allocation: "same" }, + }); + })); + + it("uses one live row per launch key and reuses the key after destruction", async () => + withTestHarness(async (harness) => { + const record = installMachineProvider(); + expect( + askMachineLaunch(harness.deps, { + key: "stable-key", + record, + inputs: null, + }).action, + ).toBe("wait"); + await expect + .poll( + () => + harness.db + .select() + .from(hosts) + .all() + .find((row) => row.launchKey === "stable-key")?.phase, + ) + .toBe("active"); + const firstHost = harness.db + .select() + .from(hosts) + .all() + .find((row) => row.launchKey === "stable-key")!; + expect(requestMachineRemoval(harness.deps, firstHost.id)).toBe(true); + await sweepProviderMachine(harness.deps, firstHost.id); + expect(getHost(harness.db, firstHost.id)?.phase).toBe("destroyed"); + + askMachineLaunch(harness.deps, { + key: "stable-key", + record, + inputs: null, + }); + const rows = harness.db + .select() + .from(hosts) + .all() + .filter((row) => row.launchKey === "stable-key"); + expect(rows).toHaveLength(2); + expect(rows.find((row) => row.destroyedAt === null)).toMatchObject({ + attempt: 2, + phase: "creating", + }); + })); + + it("preserves failed creation output until the launch caller consumes it", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const cleanupStarted = createDeferredPromise(); + const finishCleanup = createDeferredPromise(); + const message = + "Machine bootstrap command failed:\nbb-machine-install: 9: node: not found\nbb-machine-install: 9: curl: not found"; + const record = installMachineProvider({ + create: async ({ report }) => { + report.step("Bootstrapping machine"); + report.log("bb-machine-install: 9: node: not found\n"); + report.log("bb-machine-install: 9: curl: not found\n"); + return { status: "failed", message }; + }, + reconcileCleanup: async () => { + cleanupStarted.resolve(); + await finishCleanup.promise; + return { status: "removed" }; + }, + }); + + expect( + askMachineLaunch(harness.deps, { + key: "failed-bootstrap", + record, + inputs: null, + }).action, + ).toBe("wait"); + await expect + .poll( + () => + harness.db + .select() + .from(hosts) + .all() + .find((row) => row.launchKey === "failed-bootstrap")?.phase, + ) + .toBe("removing"); + + const rejected = askMachineLaunch(harness.deps, { + key: "failed-bootstrap", + record, + inputs: null, + }); + expect(rejected).toEqual({ + action: "reject", + message, + log: expect.stringContaining("curl: not found"), + }); + await cleanupStarted.promise; + const host = harness.db + .select() + .from(hosts) + .all() + .find((row) => row.launchKey === "failed-bootstrap"); + expect(host).toMatchObject({ + phase: "removing", + statusMessage: message, + teardownStatus: "running", + }); + + finishCleanup.resolve(); + await expect + .poll(() => getHost(harness.db, host!.id)?.phase) + .toBe("destroyed"); + expect(getHost(harness.db, host!.id)?.statusMessage).toBe(message); + })); + + it("cancels by removing and reconciles without a checkpoint", async () => + withTestHarness(async (harness) => { + const started = createDeferredPromise(); + const reconcileCleanup = vi.fn(async () => ({ + status: "removed" as const, + })); + installMachineProvider({ + create: async ({ signal }) => { + started.resolve(); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + signal.throwIfAborted(); + throw new Error("unreachable"); + }, + reconcileCleanup, + }); + const host = await submitMachine(harness.deps, { + key: "cancel-key", + machineProviderId: "test-machine", + inputs: null, + }); + await started.promise; + + const response = await harness.app.request(`/api/v1/hosts/${host.id}`, { + method: "DELETE", + }); + expect(response.status).toBe(200); + expect(reconcileCleanup).toHaveBeenCalledOnce(); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "destroyed", + teardownStatus: "removed", + }); + })); + + it("removes a checkpoint and fences stale checkpoint ownership", async () => + withTestHarness(async (harness) => { + const checkpointed = createDeferredPromise(); + const release = createDeferredPromise(); + let lateCheckpoint: ((value: JsonValue) => Promise) | undefined; + const remove = vi.fn(async () => ({ status: "removed" as const })); + installMachineProvider({ + create: async ({ checkpoint, signal }) => { + lateCheckpoint = checkpoint; + await checkpoint({ allocation: "one" }); + checkpointed.resolve(); + await release.promise; + signal.throwIfAborted(); + return { + status: "created", + name: "Checkpointed machine", + resource: { allocation: "one" }, + }; + }, + remove, + }); + const host = await submitMachine(harness.deps, { + key: "checkpoint-key", + machineProviderId: "test-machine", + inputs: null, + }); + await checkpointed.promise; + const operationId = getHost(harness.db, host.id)!.machineOperationId; + updateHost(harness.db, harness.hub, host.id, { + machineOperationId: "test-machine-plugin:replacement-owner", + }); + await expect(lateCheckpoint?.({ allocation: "stale" })).rejects.toThrow( + "no longer owns", + ); + updateHost(harness.db, harness.hub, host.id, { + machineOperationId: operationId, + }); + expect(requestMachineRemoval(harness.deps, host.id)).toBe(true); + release.resolve(); + await sweepProviderMachine(harness.deps, host.id); + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ + hostId: host.id, + resource: { allocation: "one" }, + }), + ); + })); +}); + +describe("machine retirement", () => { + it("keeps persistent machines and ephemeral machines with live threads", async () => + withTestHarness(async (harness) => { + installMachineProvider({ ephemeral: true }); + const ephemeralId = createHostId(); + const now = Date.now(); + harness.db + .insert(hosts) + .values({ + id: ephemeralId, + name: "Ephemeral machine", + type: "ephemeral", + machineProviderId: "test-machine", + phase: "active", + resource: { allocation: "one" }, + createdAt: now, + updatedAt: now, + }) + .run(); + const { project } = seedProjectWithSource(harness.deps, { + hostId: ephemeralId, + path: "/tmp/ephemeral", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: ephemeralId, + path: "/tmp/ephemeral", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + expect(requestAutomaticMachineRemoval(harness.deps, ephemeralId)).toBe( + false, + ); + updateHost(harness.db, harness.hub, ephemeralId, { + type: "persistent", + }); + expect(requestAutomaticMachineRemoval(harness.deps, ephemeralId)).toBe( + false, + ); + })); + + it("retries failed teardown at removeRetryAt", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const remove = vi + .fn() + .mockResolvedValueOnce({ status: "failed" as const, message: "busy" }) + .mockResolvedValueOnce({ status: "removed" as const }); + installMachineProvider({ remove }); + const id = createHostId(); + harness.db + .insert(hosts) + .values({ + id, + name: "Remove me", + type: "ephemeral", + machineProviderId: "test-machine", + phase: "active", + resource: { allocation: "one" }, + createdAt: 1, + updatedAt: 1, + }) + .run(); + + await sweepMachineLifecycles(harness.deps); + expect(getHost(harness.db, id)).toMatchObject({ + phase: "removing", + removeRetryAt: 80_000, + teardownStatus: "failed", + }); + vi.setSystemTime(79_999); + await sweepMachineLifecycles(harness.deps); + expect(remove).toHaveBeenCalledOnce(); + vi.setSystemTime(80_000); + await sweepMachineLifecycles(harness.deps); + expect(remove).toHaveBeenCalledTimes(2); + expect(getHost(harness.db, id)?.phase).toBe("destroyed"); + })); +}); + +describe("machine suspension", () => { + it("rejects each persisted provisioning state and suspends after all clear", async () => + withTestHarness(async (harness) => { + const source = seedHostSession(harness.deps, { id: "setup-source" }); + const target = seedHostSession(harness.deps, { id: "setup-target" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: source.host.id, + }); + const suspend = vi.fn(async () => ({ resource: { id: "owned" } })); + installMachineProvider({ + suspend, + resume: async () => ({ resource: { id: "owned" } }), + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + status: "starting", + }); + updateHost(harness.db, harness.hub, target.host.id, { + machineProviderId: "test-machine", + launchKey: thread.id, + resource: { id: "owned" }, + }); + + const expectBusy = async () => { + const response = await harness.app.request( + `/api/v1/hosts/${target.host.id}/suspend`, + { method: "POST" }, + ); + expect(response.status).toBe(409); + expect(await readJson(response)).toMatchObject({ + code: "machine_busy", + message: + "Wait for thread provisioning to finish before suspending this machine.", + }); + expect(getHost(harness.db, target.host.id)?.phase).toBe("active"); + expect(suspend).not.toHaveBeenCalled(); + }; + + await expectBusy(); + archiveThread(harness.db, harness.hub, thread.id); + + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: target.host.id, + path: "/tmp/provisioning-environment", + providerOwnsPath: false, + status: "provisioning", + environmentProvider: null, + }); + await expectBusy(); + harness.db + .update(environments) + .set({ status: "ready" }) + .where(eq(environments.id, environment.id)) + .run(); + + const setup = ensureProjectSourceOnHost(harness.deps, { + projectId: project.id, + projectName: project.name, + hostId: target.host.id, + remoteUrl: "https://example.test/team/project.git", + }).then( + () => null, + (error: unknown) => error, + ); + const path = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone_default_path", + ); + expect( + hasPendingProjectSourceSetupOnHost(harness.db, target.host.id), + ).toBe(true); + await expectBusy(); + + await reportQueuedCommandError(harness, path, { + errorCode: "git_auth_failed", + errorMessage: "Stop project setup", + }); + expect(await setup).toBeInstanceOf(Error); + expect( + hasPendingProjectSourceSetupOnHost(harness.db, target.host.id), + ).toBe(false); + + await requestMachineSuspension(harness.deps, target.host.id); + expect(suspend).toHaveBeenCalledOnce(); + expect(getHost(harness.db, target.host.id)?.phase).toBe("suspended"); + })); +}); diff --git a/apps/server/test/services/machines/runtime-enrollments.test.ts b/apps/server/test/services/machines/runtime-enrollments.test.ts new file mode 100644 index 0000000000..075b81fa56 --- /dev/null +++ b/apps/server/test/services/machines/runtime-enrollments.test.ts @@ -0,0 +1,305 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { + getNonDestroyedHostByLaunchKey, + listPublicHosts, + hosts, + setAppSettings, +} from "@bb/db"; +import { defaultAppSettings } from "@bb/domain"; +import type { ServerAccessGrant } from "@get-bb/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { getMachineEnrollmentService } from "../../../src/services/machines/machine-services.js"; +import { serverAccess } from "../../../src/services/machines/server-access.js"; +import { + withTestHarness, + type TestAppHarness, +} from "../../helpers/test-app.js"; + +async function installPlugin(harness: TestAppHarness, id: string) { + const root = join(harness.config.dataDir, `bb-plugin-${id}`); + await mkdir(root, { recursive: true }); + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: `bb-plugin-${id}`, + version: "0.1.0", + type: "module", + bb: { + name: id, + description: "Machine enrollment regression fixture", + branding: { icon: "Zap" }, + server: "./server.js", + }, + }), + ); + await writeFile( + join(root, "server.js"), + `export default function(bb) { + bb.experimental_machines.register({ + id: "${id}-machine", displayName: "Runtime machine", + description: "Provision a runtime test machine.", icon: "Terminal", + + reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ status: "failed", message: "unused" }), + remove: async () => ({ status: "removed" }) + }); + }`, + ); + const installed = await harness.pluginService.installPath(root); + expect(installed.status).toBe("running"); + const api = harness.pluginService.getApi(id); + if (!api) throw new Error("Plugin API was not loaded"); + return api; +} + +function launch(harness: TestAppHarness, key: string, providerId: string) { + harness.db + .insert(hosts) + .values({ + id: `host_${key}`, + name: "Runtime machine", + type: "persistent", + machineProviderId: providerId, + machineOperationId: "enrollment-runtime:operation", + launchKey: key, + attempt: 1, + phase: "creating", + statusMessage: "checkpoint step", + pendingLog: "checkpoint log", + resource: { checkpoint: "preserve" }, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + .run(); +} + +describe("production machine enrollment wiring", () => { + it("reads the current core resource across plugins without diagnostic storage", async () => { + await withTestHarness(async (h) => { + const api = await installPlugin(h, "resource-reader"); + const hostId = "resource-host"; + h.db + .insert(hosts) + .values({ + id: hostId, + name: "Existing machine", + type: "persistent", + machineProviderId: "another-plugin-machine", + resource: { sandboxId: "sandbox-existing" }, + createdAt: 1, + updatedAt: 1, + }) + .run(); + expect(await api.experimental_machines.getResource(hostId)).toEqual({ + sandboxId: "sandbox-existing", + }); + h.db + .update(hosts) + .set({ resource: { sandboxId: null, snapshotImageId: "image-1" } }) + .where(eq(hosts.id, hostId)) + .run(); + expect(await api.experimental_machines.getResource(hostId)).toEqual({ + sandboxId: null, + snapshotImageId: "image-1", + }); + h.db + .update(hosts) + .set({ resource: null }) + .where(eq(hosts.id, hostId)) + .run(); + expect(await api.experimental_machines.getResource(hostId)).toBeNull(); + expect( + await api.experimental_machines.getResource("missing-host"), + ).toBeNull(); + }); + }); + + it("reserves the launch host through the loaded plugin and reuses production connection state", async () => { + await withTestHarness(async (h) => { + setAppSettings(h.db, { + ...defaultAppSettings, + defaultMachineAccess: "direct", + machineServerUrl: "https://machine.example.test", + }); + const api = await installPlugin(h, "enrollment-runtime"); + launch(h, "runtime-launch", "enrollment-runtime-machine"); + const enrollments = getMachineEnrollmentService(h.deps).forOwner( + "enrollment-runtime", + ); + const enrollment = await enrollments.prepare({ + key: "runtime-launch", + }); + expect( + getNonDestroyedHostByLaunchKey(h.db, "runtime-launch"), + ).toMatchObject({ + id: enrollment.hostId, + resource: { checkpoint: "preserve" }, + pendingLog: "checkpoint log", + }); + expect(getMachineEnrollmentService(h.deps)).toBe( + getMachineEnrollmentService(h.deps), + ); + const reissued = await enrollments.prepare({ + key: "runtime-launch", + }); + expect(reissued).toMatchObject({ + id: enrollment.id, + hostId: enrollment.hostId, + state: "pending", + }); + if (enrollment.state !== "pending" || reissued.state !== "pending") + throw new Error("Expected pending enrollment"); + expect(reissued.bootstrap.credential).not.toBe( + enrollment.bootstrap.credential, + ); + const exec = vi.fn(async ({ stdin }: { stdin?: string }) => { + if (stdin === undefined) throw new Error("Expected enrollment input"); + const input: unknown = JSON.parse(stdin); + if ( + typeof input !== "object" || + input === null || + !("credential" in input) || + typeof input.credential !== "string" + ) { + throw new Error("Expected enrollment credential"); + } + expect( + await h.deps.machineAuth.enrollHost({ + hostId: enrollment.hostId, + token: input.credential, + allowPublicEnrollment: true, + }), + ).not.toBeNull(); + h.hub.registerDaemon("runtime-session", enrollment.hostId, { + close() {}, + send() {}, + }); + return { exitCode: 0, stdout: "", stderr: "" }; + }); + await expect( + api.experimental_machines.bootstrap({ + key: "runtime-launch", + executor: { exec }, + report: { step() {}, log() {} }, + signal: new AbortController().signal, + }), + ).resolves.toEqual({ hostId: enrollment.hostId }); + expect(exec).toHaveBeenCalledOnce(); + expect( + await enrollments.prepare({ + key: "runtime-launch", + }), + ).toEqual({ + id: enrollment.id, + hostId: enrollment.hostId, + state: "enrolled", + }); + await expect( + enrollments.waitForConnection({ + enrollmentId: enrollment.id, + timeoutMs: 100, + signal: new AbortController().signal, + }), + ).resolves.toEqual({ hostId: enrollment.hostId }); + await h.pluginService.setEnabled("enrollment-runtime", false); + expect(() => + api.experimental_machines.bootstrap({ + key: "after-disable", + report: { step() {}, log() {} }, + signal: new AbortController().signal, + }), + ).rejects.toThrow(); + }); + }); + + it("rejects foreign launches, checkpoints before failed access, and releases with the original owner key", async () => { + await withTestHarness(async (h) => { + const api = await installPlugin(h, "enrollment-runtime"); + await installPlugin(h, "enrollment-other"); + const enrollments = getMachineEnrollmentService(h.deps).forOwner( + "enrollment-runtime", + ); + const otherEnrollments = getMachineEnrollmentService(h.deps).forOwner( + "enrollment-other", + ); + const release = vi.fn(async () => {}); + const acquire = vi.fn( + async ({ + hostId, + }: { + hostId: string; + }): Promise< + ServerAccessGrant | { status: "failed"; message: string } + > => ({ + id: "runtime-grant", + serverUrl: "https://machine.example.test", + }), + ); + api.experimental_serverAccess.register({ + id: "runtime-access", + displayName: "Runtime access", + description: "Reach the server through the runtime test provider.", + availability: () => ({ status: "available" }), + acquire, + release, + }); + launch(h, "failure-launch", "enrollment-runtime-machine"); + await expect( + otherEnrollments.prepare({ + key: "failure-launch", + access: { providerId: "runtime-access" }, + }), + ).rejects.toThrow("different plugin"); + acquire.mockResolvedValueOnce({ + status: "failed", + message: "Cloud device may need dashboard revocation", + }); + await expect( + enrollments.prepare({ + key: "failure-launch", + access: { providerId: "runtime-access" }, + }), + ).rejects.toThrow(); + const reserved = getNonDestroyedHostByLaunchKey(h.db, "failure-launch"); + expect(reserved?.id).toBeTruthy(); + expect(reserved?.resource).toEqual({ checkpoint: "preserve" }); + expect( + listPublicHosts(h.db, { includeCreating: true }).find( + (host) => host.id === reserved?.id, + )?.statusMessage, + ).toBe("Cloud device may need dashboard revocation"); + const enrollment = await enrollments.prepare({ + key: "failure-launch", + access: { providerId: "runtime-access" }, + }); + expect(enrollment.hostId).toBe(reserved?.id); + expect( + listPublicHosts(h.db).some((host) => host.id === reserved?.id), + ).toBe(false); + await serverAccess.release(h.deps, { + hostId: enrollment.hostId, + key: enrollment.hostId, + }); + expect(release).toHaveBeenCalledWith({ + key: JSON.stringify(["enrollment-runtime", "failure-launch"]), + grantId: "runtime-grant", + hostId: enrollment.hostId, + }); + expect( + h.db + .select({ providerId: hosts.serverAccessProviderId }) + .from(hosts) + .where(eq(hosts.id, enrollment.hostId)) + .get()?.providerId, + ).toBeNull(); + await expect( + enrollments.prepare({ + key: "standalone", + access: { providerId: "runtime-access" }, + }), + ).rejects.toThrow("host was not found"); + }); + }); +}); diff --git a/apps/server/test/services/machines/server-access.test.ts b/apps/server/test/services/machines/server-access.test.ts new file mode 100644 index 0000000000..aed4e906f6 --- /dev/null +++ b/apps/server/test/services/machines/server-access.test.ts @@ -0,0 +1,269 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getHost, setAppSettings, updateHost, upsertHost } from "@bb/db"; +import { defaultAppSettings } from "@bb/domain"; +import type { ServerAccessProviderDeclaration } from "@get-bb/plugin-sdk"; +import { + serverAccess, + serverAccessStatus, +} from "../../../src/services/machines/server-access.js"; +import { + setServerAccessBridge, + setServerAccessRecheckHandler, +} from "../../../src/services/plugins/plugin-server-access-registry.js"; +import { listPublicHostsWithStatus } from "../../../src/services/lib/entity-lookup.js"; +import { withTestHarness } from "../../helpers/test-app.js"; + +const signal = new AbortController().signal; + +afterEach(() => { + setServerAccessBridge(undefined); + vi.unstubAllEnvs(); +}); + +function installProvider(provider: ServerAccessProviderDeclaration) { + setServerAccessBridge({ + list: () => [{ pluginId: "access-plugin", provider }], + invoke: async (_id, run) => run(), + }); +} + +function provider(): ServerAccessProviderDeclaration { + return { + id: "relay", + displayName: "Relay", + description: "Use a managed relay.", + availability: () => ({ status: "available" }), + acquire: async ({ hostId }) => ({ + id: hostId, + serverUrl: "https://bb.example.com", + headers: { "x-access-token": "secret-header" }, + }), + release: async () => {}, + }; +} + +describe("machine server access", () => { + it("lists providers without invoking availability", async () => { + await withTestHarness(async ({ deps }) => { + const availability = vi.fn(() => ({ status: "available" as const })); + installProvider({ + ...provider(), + availability, + }); + const status = await serverAccessStatus(deps); + const access = status.providers.find((entry) => entry.id === "relay"); + expect(access).toMatchObject({ id: "relay", pluginId: "access-plugin" }); + expect(availability).not.toHaveBeenCalled(); + }); + }); + it("prefers the first registered provider and respects an explicit direct default", async () => { + await withTestHarness(async ({ deps }) => { + vi.stubEnv("BB_EXTERNAL_URL", "https://direct.example.com"); + expect((await serverAccessStatus(deps)).defaultProviderId).toBe("direct"); + installProvider(provider()); + expect((await serverAccessStatus(deps)).defaultProviderId).toBe("relay"); + expect((await serverAccessStatus(deps)).providers[0]).toMatchObject({ + id: "relay", + pluginId: "access-plugin", + description: "Use a managed relay.", + }); + setAppSettings(deps.db, { + ...defaultAppSettings, + defaultMachineAccess: "direct", + }); + expect((await serverAccessStatus(deps)).defaultProviderId).toBe("direct"); + setAppSettings(deps.db, { + ...defaultAppSettings, + defaultMachineAccess: "missing", + }); + const host = upsertHost(deps.db, deps.hub, { name: "test" })!; + await expect( + serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }), + ).rejects.toThrow("unavailable"); + }); + }); + + it("requires provider setup instead of silently falling back to a configured URL", async () => { + await withTestHarness(async ({ deps }) => { + vi.stubEnv("BB_EXTERNAL_URL", "https://direct.example.com"); + const availability = vi.fn(() => ({ + status: "setup-required" as const, + message: "Set up the relay", + })); + installProvider({ + ...provider(), + availability, + }); + expect((await serverAccessStatus(deps)).defaultProviderId).toBe("relay"); + expect(availability).not.toHaveBeenCalled(); + const host = upsertHost(deps.db, deps.hub, { name: "test" })!; + await expect( + serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }), + ).rejects.toThrow("Set up the relay"); + expect(availability).toHaveBeenCalledOnce(); + setAppSettings(deps.db, { + ...defaultAppSettings, + defaultMachineAccess: "direct", + }); + expect( + ( + await serverAccess.resolve(deps, { + key: "k", + hostId: host.id, + signal, + }) + ).serverUrl, + ).toBe("https://direct.example.com"); + }); + }); + + it("returns direct access without headers", async () => { + await withTestHarness(async ({ deps }) => { + vi.stubEnv("BB_EXTERNAL_URL", "https://direct.example.com"); + const host = upsertHost(deps.db, deps.hub, { name: "direct" })!; + const grant = await serverAccess.resolve(deps, { + key: "direct", + hostId: host.id, + access: { providerId: "direct" }, + signal, + }); + expect(grant).toEqual({ + id: host.id, + serverUrl: "https://direct.example.com", + }); + }); + }); + + it("stores grant identity without its code and retains provider on retry", async () => { + await withTestHarness(async ({ deps }) => { + installProvider(provider()); + const host = upsertHost(deps.db, deps.hub, { name: "test" })!; + const grant = await serverAccess.resolve(deps, { + key: "k", + hostId: host.id, + signal, + }); + expect(grant.headers).toEqual({ "x-access-token": "secret-header" }); + const row = getHost(deps.db, host.id)!; + expect(row.serverAccessProviderId).toBe("relay"); + expect(row.serverAccessGrantId).toBe(host.id); + expect(JSON.stringify(row)).not.toContain("secret-header"); + await expect( + serverAccess.resolve(deps, { + key: "k", + hostId: host.id, + access: { providerId: "direct" }, + signal, + }), + ).rejects.toThrow("different"); + await serverAccess.release(deps, { key: "k", hostId: host.id }); + expect(getHost(deps.db, host.id)?.serverAccessGrantId).toBeNull(); + }); + }); + + it("keeps failed release retryable and refuses invalid grant output without echoing it", async () => { + await withTestHarness(async ({ deps }) => { + const release = vi.fn().mockRejectedValueOnce(new Error("retry")); + installProvider({ ...provider(), release }); + const host = upsertHost(deps.db, deps.hub, { name: "test" })!; + await serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }); + await expect( + serverAccess.release(deps, { key: "k", hostId: host.id }), + ).rejects.toThrow("retry"); + expect(getHost(deps.db, host.id)?.serverAccessGrantId).toBe(host.id); + installProvider({ + ...provider(), + acquire: async () => ({ + id: "id", + serverUrl: "https://secret:secret@example.com", + }), + }); + await expect( + serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }), + ).rejects.toThrow("invalid grant"); + }); + }); + + it("uses the explicit machine URL before the environment fallback", async () => { + vi.stubEnv("BB_EXTERNAL_URL", "https://fallback.example.com"); + await withTestHarness(async ({ deps }) => { + setAppSettings(deps.db, { + ...defaultAppSettings, + machineServerUrl: "https://configured.example.com", + }); + expect(await serverAccessStatus(deps)).toMatchObject({ + effectiveUrl: "https://configured.example.com", + urlSource: "setting", + }); + setAppSettings(deps.db, defaultAppSettings); + expect(await serverAccessStatus(deps)).toMatchObject({ + effectiveUrl: "https://fallback.example.com", + urlSource: "BB_EXTERNAL_URL", + }); + }); + }); +}); + +it("keeps interrupted access visible and releases the acquisition without a returned grant", async () => { + await withTestHarness(async ({ deps }) => { + const message = "Cloud device may need dashboard revocation"; + const release = vi + .fn() + .mockRejectedValueOnce(new Error(message)) + .mockResolvedValue(undefined); + installProvider({ + ...provider(), + acquire: async () => ({ status: "failed", message }), + release, + }); + const host = upsertHost(deps.db, deps.hub, { name: "interrupted" })!; + updateHost(deps.db, deps.hub, host.id, { + machineProviderId: "test-machine", + launchKey: "k", + phase: "creating", + }); + expect( + listPublicHostsWithStatus(deps).some((entry) => entry.id === host.id), + ).toBe(false); + await expect( + serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }), + ).rejects.toThrow(message); + expect(getHost(deps.db, host.id)).toMatchObject({ + serverAccessProviderId: "relay", + serverAccessGrantId: null, + statusMessage: message, + }); + expect( + listPublicHostsWithStatus(deps, { includeCreating: true }).find( + (entry) => entry.id === host.id, + )?.lifecycle.message, + ).toBe(message); + await expect( + serverAccess.release(deps, { key: "k", hostId: host.id }), + ).rejects.toThrow(message); + expect(getHost(deps.db, host.id)?.serverAccessProviderId).toBe("relay"); + await serverAccess.release(deps, { key: "k", hostId: host.id }); + expect(release).toHaveBeenLastCalledWith({ + key: "k", + hostId: host.id, + grantId: null, + }); + expect(getHost(deps.db, host.id)?.serverAccessProviderId).toBeNull(); + }); +}); + +it("routes a provider recheck to core with the calling plugin id", async () => { + await withTestHarness(async (h) => { + await h.pluginService.install("builtin:keep-awake", { kind: "root" }); + const api = h.pluginService.getApi("keep-awake"); + if (!api) throw new Error("Test plugin did not load"); + const rechecked: string[] = []; + setServerAccessRecheckHandler((pluginId) => rechecked.push(pluginId)); + try { + api.experimental_serverAccess.recheck(); + } finally { + setServerAccessRecheckHandler(undefined); + } + expect(rechecked).toEqual(["keep-awake"]); + }); +}); diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index ecb8a33533..35a0998e48 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -209,6 +209,7 @@ describe("builtin plugin reconciliation", () => { it("keeps official plugins bundled but out of the auto-install builtins", () => { const optionalNames = OFFICIAL_PLUGINS.map((plugin) => plugin.name); expect(optionalNames).toEqual([ + "environment-modal-sandbox", "browser-automation", "github", "docs", diff --git a/apps/server/test/services/plugins/keep-awake.test.ts b/apps/server/test/services/plugins/keep-awake.test.ts index 555537a69a..43ff47be95 100644 --- a/apps/server/test/services/plugins/keep-awake.test.ts +++ b/apps/server/test/services/plugins/keep-awake.test.ts @@ -40,6 +40,7 @@ describe("builtin Keep Awake plugin", () => { await vi.waitFor(() => expect(responder.requests).toHaveLength(1)); expect(responder.requests[0]?.command).toMatchObject({ type: "plugin.host.call", + contributedEnv: [], pluginId: "keep-awake", method: "setEnabled", input: { enabled: false }, @@ -60,6 +61,7 @@ describe("builtin Keep Awake plugin", () => { await vi.waitFor(() => expect(responder.requests).toHaveLength(2)); expect(responder.requests[1]?.command).toMatchObject({ type: "plugin.host.call", + contributedEnv: [], pluginId: "keep-awake", method: "setEnabled", input: { enabled: true }, diff --git a/apps/server/test/services/plugins/plugin-agent-contributions.test.ts b/apps/server/test/services/plugins/plugin-agent-contributions.test.ts index c7dd00e124..d1a4ec8de9 100644 --- a/apps/server/test/services/plugins/plugin-agent-contributions.test.ts +++ b/apps/server/test/services/plugins/plugin-agent-contributions.test.ts @@ -351,13 +351,11 @@ describe("plugin agent contributions reach thread runtime config", () => { name: "PLUGIN_CONTEXT", value: context.threadId + ":" + context.projectId + ":" + context.hostId, reason: "Expose resolution context", - secret: false, }, { name: "SHARED_TOKEN", value: "first", reason: "First registration wins", - secret: true, }, ]); } @@ -372,13 +370,11 @@ describe("plugin agent contributions reach thread runtime config", () => { name: "SHARED_TOKEN", value: "second", reason: "Conflicting registration", - secret: true, }, { name: "PLUGIN_PROXY_URL", value: { serverPath: "/plugins/env-second/proxy" }, reason: "Use the server auth proxy", - secret: false, }, ]); } @@ -425,21 +421,18 @@ describe("plugin agent contributions reach thread runtime config", () => { name: "PLUGIN_CONTEXT", value: `${thread.id}:${project.id}:${host.id}`, reason: "Expose resolution context", - secret: false, source: { plugin: "env-first" }, }, { name: "SHARED_TOKEN", value: "first", reason: "First registration wins", - secret: true, source: { plugin: "env-first" }, }, { name: "PLUGIN_PROXY_URL", value: { serverPath: "/plugins/env-second/proxy" }, reason: "Use the server auth proxy", - secret: false, source: { plugin: "env-second" }, }, ]); diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index fa674d4b7d..725a74d055 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -24,6 +24,7 @@ import { type PluginNewThreadPanelProps, type PluginPendingInteractionProps, type PluginEnvironmentProviderInputsProps, + type PluginMachineProviderInputsProps, type PluginProviderIconRegistration, type PluginTimelineRendererProps, type PluginSettingDescriptor, @@ -165,6 +166,8 @@ const BB_PLUGIN_API_KEYS = [ "experimental_aiServices", "experimental_hooks", "experimental_environments", + "experimental_machines", + "experimental_serverAccess", "sdk", "onDispose", ] as const satisfies readonly (keyof BbPluginApi)[]; @@ -209,6 +212,8 @@ const _assertAllAuthModesListed: MissingAuthMode extends never ? true : never = void _assertAllAuthModesListed; const THREAD_EVENT_PAYLOAD_FIELDS = { + "experimental_thread.events": ["thread", "sequence"], + "experimental_terminal.input": ["terminal"], "thread.created": ["thread"], "thread.active": ["thread"], "thread.idle": ["thread", "lastAssistantText"], @@ -267,6 +272,7 @@ type SlotPropsByName = { experimental_providerIcon: PluginProviderIconRegistration; experimental_timelineRenderer: PluginTimelineRendererProps; experimental_environmentProviderInputs: PluginEnvironmentProviderInputsProps; + experimental_machineProviderInputs: PluginMachineProviderInputsProps; }; type MissingSlot = Exclude; @@ -387,10 +393,11 @@ const FRONTEND_SLOT_PROP_FIELDS = { ], experimental_environmentProviderInputs: [ "projectId", - "hostId", + "target", "value", "onChange", ], + experimental_machineProviderInputs: ["value", "onChange"], } as const satisfies { [S in keyof SlotPropsByName]: readonly (keyof SlotPropsByName[S])[]; }; @@ -515,14 +522,17 @@ describe("bb-plugin-authoring skill", () => { const skillEntry = readFileSync(SKILL_PATH, "utf8"); const skill = readSkillTree(); - it("does not advertise unshipped machine providers", () => { - for (const doc of [ - skillEntry, - readReference("frontend-renderer-slots.md"), - readReference("backend-events.md"), - ]) { - expect(doc).not.toMatch(/machine providers?|custom-machine/); - } + it("documents machine creation checkpoints and private bootstrap delivery", () => { + expect(skillEntry).toContain("machine providers"); + const backend = readReference("backend-machines.md"); + expect(backend).toContain("await checkpoint(resource)"); + expect(backend).toContain("bb.experimental_machines.bootstrap({"); + expect(backend).toMatch( + /Never put the\s+bootstrap bundle in resource JSON/, + ); + expect(backend.indexOf("await checkpoint(resource)")).toBeLessThan( + backend.indexOf("bb.experimental_machines.bootstrap({"), + ); }); it("has frontmatter naming the skill after its directory", () => { @@ -667,7 +677,7 @@ describe("bb-plugin-authoring skill", () => { expect(readReference("frontend-components.md")).not.toContain( 'workspace: { type: "personal" }', ); - expect(readReference("backend-events.md")).toContain("Twelve events."); + expect(readReference("backend-events.md")).toContain("Fourteen events."); expect(readReference("backend-events.md")).toContain( "The seven `thread.*` ones", ); diff --git a/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts b/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts index e6381d36a5..0fa148d972 100644 --- a/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts +++ b/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts @@ -13,6 +13,7 @@ async function createRuntime() { const db = createConnection(":memory:"); migrate(db); return createPluginRuntime({ + machineEnrollments: null, deps: { db, hub: { diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 56db718e71..399bef0924 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -1381,7 +1381,6 @@ function seedEnvironmentAtPath( }, ): void { const host = upsertHost(db, noopNotifier, { - type: "persistent", name: "Test host", }); const { project } = createProject(db, noopNotifier, { diff --git a/apps/server/test/services/plugins/plugin-thread-events.test.ts b/apps/server/test/services/plugins/plugin-thread-events.test.ts index 69ca44daa5..994e67661c 100644 --- a/apps/server/test/services/plugins/plugin-thread-events.test.ts +++ b/apps/server/test/services/plugins/plugin-thread-events.test.ts @@ -1,3 +1,5 @@ +import { getLatestThreadSequence } from "@bb/db"; +import { emitPluginThreadEvents } from "../../../src/services/plugins/plugin-thread-events.js"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -579,3 +581,47 @@ describe("plugin thread lifecycle events", () => { } }); }); + +it("coalesces thread appends and delivers current status without reading history", async () => { + const recorded: Array<{ + thread: { id: string; status: string }; + sequence: number; + }> = []; + globals.__sequenceEvents = recorded; + const { harness, cleanup } = await setUpPluginHarness(` + export default function plugin(bb) { + bb.events.on("experimental_thread.events", (payload) => { + globalThis.__sequenceEvents.push(payload); + }); + } + `); + try { + const { thread } = seedThreadFixture(harness, { + thread: { status: "starting" }, + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + for (let i = 0; i < 20; i++) emitPluginThreadEvents(thread.id); + await vi.advanceTimersByTimeAsync(999); + expect(recorded).toHaveLength(0); + applyLoggedThreadLifecycleEvent(lifecycleDeps(harness), { + threadId: thread.id, + event: { type: "run.started" }, + }); + await vi.advanceTimersByTimeAsync(1); + expect(recorded).toMatchObject([ + { + thread: { id: thread.id, status: "active" }, + sequence: getLatestThreadSequence(harness.db, { threadId: thread.id }), + }, + ]); + emitPluginThreadEvents(thread.id); + await vi.advanceTimersByTimeAsync(1000); + expect(recorded).toHaveLength(2); + await vi.advanceTimersByTimeAsync(5000); + expect(recorded).toHaveLength(2); + } finally { + vi.useRealTimers(); + delete globals.__sequenceEvents; + await cleanup(); + } +}); diff --git a/apps/server/test/services/projects/project-source-setup.test.ts b/apps/server/test/services/projects/project-source-setup.test.ts new file mode 100644 index 0000000000..e056948a53 --- /dev/null +++ b/apps/server/test/services/projects/project-source-setup.test.ts @@ -0,0 +1,232 @@ +import { getProjectSourceByHost, projectSourceOwnsPath } from "@bb/db"; +import { describe, expect, it, vi } from "vitest"; +import { reportEnvironmentHookProgress } from "../../../src/services/environments/environment-hooks.js"; +import { ensureProjectSourceOnHost } from "../../../src/services/projects/project-source-setup.js"; +import { + listQueuedCommands, + reportQueuedCommandSuccess, + reportQueuedCommandError, + waitForQueuedCommand, +} from "../../helpers/commands.js"; +import { seedHostSession, seedProjectWithSource } from "../../helpers/seed.js"; +import { withTestHarness } from "../../helpers/test-app.js"; + +const remoteUrl = "https://example.test/team/project.git"; +const targetPath = "/private/checkouts/project-id"; + +describe("automatic project source setup", () => { + it("forwards clone progress to the provisioning report", async () => { + await withTestHarness(async (harness) => { + const source = seedHostSession(harness.deps, { id: "progress-source" }); + const target = seedHostSession(harness.deps, { id: "progress-target" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: source.host.id, + }); + const report = { step: vi.fn(), log: vi.fn() }; + const setup = ensureProjectSourceOnHost(harness.deps, { + projectId: project.id, + projectName: project.name, + hostId: target.host.id, + remoteUrl, + report, + }); + const defaultPath = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone_default_path", + ); + await reportQueuedCommandSuccess(harness, defaultPath, { + path: targetPath, + }); + const exists = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "host.paths_exist", + ); + await reportQueuedCommandSuccess(harness, exists, { + existence: { [targetPath]: false }, + }); + const clone = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone", + ); + if (clone.command.type !== "project.clone") throw new Error("bad clone"); + reportEnvironmentHookProgress(harness.deps, target.host.id, { + type: "environment.hook.progress", + operationId: clone.command.operationId, + entry: { type: "output", text: "Receiving objects: 50%", status: null }, + }); + expect(report.log).toHaveBeenCalledWith("Receiving objects: 50%\n"); + await reportQueuedCommandSuccess(harness, clone, { + path: targetPath, + gitRemoteUrl: remoteUrl, + }); + await setup; + }); + }); + + it.each(["missing", "recovered", "foreign"] as const)( + "serializes concurrent setup of a %s target", + async (target) => { + await withTestHarness(async (harness) => { + const original = seedHostSession(harness.deps, { + id: "source-original", + }); + const fresh = seedHostSession(harness.deps, { id: "source-fresh" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: original.host.id, + }); + const args = { + projectId: project.id, + projectName: project.name, + hostId: fresh.host.id, + remoteUrl, + }; + const setup = Promise.allSettled([ + ensureProjectSourceOnHost(harness.deps, args), + ensureProjectSourceOnHost(harness.deps, args), + ]); + const defaultPath = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone_default_path", + ); + expect(defaultPath.command).toEqual({ + type: "project.clone_default_path", + projectSlug: `project-${project.id}`, + }); + expect( + listQueuedCommands(harness, "project.clone_default_path"), + ).toHaveLength(1); + await reportQueuedCommandSuccess(harness, defaultPath, { + path: targetPath, + }); + const exists = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "host.paths_exist", + ); + await reportQueuedCommandSuccess(harness, exists, { + existence: { [targetPath]: target !== "missing" }, + }); + if (target === "missing") { + const clone = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone", + ); + expect(listQueuedCommands(harness, "project.clone")).toHaveLength(1); + expect(clone.command).toMatchObject({ remoteUrl, targetPath }); + await reportQueuedCommandSuccess(harness, clone, { + path: targetPath, + gitRemoteUrl: remoteUrl, + }); + } else { + const inspect = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.inspect", + ); + expect( + getProjectSourceByHost(harness.db, project.id, fresh.host.id), + ).toBeNull(); + expect(listQueuedCommands(harness, "project.clone")).toEqual([]); + await reportQueuedCommandSuccess(harness, inspect, { + path: targetPath, + gitRemoteUrl: + target === "foreign" + ? "https://example.test/unrelated.git" + : remoteUrl, + }); + } + const results = await setup; + expect( + projectSourceOwnsPath( + harness.db, + project.id, + fresh.host.id, + targetPath, + ), + ).toBe(target === "missing"); + if (target === "foreign") { + expect(results).toEqual([ + { + status: "rejected", + reason: expect.objectContaining({ + message: expect.stringContaining("does not match"), + }), + }, + { + status: "rejected", + reason: expect.objectContaining({ + message: expect.stringContaining("does not match"), + }), + }, + ]); + expect( + getProjectSourceByHost(harness.db, project.id, fresh.host.id), + ).toBeNull(); + return; + } + const source = getProjectSourceByHost( + harness.db, + project.id, + fresh.host.id, + ); + expect(source).toMatchObject({ path: targetPath }); + expect(results).toEqual([ + { status: "fulfilled", value: source }, + { status: "fulfilled", value: source }, + ]); + await expect( + ensureProjectSourceOnHost(harness.deps, { + ...args, + projectName: "Renamed project", + }), + ).resolves.toEqual(source); + expect(listQueuedCommands(harness, "project.clone")).toEqual([]); + expect( + listQueuedCommands(harness, "project.clone_default_path"), + ).toEqual([]); + }); + }, + ); +}); + +it("does not register a checkout or dispatch a turn after private repository authentication fails", async () => { + await withTestHarness(async (harness) => { + const source = seedHostSession(harness.deps, { id: "private-source" }); + const target = seedHostSession(harness.deps, { id: "private-target" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: source.host.id, + }); + const result = ensureProjectSourceOnHost(harness.deps, { + projectId: project.id, + projectName: project.name, + hostId: target.host.id, + remoteUrl, + }).then( + () => "unexpected success", + () => "checkout failed", + ); + const path = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone_default_path", + ); + await reportQueuedCommandSuccess(harness, path, { path: targetPath }); + const exists = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "host.paths_exist", + ); + await reportQueuedCommandSuccess(harness, exists, { + existence: { [targetPath]: false }, + }); + const clone = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone", + ); + await reportQueuedCommandError(harness, clone, { + errorCode: "git_auth_failed", + errorMessage: "Repository access denied", + }); + expect(await result).toBe("checkout failed"); + expect( + getProjectSourceByHost(harness.db, project.id, target.host.id), + ).toBeNull(); + expect(listQueuedCommands(harness, "thread.start")).toEqual([]); + }); +}); diff --git a/apps/server/test/services/prompt-history.test.ts b/apps/server/test/services/prompt-history.test.ts index efb6ec0511..c00abf1fbe 100644 --- a/apps/server/test/services/prompt-history.test.ts +++ b/apps/server/test/services/prompt-history.test.ts @@ -37,7 +37,6 @@ function setup() { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const firstProject = createProject(db, noopNotifier, { name: "Project A", diff --git a/apps/server/test/services/threads/conversation-outline-performance.test.ts b/apps/server/test/services/threads/conversation-outline-performance.test.ts index 104b3bcf58..68e707b50c 100644 --- a/apps/server/test/services/threads/conversation-outline-performance.test.ts +++ b/apps/server/test/services/threads/conversation-outline-performance.test.ts @@ -34,7 +34,6 @@ function setup(status: Thread["status"] = "starting") { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/services/threads/lifecycle-outcome.test.ts b/apps/server/test/services/threads/lifecycle-outcome.test.ts index 8eb72dc4ae..c4b0d73206 100644 --- a/apps/server/test/services/threads/lifecycle-outcome.test.ts +++ b/apps/server/test/services/threads/lifecycle-outcome.test.ts @@ -120,7 +120,6 @@ function setup(status: ThreadStatus): Setup { migrate(db); const hub = new NotificationHub(); const host = upsertHost(db, noopNotifier, { - type: "persistent", id: "host-lifecycle-outcome", name: "Lifecycle Outcome Host", }); @@ -155,7 +154,6 @@ function connectDaemon(db: DbConnection, hub: NotificationHub, hostId: string) { hostId, instanceId: `instance-${randomUUID()}`, hostName: "Lifecycle Outcome Host", - hostType: "persistent", dataDir: `/tmp/${hostId}`, protocolVersion: 1, heartbeatIntervalMs: 5_000, diff --git a/apps/server/test/services/threads/thread-runtime-display.test.ts b/apps/server/test/services/threads/thread-runtime-display.test.ts index 5bdbf7d4d9..d24458ae06 100644 --- a/apps/server/test/services/threads/thread-runtime-display.test.ts +++ b/apps/server/test/services/threads/thread-runtime-display.test.ts @@ -131,7 +131,6 @@ function setup(): SetupResult { const host = upsertHost(db, noopNotifier, { id: "host-runtime-display", name: "Runtime Display Host", - type: "persistent", }); return { db, hostId: host.id, hub }; } @@ -151,7 +150,6 @@ function openTestSession(args: OpenTestSessionArgs) { hostId: args.hostId, instanceId: `instance-${randomUUID()}`, hostName: "Runtime Display Host", - hostType: "persistent", dataDir: `/tmp/${args.hostId}`, protocolVersion: 1, heartbeatIntervalMs: 5_000, diff --git a/apps/server/test/services/threads/timeline-context-clear.test.ts b/apps/server/test/services/threads/timeline-context-clear.test.ts index 5f64a004a1..a36018f73b 100644 --- a/apps/server/test/services/threads/timeline-context-clear.test.ts +++ b/apps/server/test/services/threads/timeline-context-clear.test.ts @@ -28,7 +28,6 @@ function setup(): { db: DbConnection; thread: Thread } { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/services/threads/timeline-event-budget.test.ts b/apps/server/test/services/threads/timeline-event-budget.test.ts index 5f7fc7d984..4729d44773 100644 --- a/apps/server/test/services/threads/timeline-event-budget.test.ts +++ b/apps/server/test/services/threads/timeline-event-budget.test.ts @@ -116,7 +116,6 @@ function setup(connection?: DbConnection): { if (connection === undefined) migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/services/threads/timeline-head-state.test.ts b/apps/server/test/services/threads/timeline-head-state.test.ts index 6948d2fd1f..678d9bdf16 100644 --- a/apps/server/test/services/threads/timeline-head-state.test.ts +++ b/apps/server/test/services/threads/timeline-head-state.test.ts @@ -34,7 +34,6 @@ function setup(): { db: DbConnection; thread: Thread } { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/services/threads/timeline-in-turn-window.test.ts b/apps/server/test/services/threads/timeline-in-turn-window.test.ts index 0b9a78efb6..2b0f6b3f26 100644 --- a/apps/server/test/services/threads/timeline-in-turn-window.test.ts +++ b/apps/server/test/services/threads/timeline-in-turn-window.test.ts @@ -51,7 +51,6 @@ function setup(): { db: DbConnection; thread: Thread } { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/services/threads/timeline-parented-pagination.test.ts b/apps/server/test/services/threads/timeline-parented-pagination.test.ts index 9e30f41d9e..fd9e5c0e15 100644 --- a/apps/server/test/services/threads/timeline-parented-pagination.test.ts +++ b/apps/server/test/services/threads/timeline-parented-pagination.test.ts @@ -38,7 +38,6 @@ function setup(): SetupResult { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/services/threads/timeline-provider-input.test.ts b/apps/server/test/services/threads/timeline-provider-input.test.ts index 9533a84e4d..21bc1810cf 100644 --- a/apps/server/test/services/threads/timeline-provider-input.test.ts +++ b/apps/server/test/services/threads/timeline-provider-input.test.ts @@ -27,7 +27,6 @@ function setup(): { db: DbConnection; thread: Thread } { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts b/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts index de014dbf54..3bc92cff80 100644 --- a/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts +++ b/apps/server/test/services/threads/timeline-workflow-progress-window.test.ts @@ -48,7 +48,6 @@ function setup(): { db: DbConnection; thread: Thread } { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/system/execution-options.test.ts b/apps/server/test/system/execution-options.test.ts index 8509fb8eac..d09dc8bbf9 100644 --- a/apps/server/test/system/execution-options.test.ts +++ b/apps/server/test/system/execution-options.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { getAppSettings, setAppSettings } from "@bb/db"; +import { getAppSettings, setAppSettings, updateHost } from "@bb/db"; import { hostDaemonServerWsMessageSchema, type HostDaemonOnlineRpcRequestMessage, @@ -20,6 +20,7 @@ import { } from "../helpers/host-rpc.js"; import { seedEnvironment, + seedHost, seedHostSession, seedProjectWithSource, seedSession, @@ -586,6 +587,39 @@ describe("resolveSystemExecutionOptions", () => { }); }); + it("skips provider discovery while the host is suspended", async () => { + await withTestHarness({}, async (harness) => { + const warn = vi.fn(); + harness.deps.logger = { ...harness.deps.logger, warn }; + const host = seedHost(harness.deps, { + id: "host-execution-options-suspended", + }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: 123, + }); + const request = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + + const response = await resolveSystemExecutionOptions(harness.deps, { + hostId: host.id, + providerId: "codex", + }); + + expect(response.providers.map((provider) => provider.id)).toEqual([ + "codex", + "claude-code", + "pi", + "acp-cursor", + ]); + expect(response.modelLoadError).toEqual({ + providerId: "codex", + code: "failed", + }); + expect(request).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + }); + it("spends one command timeout across installed-only provider discovery", async () => { await withTestHarness( { seedFirstPartyProviders: false }, diff --git a/apps/server/test/system/provider-states.test.ts b/apps/server/test/system/provider-states.test.ts index e0236933e3..4c49e8dcf4 100644 --- a/apps/server/test/system/provider-states.test.ts +++ b/apps/server/test/system/provider-states.test.ts @@ -1,11 +1,13 @@ import type { ProviderHealth } from "@bb/host-daemon-contract"; -import { describe, expect, it } from "vitest"; +import { updateHost } from "@bb/db"; +import { describe, expect, it, vi } from "vitest"; import { getProviderStates } from "../../src/services/system/provider-states.js"; import { setPluginAgentContributions } from "../../src/services/plugins/plugin-agent-contributions.js"; import { registerHostRpcResponder } from "../helpers/host-rpc.js"; import { minimalProviderRegistration } from "../helpers/provider-registry.js"; import { seedEnvironment, + seedHost, seedHostSession, seedProjectWithSource, } from "../helpers/seed.js"; @@ -43,6 +45,39 @@ function healthForInstalledOnlyProvider( } describe("getProviderStates", () => { + it("reports paused readiness without probing a suspended host", async () => { + await withTestHarness(async (harness) => { + const host = seedHost(harness.deps, { + id: "host-provider-states-suspended", + }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: 123, + }); + const request = vi.spyOn(harness.hub, "requestHostOnlineRpc"); + + const result = await getProviderStates(harness.deps, { + hostId: host.id, + }); + + expect(result.providers.map((provider) => provider.providerId)).toEqual([ + "codex", + "claude-code", + "pi", + "acp-cursor", + ]); + expect(result.providers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + status: "unknown", + statusMessage: "Machine is paused.", + }), + ]), + ); + expect(request).not.toHaveBeenCalled(); + }); + }); + it("reports an unauthenticated provider as ready when contributed env supplies credentials", async () => { await withTestHarness(async (harness) => { setPluginAgentContributions({ @@ -100,8 +135,7 @@ describe("getProviderStates", () => { ), ).toMatchObject({ status: "ready", - statusMessage: - "Credentials are provided by the Account Pooler hub.", + statusMessage: "Credentials are provided by the Account Pooler hub.", planLabel: "Proxied", accountEmail: null, loginCommand: null, diff --git a/apps/server/test/threads/environment-providers.test.ts b/apps/server/test/threads/environment-providers.test.ts index 9fd07a6e6a..f37cec19c0 100644 --- a/apps/server/test/threads/environment-providers.test.ts +++ b/apps/server/test/threads/environment-providers.test.ts @@ -12,12 +12,14 @@ import { createProjectSource, ensurePersonalProject, getEnvironment, + getNonDestroyedHostByLaunchKey, getPreparingEnvironment, getDefaultProjectSource, getThread, getThreadStartupContext, listEnvironments, listEvents, + setProjectGitRemoteUrlIfMissing, } from "@bb/db"; import { PERSONAL_PROJECT_ID, type JsonValue } from "@bb/domain"; import type { @@ -27,14 +29,20 @@ import type { PluginHookName, } from "@get-bb/plugin-sdk"; import type { PluginEnvironmentProviderValidateContext } from "@get-bb/plugin-sdk/environment-provider"; -import { validatePluginEnvironmentProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import type { PluginMachineProviderCreateContext } from "@get-bb/plugin-sdk/machine-provider"; +import { + validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk/internal/host-policy"; import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { ApiError } from "../../src/errors.js"; import { setPluginEnvironmentProviderBridge, + type PluginEnvironmentCompositionRecord, type PluginEnvironmentProviderRecord, } from "../../src/services/plugins/plugin-environment-provider-registry.js"; +import { setPluginMachineProviderBridge } from "../../src/services/plugins/plugin-machine-provider-registry.js"; import { setPluginHookProvider, type PluginHookRegistration, @@ -95,7 +103,10 @@ const CONTAINER_INPUTS = z.object({ cpus: z.number().int().positive().default(2), }); -function installTargets(fakes: FakeTarget[]): void { +function installTargets( + fakes: FakeTarget[], + compositions: PluginEnvironmentCompositionRecord[] = [], +): void { const records: PluginEnvironmentProviderRecord[] = fakes.map((fake) => ({ pluginId: PLUGIN_ID, provider: validatePluginEnvironmentProviderDeclaration({ @@ -117,6 +128,7 @@ function installTargets(fakes: FakeTarget[]): void { }), })); setPluginEnvironmentProviderBridge({ + listEnvironmentCompositions: () => compositions, listEnvironmentProviders: () => records, getEnvironmentProvider: (id) => records.find((record) => record.provider.id === id), @@ -165,6 +177,7 @@ function installEnvironmentIntentProbe(): PluginDispatchEnvironmentIntent[] { afterEach(() => { clearAllThreadProvisionSchedules(); setPluginEnvironmentProviderBridge(undefined); + setPluginMachineProviderBridge(undefined); setPluginHookProvider(undefined); }); @@ -192,6 +205,116 @@ function seedTargetFixture( return { environment, host, project, session }; } +describe("machine and environment provider composition", () => { + it("creates the environment before its machine and mirrors machine failure", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "composition-environment-first", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + "https://example.test/project.git", + ); + const started = createDeferredPromise(); + const release = createDeferredPromise(); + const machine = { + pluginId: "cloud", + provider: validatePluginMachineProviderDeclaration({ + description: "Provision a test machine.", + icon: "Terminal", + id: "test-machine", + displayName: "Test machine", + create: async ({ report }: PluginMachineProviderCreateContext) => { + report.step("Booting cloud machine"); + report.log("Allocating VM\n"); + started.resolve(); + await release.promise; + return { status: "failed" as const, message: "Cloud quota exceeded" }; + }, + reconcileCleanup: async () => ({ status: "removed" }), + remove: async () => ({ status: "removed" }), + }), + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [machine], + getMachineProvider: (id) => + id === machine.provider.id ? machine : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + installTargets( + [ + { + id: "project-checkout", + requiresProjectCheckout: true, + provision: () => { + throw new Error("Environment provider started before the machine"); + }, + }, + ], + [ + { + pluginId: "cloud", + composition: { + id: "test-sandbox", + displayName: "Test sandbox", + machineProviderId: "test-machine", + environmentProviderId: "project-checkout", + }, + }, + ], + ); + + const thread = await createThreadFromRequest(harness.deps, { + environment: { + type: "provider", + environmentProviderId: "test-sandbox", + inputs: null, + }, + projectId: project.id, + input: textInput("Create it"), + origin: "app", + providerId: "codex", + model: "requested-model", + startedOnBehalfOf: null, + }); + + await started.promise; + await expect + .poll(() => getPreparingEnvironment(harness.db, thread.id)) + .toMatchObject({ + status: "creating", + statusMessage: "Booting cloud machine", + pendingLog: expect.stringContaining("Allocating VM"), + }); + const machineHost = getNonDestroyedHostByLaunchKey(harness.db, thread.id); + expect(machineHost).toMatchObject({ + phase: "creating", + statusMessage: "Booting cloud machine", + }); + expect(getPreparingEnvironment(harness.db, thread.id)?.hostId).toBe( + machineHost?.id, + ); + + release.resolve(); + await expect + .poll(() => getPreparingEnvironment(harness.db, thread.id)) + .toMatchObject({ + status: "error", + statusMessage: "Cloud quota exceeded", + }); + }); + }); +}); + function createTargetThread( harness: TestAppHarness, args: { @@ -1417,6 +1540,7 @@ describe("provider inputs are parsed at create time", () => { }; expect(body.providers).toEqual([ { + machineProviderId: null, id: PROVIDER_ID, displayName: "Fake container", icon: null, @@ -1441,6 +1565,7 @@ describe("provider inputs are parsed at create time", () => { availability: null, }, { + machineProviderId: null, id: "plain", displayName: "Fake container", icon: null, diff --git a/apps/server/test/threads/thread-create-helpers.test.ts b/apps/server/test/threads/thread-create-helpers.test.ts index 97cd22b457..f7a34dedb6 100644 --- a/apps/server/test/threads/thread-create-helpers.test.ts +++ b/apps/server/test/threads/thread-create-helpers.test.ts @@ -114,7 +114,6 @@ describe("createThreadRecord", () => { const deps = { db, hub: noopNotifier }; const host = upsertHost(db, noopNotifier, { name: "Test Host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "Test Project", diff --git a/apps/server/test/threads/thread-data.test.ts b/apps/server/test/threads/thread-data.test.ts index 7f1432b208..a950f6649b 100644 --- a/apps/server/test/threads/thread-data.test.ts +++ b/apps/server/test/threads/thread-data.test.ts @@ -15,7 +15,6 @@ function setup() { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/threads/thread-parent.test.ts b/apps/server/test/threads/thread-parent.test.ts index 2a2650c16f..def2bfefe0 100644 --- a/apps/server/test/threads/thread-parent.test.ts +++ b/apps/server/test/threads/thread-parent.test.ts @@ -21,7 +21,6 @@ function setup() { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/threads/thread-provisioning-state.test.ts b/apps/server/test/threads/thread-provisioning-state.test.ts index b7b34f6296..55f78617d6 100644 --- a/apps/server/test/threads/thread-provisioning-state.test.ts +++ b/apps/server/test/threads/thread-provisioning-state.test.ts @@ -18,7 +18,6 @@ function setup() { const db = createConnection(":memory:"); migrate(db); const host = upsertHost(db, noopNotifier, { - type: "persistent", name: "test-host", }); const { project } = createProject(db, noopNotifier, { diff --git a/apps/server/test/threads/turn-failed-retry.test.ts b/apps/server/test/threads/turn-failed-retry.test.ts index 09d2379ade..572867774b 100644 --- a/apps/server/test/threads/turn-failed-retry.test.ts +++ b/apps/server/test/threads/turn-failed-retry.test.ts @@ -75,6 +75,8 @@ afterEach(() => { function recordTurnFailedAnnouncements(): string[] { const announced: string[] = []; setPluginThreadEventEmitter({ + emitThreadEvents: () => {}, + emitTerminalInput: () => {}, emitThreadCreated: () => {}, emitThreadActive: () => {}, emitThreadIdle: () => {}, diff --git a/apps/web/src/routes/api.connect.machine-code.tsx b/apps/web/src/routes/api.connect.machine-code.tsx index e3c80ae6f9..496e12bb48 100644 --- a/apps/web/src/routes/api.connect.machine-code.tsx +++ b/apps/web/src/routes/api.connect.machine-code.tsx @@ -2,12 +2,23 @@ import { createFileRoute } from "@tanstack/react-router"; import { createMachineCodeForServerCredential, depsFromEnv, + lookupMachineCodeForServerCredential, } from "@/server/api"; import { getEnv } from "@/server/env"; export const Route = createFileRoute("/api/connect/machine-code")({ server: { handlers: { + GET: async ({ request }) => { + const result = await lookupMachineCodeForServerCredential( + depsFromEnv(getEnv()), + request.headers.get("x-bb-connect-machine") ?? "", + request.headers.get("x-bb-connect-code") ?? "", + ); + return Response.json(result, { + status: "status" in result ? result.status : 200, + }); + }, POST: async ({ request }) => { const credential = request.headers.get("x-bb-connect-machine") ?? ""; const result = await createMachineCodeForServerCredential( diff --git a/apps/web/src/server/api.test.ts b/apps/web/src/server/api.test.ts index 427e281931..601e157418 100644 --- a/apps/web/src/server/api.test.ts +++ b/apps/web/src/server/api.test.ts @@ -20,6 +20,7 @@ import { claimHandle, createConnectCode, createMachineCodeForServerCredential, + lookupMachineCodeForServerCredential, createServer, disconnectServer, removeServer, @@ -542,8 +543,38 @@ describe("server-authenticated machine-code round trip", () => { if ("status" in minted) throw new Error(minted.error); expect(minted.serverUrl).toBe("https://sawyer-desktop.getbb.app"); + expect( + await lookupMachineCodeForServerCredential( + deps, + serverCredential, + minted.code, + ), + ).toEqual({ consumed: false, machineId: null }); const redeemed = await redeemMachineCode(deps, minted.code); if ("error" in redeemed) throw new Error(redeemed.error); + expect( + await lookupMachineCodeForServerCredential( + deps, + serverCredential, + minted.code, + ), + ).toEqual({ consumed: true, machineId: redeemed.machineId }); + expect( + await lookupMachineCodeForServerCredential(deps, "bogus", minted.code), + ).toMatchObject({ status: 401 }); + const other = await createServer(deps, "u1", "sawyer-other"); + if (!("ok" in other)) throw new Error("server setup failed"); + db.update(server) + .set({ credentialHash: await sha256Hex("bbcred_other") }) + .where(eq(server.id, other.server.id)) + .run(); + expect( + await lookupMachineCodeForServerCredential( + deps, + "bbcred_other", + minted.code, + ), + ).toMatchObject({ status: 404 }); expect(redeemed.credential.startsWith("bbcm_")).toBe(true); expect(redeemed.serverUrl).toBe("https://sawyer-desktop.getbb.app"); expect(db.select().from(machine).all()).toHaveLength(1); diff --git a/apps/web/src/server/api.ts b/apps/web/src/server/api.ts index c77dc8eb17..6acec9e34a 100644 --- a/apps/web/src/server/api.ts +++ b/apps/web/src/server/api.ts @@ -730,6 +730,56 @@ export async function redeemConnectCode( }; } +async function machineIdForCode(userId: string, code: string): Promise { + const hash = await sha256Hex(JSON.stringify([userId, code])); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`; +} + +export async function lookupMachineCodeForServerCredential( + deps: Pick, + credential: string, + code: string, +): Promise< + | { consumed: boolean; machineId: string | null } + | { error: string; status: number } +> { + const srv = await deps.db + .select() + .from(server) + .where( + and( + eq(server.credentialHash, await sha256Hex(credential.trim())), + isNull(server.revokedAt), + ), + ) + .get(); + if (!credential.trim() || !srv) return { error: "unauthorized", status: 401 }; + const row = await deps.db + .select() + .from(connectCode) + .where( + and( + eq(connectCode.code, code.trim().toUpperCase()), + eq(connectCode.serverId, srv.id), + eq(connectCode.userId, srv.userId), + eq(connectCode.purpose, "machine-pair"), + ), + ) + .get(); + if (!row) return { error: "invalid-code", status: 404 }; + const device = await deps.db + .select({ id: machine.id }) + .from(machine) + .where( + and( + eq(machine.id, await machineIdForCode(row.userId, row.code)), + eq(machine.userId, srv.userId), + ), + ) + .get(); + return { consumed: row.consumedAt !== null, machineId: device?.id ?? null }; +} + export async function redeemMachineCode( deps: Pick, code: string, @@ -777,7 +827,7 @@ export async function redeemMachineCode( return { error: "already-used", status: 409 }; const credential = generateToken("bbcm_", 32); - const machineId = crypto.randomUUID(); + const machineId = await machineIdForCode(row.userId, normalized); await db .insert(machine) .values({ diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index b3338ceb72..ef267aac38 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -54,8 +54,8 @@ calls it for each matching session and turn with the thread, project, and host ids, validates at most 32 environment entries, resolves registration conflicts in plugin load order, and sends the winning values to the host. A value may be a literal string or a server-relative path that the host expands against its -authenticated `BB_SERVER_URL`. Contributions override the shell environment; -entries marked `secret` are masked in provider environment events. The resolver +authenticated `BB_SERVER_URL`. Contributions override the shell environment and +their values are reported as-is in provider environment events. The resolver receives `ExperimentalPluginProviderEnvContext` and returns `ExperimentalPluginProviderEnvEntry` values. @@ -516,8 +516,11 @@ the current decision timeout is appropriate before stabilizing it. **What it does.** The picker-side half of environment providers. The slot registers the control the New Thread environment picker renders beside this plugin's selected provider (`{ environmentProviderId, component }`; props -`{ projectId, hostId, value, onChange }` over the selection's `inputs` JSON -value — `hostId` is the picked machine; `onChange({ status: "ready", value })` supplies valid inputs and +`{ projectId, target, value, onChange }` over the selection's `inputs` JSON +value. `target` is `{ kind: "existing-host", hostId: string }` for a selected host +or `{ kind: "new-host" }` before machine provisioning. This replaces the nullable +`hostId` prop; backend create still receives the provisioned host. +`onChange({ status: "ready", value })` supplies valid inputs and `onChange({ status: "blocked", reason })` blocks submit with the plugin's reason). It is mounted only for a provider that declared `inputs`. A provider whose schema accepts empty input can be submitted without a registered slot; any @@ -564,6 +567,70 @@ and owns the existing/new selection, labels, blocker copy, and emitted inputs. has to report ready from an effect on mount, as the worktree does. Decide whether the registration should instead declare a default value. +## `app.slots.experimental_machineProviderInputs` (`@get-bb/plugin-sdk/app`) + +Supporting app exports are `PluginMachineProviderInputsRegistration`, +`PluginMachineProviderInputsProps`, and `PluginMachineProviderInputsChange`. + +**What it does.** Registers the compact app control for one machine provider's +inputs with `{ machineProviderId, component }`. The component receives +`{ value, onChange }` and reports ready JSON or a blocked reason. It renders in +the New Thread toolbar when a selected environment composition declares machine +inputs. A control reports its +default ready value on mount and loads richer choices only after its shared +responsive drawer opens. The resulting value is persisted and readable by +every plugin, so it must contain no secrets; credentials belong in plugin +settings and the value carries only non-secret configuration or references. + +**Audit before stabilizing.** Confirm the compact-chip and responsive-drawer +shape works across machine providers, that ready/blocked is sufficient, and +that provider changes and crashed controls cannot retain stale launch inputs. + +## `bb.experimental_machines` (`register`) + +Registers project-independent machine providers with required id, displayName, +description and icon; optional ephemeral policy, Standard Schema inputs, +availability and validate; required create, reconcileCleanup and remove; and optional paired +suspend/resume callbacks. Core validates descriptions/icons and parses inputs +at registration/creation boundaries. Inputs and resource JSON must not contain +credentials. Resource records are bounded to 16 KiB. + +Core owns enrollment, durable launches, cleanup retries and coordinated lifecycle +transitions. Plugins own allocation, filesystem preservation and idle policy. +Each `bootstrap` call revokes any pending credential and issues a fresh one for +the same durable host identity. +Create returns a readable machine name and private resource. Create failures are +terminal; providers retry vendor API hiccups inside create. +Create awaits checkpoint before bootstrap. Suspend and +resume also await checkpoint before destructive cleanup/bootstrap. All three +checkpoint signatures return Promise; a rejected checkpoint stops the +provider's subsequent work. + +`getResource(hostId)` reads persisted resource metadata across plugins; it does +not perform vendor observation. The containing namespace is experimental, so +getResource does not repeat that prefix. + +Core clones a project's remote only when the concrete environment provider +requires a checkout and that host has none. Machine-only registration does not +create an environment picker row; explicit composition supplies that behavior. + +Before stabilization, verify registration validation, creation-key ownership, +interrupted allocation cleanup, checkpoint rejection, resource privacy, removal +serialization and same-identity restoration. Machine lifecycle uses removing; +removal retry timing is internal, not a public retirement policy. + +## `@get-bb/plugin-sdk/machine-provider` + +Exports provider definitions, input schemas, availability/validation results, +create and lifecycle contexts, progress and resource/removal results. +Create receives inputs/key/attempt/checkpoint/report/signal and no project. +ReconcileCleanup receives the last checkpointed resource and only +discovers/removes uncertain allocations. Suspend and resume share the lifecycle +context and must be registered together. Description and icon are required. + +Supporting declarations belong to the experimental machine namespace. +Stabilization follows the registration audit above. + ## `bb.branding.experimental_icons` (manifest) and namespaced presentation glyphs **What it does.** A plugin ships SVG files and declares a name → file map in @@ -1702,13 +1769,13 @@ while a palette switch resolves, so a consumer never paints an unthemed frame. ## `app.slots.experimental_providerIcon` (`@get-bb/plugin-sdk/app`) **Kept experimental (2026-08-22).** zero shipped registrations — first-party -agent and environment providers use declared glyphs or SVG assets, +agent, environment, and machine providers use declared glyphs or SVG assets, and the provider catalogs' `glyph` / `logoUrl` icon metadata covers both forms without a frontend bundle; the open questions are id squatting and whether the slot should exist at all (deleting it is the owner's call). **What it does.** Lets a plugin frontend supply the React component bb draws -as one agent or environment provider's icon: `{ providerId, icon }`, +as one agent, environment, or machine provider's icon: `{ providerId, icon }`, where `icon` receives only the host's `className` (sizing; agent providers also have the declared `strings.iconTint`). The component wins over the provider's served `logoUrl`, which the host otherwise draws as a `currentColor` mask. @@ -2594,3 +2661,235 @@ and plugin file navigation feed the same opener boundary. file-tree navigation, and unsaved-edit behavior with more editor consumers. 4. Verify older hosts omit the prop safely and older plugins ignore it; audit reload restoration and cross-client range updates under the existing tab policy. + +## `bb.experimental_serverAccess.recheck` + +Availability is only read when something loads system configuration, so a +provider that gains or loses access between reads leaves Machines settings +showing a stale state. `recheck` is the plugin's way to say its availability +changed; core re-reads the registered providers and notifies connected clients +with a system `config-changed` change, exactly like the environment provider +recheck handler. It is a fire-and-forget signal, not a request for a decision, +and it carries no payload: core re-invokes `availability` rather than trusting +anything the caller reports. Callers must debounce to real transitions — +Connect signals only when its paired state or public URL changes, not on every +tunnel status publish, because each call broadcasts to every client. + +Stabilization requires proving that a recheck from an unregistered or disposed +plugin is inert, that a provider cannot use it to force repeated refreshes of +unrelated configuration, and that pairing, unpairing and credential rejection +each reach the Machines settings section without a manual reload. + +## `bb.experimental_serverAccess.register` + +Availability may include an optional public `serverUrl` for display in Machines +settings. Core validates HTTP(S) URLs without embedded credentials. This is +display metadata, not a machine access grant. Audit provider URL safety before +stabilizing this field. + +`PluginServerAccess` registers server access through `ServerAccessProviderDeclaration`: id, +displayName, description, availability, acquire({ key, hostId, signal }) and +release({ key, hostId, grantId }). Acquire returns either a `ServerAccessGrant` carrying +`{ id, serverUrl, headers?: Record }` or +`{ status: "failed", message }`. +Machines attach these optional headers to enrollment, HTTP, WebSocket and runtime +requests. A direct grant omits headers; access providers own credential redemption. +`ServerAccessSelection` selects a provider explicitly. Core persists only +provider id and grant id per host; credentials travel in bootstrap delivery. +Direct access reads machineServerUrl with BB_EXTERNAL_URL fallback. +defaultMachineAccess selects a provider; otherwise core uses the first registered +provider, or direct when none are registered. Access covers account-pool and other +runtime requests after enrolment. Connect redeems Cloud codes server-side and persists connectMachineId with the +grant before returning it, allowing release to revoke even before enrollment. +Host detail retains connectMachineId from trusted gate metadata for legacy grants. + +The failed acquisition result exposes its deliberate user-safe recovery message +through the plugin boundary; ordinary thrown errors stay redacted. Release receives a null grantId when acquire was interrupted. Core persists the +provider before acquisition and retries release by key and hostId. Keep intent +and credential-bearing grants in secret storage; only non-secret revocation +metadata belongs in KV. + +Before stabilization, prove retry-safe acquire/release across process death, +credential privacy and revocation, explicit and automatic defaults, expired +codes, removal while a provider is unavailable, and both enrolment and runtime +traffic with independent direct and Connect consumers. Availability does not +prove reachability from a remote machine. + +## Machine enrollment and bootstrap + +`bb.experimental_machines.bootstrap` prepares enrollment and waits for the daemon connection. Enrollment keys are scoped to the calling plugin and retain their host identity. Pending credentials are single-use and short-lived. Manual bootstrap bundles live only in server memory, so the user regenerates the command after a restart. Bootstrap bundles carry optional access headers. A successful exchange is recovered as `enrolled` after a server crash. + +`MachineExecutor` carries argv, stdin, a timeout, and an abort signal. `bootstrap` passes the bundle through private stdin, ignores remote output, and reports fixed progress messages. It installs pending enrollments and starts enrolled machines again so snapshot restores can reuse their identity. When the executor is omitted, it waits for a manual connection. It returns the reserved host ID. Installation requires Node, npm, and curl and installs no OS packages. + +Stabilization requires independent Modal and SSH consumers, failure verification for expired credentials, concurrent retries, interrupted exchange, cancellation, identity mismatch, and restored snapshots, plus an audit that credentials never enter resource data or logs. Migration and live vendor verification remain part of the integration release gate. + +The bootstrap surface's supporting exports are `MachineExecutorRequest`, +`MachineExecutor`, `MachineBootstrapRequest`, and `MachineBootstrapApi`. +They belong to experimental `PluginMachines`; their unprefixed names do not +indicate stabilization. Bootstrap uses executor `exec`. Create must persist an +allocated resource with `await checkpoint(resource)`, then bootstrap with its +durable key. Stabilization must verify cleanup +of checkpointed allocation before successful enrollment, including safe no-op +uninstall when installation never began, and retry after partial installation. + +## Machine provider `reconcileCleanup` + +Required reconciliation-only cancellation callback on `PluginMachineProviderDefinition`. +It remains experimental through `bb.experimental_machines`; the unprefixed callback +name does not indicate stabilization. +Core supplies the durable launch key, last checkpointed resource or null, +progress reporter and a cleanup signal. Providers discover and remove uncertain +allocations using vendor tags, names or metadata; the callback must never allocate or bootstrap. +Return removed only after cleanup is settled (including no submitted allocation), or +failed while the allocation is unresolved. Core persists the core cleanup retry deadline +across sweeps and restarts, including subsequent access release failures. +Stabilization requires crash/abort coverage before submission, after submission but +before checkpoint, eventual vendor discovery, and access-release retry coverage for +each shipped provider. + +## Machine provider `ephemeral` + +Optional boolean on `PluginMachineProviderDefinition`, defaulting to false. +Providers set it only when they create disposable compute. After the last +live thread and its creating/ready machine launch are gone, core automatically +requests removal regardless of environment retirement policy. Ephemeral removal +does not invoke environment providers or resume suspended compute; after compute +removal succeeds, core marks every attached environment destroyed with teardown +removed as read-only history. The request uses the ordinary durable machine +removal lifecycle, including `removeRetryAt` retries. Manually enrolled machines and providers +that omit the field are never automatically removed. Stabilization requires +another compute provider and recovery coverage across environment retirement, +live-work races, provider unavailability, and repeated removal failures. + +### Machine lifecycle allocation checkpoint + +`PluginMachineProviderLifecycleContext.checkpoint(resource): Promise` on +the experimental machine-provider contract persists a bounded recovery record. +Create extends this context; suspend and resume add the current host and resource. +It is not a filesystem save, and daemon-connected is not agent-ready: checkout +setup and provider authentication remain core work. +Core fences provider ownership, lifecycle phase and a persisted operation ID; +stale resume, suspend and removal completions cannot replace newer state. Restart +uses the checkpoint with the same enrollment key. Stabilization requires real-DB +crash recovery after allocation/before bootstrap, competing lifecycle operations, +wrong-owner rejection and no duplicate enrollment. Standalone launch submission +is durable; SDK submit/launch/follow/cancel match CLI create/status/cancel. + +Unknown-allocation reconciliation stops after a 30-minute launch window; +unresolved cleanup remains recorded. Known resources and access release keep +retrying at the core retry interval indefinitely. An explicit cancel retries cleanup even +after automatic retries are exhausted. + +## Transient provider setup data + +Machine setup slots receive host identity and removal controls. Core keeps the +manual bootstrap bundle in memory and serves the command and expiry through the +host-keyed enrollment-command endpoint. Reading does not renew the enrollment. +Completion, removal, expiry, and server restart remove the cached command. Commands stay out of +persisted progress and transcripts. Machine credentials cannot retrieve commands. + +Stabilization requires expiry/removal, credential authorization and +transcript-redaction coverage. + +## Project checkout ownership + +The provider context's `projectCheckout.experimental_ownsPath` identifies a +checkout materialised by core; absence from older servers means unowned. Core +supplies an explicit boolean, derived from persisted source ownership, never +from caller inputs. Project checkout reports ownsPath only for that exact path, +so core's environment hook policy applies to fresh machine clones and leaves +user-maintained attachments alone. Audit ownership propagation and recovery before stabilizing. + +## Coordinated machine maintenance + +Plugins own idle timing using event notifications, plugin KV and background schedules. +Core does not impose a second idle timeout or veto pause merely because work is active. + +`bb.sdk.hosts.experimental_suspend({hostId})` accepts follow-ups into the existing host-wait queue, drains active turns, setup hooks +and terminals with a five-minute bound, then invokes the provider's suspend callback. +It rejects with `machine_busy` while persisted state still ties a live thread launch or a +provisioning environment to the host, or while core is preparing a project checkout there. +Automatic idle schedulers retry after that state clears. +`await suspend.checkpoint(resource)` durably persists opaque provider state before destructive +cleanup. Core fences operations and resumes the same host identity without rerunning checkout setup. Providers own vendor observations, snapshots, loss reporting, +and expiry scheduling using `bb.background.schedule` and startup reconciliation. +Providers must reserve the full drain bound plus snapshot time and scheduling jitter; +a server outage or late wake cannot guarantee preservation. Unsafe recovery must fail +inside the provider; a dispatch hook is not an integrity boundary. + +The host DTO's lifecycle phase and progress expose maintenance state and suspension +or resume failures. Explicit machine removal remains available. +Stabilization requires interruption, checkpoint/restart, removal serialization, +failed drain, bounded drain and same-identity restore tests. + +## Thread-sequence and terminal-input notifications + +`PluginEvents.on("experimental_thread.events", handler)` delivers `{thread, sequence}`. +Core coalesces appends per thread into one notification per one-second window, reading +the latest sequence and current public thread DTO at delivery. Continuous output is +reported periodically; the last pending window is delivered after output stops. +Reads/polling do not emit it. No contents, replay, activity classification or veto. + +`PluginEvents.on("experimental_terminal.input", handler)` delivers `{terminal}` after +real nonempty user input is forwarded, including interactive terminal input. No input +contents, output or keepalives are exposed. The public terminal DTO identifies its host. + +Modal v1 bumps its own idle clock when the delivered thread is active, and on terminal +input to its machines. It does not fetch or classify thread events. Stabilization +requires coalescing tests, live streaming/terminal verification, current-status behavior, +and review of event delivery overhead. No daemon wire change is required. + +Follow-ups before the suspend callback cancel preparation after work stopping settles. +After callback invocation, core completes pause and resumes for queued work. Reconnection +alone must not release work during preservation. Cancellation is reported as a rejected +pause, not a successful save. + +## `bb.experimental_machines.getResource` + +Returns core’s current persisted host resource as JSON, or null when the host or +resource is absent. Reads are available across plugins, following the host access +model; callers parse provider-specific data. Resources must contain identifiers +and configuration, never credentials. This lets provider RPCs inspect existing +machines without duplicating lifecycle state in plugin KV. Stabilization requires +validating missing-resource semantics and use by additional machine providers. + +## Environment compositions + +`bb.experimental_environments.register({ id, displayName, machineProviderId, +environmentProviderId })` declares a new-machine environment option backed by +one concrete environment provider. It cannot also supply lifecycle callbacks +or inputs. The server resolves the references before creating the launch, +persists the concrete provider and explicit machine selection, and uses the +existing provisioning lifecycle. Machine registration alone adds no picker row. + +The environment-provider listing includes nullable `machineProviderId` and, for +compositions only, the target `environmentProviderId`. Clients keep the composition’s label and mount the target provider’s input +controls without changing the submitted composition ID. +Compositions appear outside host groups; explicit SDK/CLI creation omits machine +selection. Ordinary environment selections still require a machine. Core rejects +conflicting machine selectors, missing referenced providers and required missing +Git remotes before allocation. Modal combines its machine with project-checkout; +core retains checkout cloning, project-source registration and setup hooks. +A clone failure leaves the ready machine intact and records the thread error. + +Stabilization requires UI/CLI parity, registration validation, pre-allocation +refusals, provisioning timeline coverage, clone-failure machine retention and +later project-checkout/worktree reuse. There is no clone plugin or new lifecycle. + +For new-machine composition inputs, `experimental_useBranches` accepts a null +host and obtains branch suggestions from the project’s default local source. +Project-checkout treats that selection as a fresh clone: no source-checkout +conflict/dirty-state blockers apply, and an omitted branch uses the repository +default. Explicit branch inputs still pass to the concrete provider. + +## Experimental host SDK machine operations + +New host methods are experimental_create, experimental_getEnrollmentCommand, +experimental_listProviders, experimental_suspend, experimental_resume and +experimental_retryCleanup. There are no unprefixed aliases. Creation returns a +reserved host immediately when `wait: false`; otherwise it polls that host until +active. The enrollment-command method exists for the manual setup UI and CLI and +returns a credential only while that host is creating. + +Before stabilizing, verify creation cancellation through host removal, +same-host restoration, serialized removal, plugin callers and UI/CLI parity. diff --git a/docs/cli-guide-and-skill.md b/docs/cli-guide-and-skill.md index 73c99f5d02..04ab9dee1d 100644 --- a/docs/cli-guide-and-skill.md +++ b/docs/cli-guide-and-skill.md @@ -14,3 +14,20 @@ keeps cleanup pending until the daemon confirms hook termination. Hook identity and completion persist across server restarts. Attached checkout and personal-workspace paths skip both hooks. These semantics apply equally to CLI, SDK, and app launches; see [worktrees.md](worktrees.md). + +The Machines settings creation drawer prepares an existing-machine command when access is ready, otherwise shows setup guidance. After access is ready, Choose a machine provider reviews provider inputs and launches through `hosts.experimental_create`/`bb machine create`. A machine belongs to no project; projects reach it later through project sources. + +Machine maintenance state is part of `bb machine list --json`; there is no +separate machine lifecycle command. Keep the machine guide and bb-cli command +index aligned with this surface. + +Local installed-daemon start, stop, and uninstall operations are flags on +`install-machine.sh`, not `bb machine` subcommands. + +Modal connection and machine commands are documented in [modal-sandboxes](../plugins/environment-modal-sandbox/skills/modal-sandboxes/SKILL.md). `bb modal image show [--json]` reads the Dockerfile shown in settings; `bb modal image set --file PATH [--json]` saves a validated plugin-wide override and `bb modal image reset [--json]` restores the bundled default for future machines; `bb modal account inspect --json` checks credentials; `bb machine create --provider modal-sandbox --json` automatically prepares the bundled image and installs the daemon. `bb machine remove MACHINE --yes` explicitly removes compute and private snapshots. + +`bb thread spawn --machine-inputs ` configures either an explicit +`--new-machine` or the machine provider owned by a composed +`--environment-provider`; a composition rejects separate machine selectors. + +Modal image debugging uses `bb modal image build`, `bb modal sandbox run`, `bb modal sandbox exec ID [--json] -- COMMAND...`, and `bb modal sandbox stop ID`. Debug compute expires after 30 minutes and skips BB enrollment and project setup. See the plugin skill for output limits and typed RPC equivalents. diff --git a/docs/configuration.md b/docs/configuration.md index 53a32c8ea7..1cf3cf8623 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -552,8 +552,18 @@ compatibility roots follow the related config and environment switches. ## Multi-machine -Settings → Machines can enroll, -rename, and remove machines; project settings can add a path or clone source on +Settings → Machines offers Manual machine setup (the built-in `manual` provider) +alongside installed cloud, SSH and Tailscale providers. Manual machine setup prints +a private enrollment command and waits for the daemon. The one-line command +downloads `/install.sh` with a short-lived enrollment header. The server embeds +the pending bootstrap in its uncached response; cancelled, expired, or consumed +enrollments are rejected. `bb machine create +--provider manual` follows the same lifecycle; `--no-wait` returns the creating +host ID. Manual machines never suspend or retire automatically. Removal +revokes access; run the original installer with `--uninstall --host-id ` on +that machine to uninstall its daemon. The local host remains provider-less. + +Settings → Machines can also rename and remove machines; project settings can add a path or clone source on each machine; and thread creation can target any enrolled machine with a usable source. The CLI equivalents are `bb machine list`, `bb project create --machine ...`, `bb project source add --machine @@ -1151,10 +1161,9 @@ npx bb-app --server-port 48886 --host-daemon-port 48887 The Settings → Machines installer assigns every enrolled standalone host daemon a stable local API port so it can coexist with the desktop app and with daemons -enrolled to other servers. Atomic reservations under -`~/.bb-machines/host-daemon-ports/` cover both default and custom -`BB_DATA_DIR` locations. Its generated command accepts `--host-daemon-port -` when an explicit port is required. +enrolled to other servers. The selected port is persisted in the machine data +directory and reused by subsequent runs. Its generated command accepts +`--host-daemon-port ` when an explicit port is required. ## Source Development @@ -1246,3 +1255,122 @@ message without disabling sharing. Use `bb plugin config set true|false` or the SDK's `plugins.updateSettings({ pluginId, values })`. These settings apply when agent configuration is next assembled, not retroactively to existing text. + +### Machine server access + +Machines settings expose **Server URL reachable by machines** (`machineServerUrl`) +and **Default machine access** (`defaultMachineAccess`). The URL must be HTTP +or HTTPS without embedded credentials. An unset URL falls back to +`BB_EXTERNAL_URL`. The URL input appears when Manual is selected. An unset access provider selects +bb connect, even when unpaired; Machines settings links to its setup. Choose +Manual (`direct`) explicitly to use your own URL. An explicit provider must +be installed and available. Configure these with `bb settings general +machineServerUrl ` and `bb settings general defaultMachineAccess +`. `bb settings show --json` reports the effective access +selection. Access grants serve ongoing runtime requests as well as enrolment. + +Bootstrap v2 carries optional provider headers. The machine persists them as +`serverHeaders` in its private `config.json`; the launcher supplies them to the +daemon through `BB_SERVER_HEADERS` as a JSON string map. These headers are private +credentials and cover enrollment, HTTP, WebSocket, and runtime proxy requests. +Direct grants omit headers. Legacy `machineCredential` configuration is translated +into the corresponding request header when loading an existing machine. + +For machine enrollment, `BB_DATA_DIR` selects isolated machine state instead of +`~/.bb-machines/`. `bb machine enroll` refuses the default `~/.bb` +directory and a conflicting host or server identity. Local `bb machine +start|stop|uninstall --host-id ` treats `BB_DATA_DIR` (or `--data-dir`) as an +ownership assertion, not permission to act on arbitrary files: lifecycle commands +require a canonical installer-owned directory under `~/.bb-machines` and verify +identity and service/process ownership. Optional `--server-url` asserts the server. +Without an explicit directory, lifecycle commands locate the unique matching host. + +### Machine environment + +Core resolves machine contributions through +`apps/server/src/services/hosts/host-environment.ts` before dispatching setup and +teardown hooks. The `environment.hook.run` command carries `contributedEnv`; +the daemon applies them to the hook child process. Hook progress and errors are +forwarded as-is, so contributed values printed by the child remain visible. +Machine selection and precedence stay in the server +resolver, which returns no contributions for the local host. + +Settings → Machines → Machine environment defines variables for all enrolled +machine hosts. Local hosts do not receive them. All values are encrypted in the +database using AES-256-GCM and are never returned by settings reads. The server +keeps the encryption key in its data directory's `machine-environment-key` file +(mode 0600); include this file with database backups. Names and notes are public +metadata. Existing plaintext settings and private secret files migrate on first +access; each old secret file is removed only after its encrypted record is saved. +Historical backups may still contain values stored before migration. + +Core resolves the environment for each agent turn, project-source clone, host +setup call, and new BB terminal. User variables override built-ins; agent-provider +contributions override host variables for agent turns. Existing terminals keep +the environment they started with: open a new terminal after a change. Agent +turns receive refreshed values on their next turn and after resume. Codex rebuilds +its loaded session from the existing conversation when the environment changes. + +Machine environment commands require host-daemon protocol 192, covering machine +lifecycle and contribution fields for core hooks, host plugins, and terminals. + +The built-in GitHub row uses `gh auth token --hostname github.com` and `gh api +--hostname github.com user` on the server host. It supplies `GH_TOKEN`, Git's +`GIT_CONFIG_*` environment entries for an HTTPS credential helper and SSH URL +rewrites for github.com, and author/committer identity. The helper expands +`GH_TOKEN` when Git calls it; no helper file, global Git config, or credential +store is installed. Private email uses `+@users.noreply.github.com`. +A user `GH_TOKEN` overrides the built-in token, and the row shows overridden. +Tokens obtained from gh are never persisted by the server. Image construction +and Modal filesystem snapshot settings do not receive these contributions. + +Use `bb machine env list --json`, `bb machine env set NAME [--note +text] --json`, and `bb machine env unset NAME --json`. Set reads its value from +stdin, removes one trailing newline, and never accepts a value in argv. For +example, `printf '%s' staging | bb machine env set DEPLOY_REGION`. Pipe secrets +from a secure source instead of putting them in shell history. + +SDK parity: `sdk.system.machineEnvironment()` and +`sdk.system.replaceMachineEnvironment({ variables })`. Replacement is atomic; +pass every row to retain, using `value: null` for an unchanged saved secret. All +list rows have `value: null` and `secret: true`. +`bb machine env list` reports the built-in readiness as `builtInGit`. + +Automatic machine GitHub credentials are enabled by default. Use +`bb settings general machineGitCredentialsEnabled false` to stop forwarding the +server gh credentials to machines; `true` enables them again. In Machines → +Advanced settings, the automatic GH_TOKEN switch controls the same setting. +This does not log the server out or suppress an explicit custom GH_TOKEN. +Changes apply to new turns, setup commands and terminals. + +## Modal machines + +The optional Modal sandbox plugin builds/reuses named tools images for new +machines. Its plugin page keeps the bundled Standard Dockerfile as the first +image and can add named Dockerfiles, existing Modal image IDs, and named CPU/memory +presets. CLI: `bb modal image show`, `bb modal image set --file PATH`, and +`bb modal image reset` (append `--json`). Typed plugin RPCs `image.definition`, +`image.set({dockerfile})`, and `image.reset` expose the same persistent definition. +Supported instructions are one FROM followed by RUN, ENV, WORKDIR, and USER; no +build context or multi-stage builds. Saving does not build or modify existing +machines. The next new machine uses the saved definition. BB installs the daemon +on demand, then clones the project and runs its setup hook. + +Configure `tokenId` and `tokenSecret` in secret plugin settings; `appName` defaults +to `bb-sandboxes`. With no size preset, Modal's CPU and memory defaults apply. +`idleMinutes` defaults to 15 (0 disables idle suspension), and `timeoutMinutes` +defaults to 1440 with an allowed range of 1–1440. Existing machines use current idle +policy; running compute keeps its vendor deadline and restored compute uses the +current lifetime. Resource reservations stay pinned across restore. + +There is no automatic retention removal. Use `bb machine remove MACHINE --yes` +for explicit cleanup. Manual and idle pauses save a filesystem snapshot before +terminating compute. There is no pre-expiry scheduler: a sandbox that stays active +until its configured timeout can lose changes since its last successful pause. +Provider details expose expiry and saved-image status; missing compute never +silently restores stale state. Open terminals prevent idle suspension. + +`bb modal account inspect --json` tests credentials without allocating resources. +Create with `bb machine create --provider modal-sandbox --project PROJECT --json`. +See [modal-sandboxes](../plugins/environment-modal-sandbox/skills/modal-sandboxes/SKILL.md) +for prerequisites and lifecycle commands. diff --git a/docs/multiple-devices.md b/docs/multiple-devices.md index 8887e8f4f7..dda2ce1f4c 100644 --- a/docs/multiple-devices.md +++ b/docs/multiple-devices.md @@ -189,10 +189,8 @@ prefix, so enrollment needs neither `sudo` nor a PATH change. Each joined server gets its own daemon instance, data directory (`~/.bb-machines/`, override with `BB_DATA_DIR` when running the installer), local API port, and launchd/systemd service. The installer persists -the selected port in that data directory and atomically reserves it under -`~/.bb-machines/host-daemon-ports/`, including when `BB_DATA_DIR` points -elsewhere. Subsequent runs reuse the reservation; pass `--host-daemon-port -` to the installer to override the selection. One machine can therefore +the selected port in that data directory. Subsequent runs reuse it; pass +`--host-daemon-port ` to the installer to override the selection. One machine can therefore serve several bb servers at once, and joining never touches a full local bb install's `~/.bb`. Each instance keeps its own `bb-app` under that data directory and self-updates against its own server, so servers running different diff --git a/docs/worktrees.md b/docs/worktrees.md index 0ae667f8fa..77b9da1db6 100644 --- a/docs/worktrees.md +++ b/docs/worktrees.md @@ -187,3 +187,21 @@ A few quick checks: outside bb before debugging through the provisioning transcript. 5. Run `bash .bb-env-teardown.sh` manually before you delete a test worktree. Confirm that repeated runs do not fail or remove shared resources. + +## Fresh project clones on machines + +Core applies the same setup and teardown policy when a project checkout is freshly +cloned onto a new machine and the environment provider reports that it owns the +checkout. `.bb-env-setup.sh` must succeed before the environment is ready. +`.bb-env-teardown.sh` runs before removal with its own 15-minute timeout; a failure +is reported but does not prevent removal. A user-maintained checkout attached to +BB remains unowned and runs neither hook. + +Fresh machine clones do not apply `.worktreeinclude`: no local source checkout +exists on the new host. Supply local files and secrets through core Machine +environment settings. Keep the repo hook's cache/no-op logic in the repository; +Modal's stored Dockerfile recipe contains image-build instructions only. + +Restoring a machine filesystem does not rerun `.bb-env-setup.sh`. Setup runs when core first creates an owned environment. Fresh machine clones do not apply `.worktreeinclude`; supply local files and secrets through Machine environment settings. + +Thread startup does not validate workspace fingerprints, probe agent authentication, or automatically install agent CLIs. diff --git a/packages/agent-runtime/src/integration.env-isolation.test.ts b/packages/agent-runtime/src/integration.env-isolation.test.ts index 699b81bbef..5311832eeb 100644 --- a/packages/agent-runtime/src/integration.env-isolation.test.ts +++ b/packages/agent-runtime/src/integration.env-isolation.test.ts @@ -185,3 +185,60 @@ for (const providerId of providers) { }, 95_000); }); } + +it("codex provider applies rotated and removed contributions on the next turn", async () => { + const ctx = createTestRuntime("codex", { + onInteractiveRequest: createApprovalResolution, + }); + const threadId = newThreadId(); + const contribution = (value: string) => [ + { + name: "BB_MACHINE_ROTATION_TEST", + value, + reason: "Verify next-turn rotation", + source: { core: "machine-git" as const }, + }, + ]; + try { + const options = await resolveRuntimeOptions({ + ctx, + providerId: "codex", + preset: "full", + }); + await ctx.runtime.startThread({ + environmentId: `env-rotation-${randomUUID()}`, + threadId, + projectId: `project-rotation-${randomUUID()}`, + providerId: "codex", + options, + contributedEnv: contribution("original"), + }); + for (const [index, value] of ["original", "rotated", ""].entries()) { + const fileName = `rotation-${randomUUID()}.txt`; + await ctx.runtime.runTurn({ + threadId, + clientRequestId: `creq_23456789a${index + 2}`, + options, + contributedEnv: value === "" ? [] : contribution(value), + input: [ + promptTextInput({ + text: createCapturePrompt( + `if [ "\${BB_MACHINE_ROTATION_TEST-}" = '${value}' ]; then printf PASS; else printf FAIL; fi > ${fileName}`, + ), + }), + ], + }); + await waitForRuntimeCondition({ + ctx, + label: `rotation turn ${index}`, + predicate: () => + turnCompletedCountForThread(ctx.events, threadId) > index, + timeoutMs: 90_000, + }); + expect(readFileSync(join(ctx.tmpDir, fileName), "utf8")).toBe("PASS"); + } + } finally { + await ctx.runtime.shutdown(); + cleanup(ctx); + } +}, 180_000); diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index afeac5b6a8..760fc1f2c9 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -244,14 +244,12 @@ describe("createAgentRuntime lifecycle", () => { value: "/plugin/bin", source: { plugin: "env-test" }, reason: "Use the plugin toolchain", - secret: false, }, { name: "AUTH_PROXY_URL", value: { serverPath: "/plugins/env-test/auth" }, source: { plugin: "env-test" }, reason: "Use the authenticated server proxy", - secret: true, }, ] as const; const runtime = createScriptedEchoRuntime({ @@ -312,12 +310,12 @@ describe("createAgentRuntime lifecycle", () => { { name: "AUTH_PROXY_URL", source: { plugin: "env-test" }, - value: { masked: true }, + value: "http://127.0.0.1:3334/plugins/env-test/auth", reason: "Use the authenticated server proxy", }, ]), }); - expect(JSON.stringify(events)).not.toContain("/plugins/env-test/auth"); + expect(JSON.stringify(events)).toContain("/plugins/env-test/auth"); await runtime.runTurn({ clientRequestId: "creq_222222224c", @@ -333,6 +331,82 @@ describe("createAgentRuntime lifecycle", () => { await runtime.shutdown(); }); + it("reinjects rotated machine credentials on the next turn and resume", async () => { + const record = createScriptedEchoRequestRecord(); + const events: ThreadEvent[] = []; + const runtime = createScriptedEchoRuntime({ + runtime: { + workspacePath: tmpDir, + env: record.env, + onEvent: (event) => events.push(event), + }, + }); + const credentials = (value: string) => [ + { + name: "GH_TOKEN", + value, + source: { core: "machine-git" as const }, + reason: "Server gh login", + }, + ]; + try { + await runtime.startThread({ + environmentId: "env-1", + projectId: "p1", + threadId: "git-thread", + providerId: "fake", + contributedEnv: credentials("first-git-token"), + options: fullRuntimeOptions, + }); + await runtime.runTurn({ + clientRequestId: "creq_222222224c", + threadId: "git-thread", + input: [promptTextInput({ text: "rotated-git-token" })], + contributedEnv: credentials("rotated-git-token"), + options: fullRuntimeOptions, + }); + expect(record.last("turn/start")?.params).toMatchObject({ + options: { envVars: { GH_TOKEN: "rotated-git-token" } }, + }); + await waitForThreadAgentMessageText({ + events, + providerId: "fake", + runtime, + text: "rotated-git-token", + threadId: "git-thread", + }); + await runtime.resumeThread({ + environmentId: "env-1", + threadId: "git-resumed", + providerId: "fake", + providerThreadId: "old-git-thread", + contributedEnv: credentials("resumed-git-token"), + options: fullRuntimeOptions, + }); + expect(record.last("thread/resume")?.params).toMatchObject({ + options: { envVars: { GH_TOKEN: "resumed-git-token" } }, + }); + expect(JSON.stringify(events)).toContain("first-git-token"); + expect(JSON.stringify(events)).toContain("resumed-git-token"); + expect( + events.filter((event) => event.type === "provider.env-resolved"), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entries: expect.arrayContaining([ + expect.objectContaining({ + name: "GH_TOKEN", + value: "rotated-git-token", + }), + ]), + }), + ]), + ); + } finally { + await runtime.shutdown(); + } + }); + it("drops unresolved server paths without preventing thread start", async () => { const record = createScriptedEchoRequestRecord(); const events: ThreadEvent[] = []; @@ -356,7 +430,6 @@ describe("createAgentRuntime lifecycle", () => { value: { serverPath: "/plugins/env-test/auth" }, source: { plugin: "env-test" }, reason: "Use the authenticated server proxy", - secret: true, }, ], options: fullRuntimeOptions, diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts index cc6f8fbc70..43a00381d3 100644 --- a/packages/agent-runtime/src/test/runtime-test-harness.ts +++ b/packages/agent-runtime/src/test/runtime-test-harness.ts @@ -44,6 +44,7 @@ export const scriptedEchoBridgeModulePath = join( export interface ScriptedEchoLaunchScript { startDelayMs?: number; + turnStartResponseDelayMs?: number; answerStartWithoutIdentity?: boolean; identityAfterResponse?: boolean; identityNotificationsBeforeTurn?: { @@ -75,6 +76,8 @@ export interface ScriptedEchoLaunchScript { recoveryThreadIdHint?: string; approvalEnforcedBy?: "runtime" | "provider"; identifyProcess?: boolean; + textDeltaChunkSize?: number; + stderrChunksOnTurn?: string[]; failStopForThreadIds?: string[]; emitIdentityOnSigterm?: boolean; } diff --git a/packages/agent-runtime/src/thread-shell-environment.ts b/packages/agent-runtime/src/thread-shell-environment.ts index c6aedd5b41..7e4ff9ec10 100644 --- a/packages/agent-runtime/src/thread-shell-environment.ts +++ b/packages/agent-runtime/src/thread-shell-environment.ts @@ -30,7 +30,10 @@ export function buildThreadShellEnvironment( export interface ResolvedThreadEnvironmentEntry { name: string; - source: "shell" | { plugin: string }; + source: + | "shell" + | { plugin: string } + | { core: "machine-git" | "machine-environment" }; value: string | { masked: true }; reason?: string; } @@ -70,7 +73,10 @@ export function resolveThreadEnvironment(args: ResolveThreadEnvironmentArgs): { }); droppedContributions.push({ name: contribution.name, - plugin: contribution.source.plugin, + plugin: + "plugin" in contribution.source + ? contribution.source.plugin + : contribution.source.core, }); continue; } @@ -84,7 +90,7 @@ export function resolveThreadEnvironment(args: ResolveThreadEnvironmentArgs): { entries.push({ name: contribution.name, source: contribution.source, - value: contribution.secret ? { masked: true } : value, + value, reason: contribution.reason, }); } diff --git a/packages/agent-runtime/src/types.ts b/packages/agent-runtime/src/types.ts index 8f56d26ebd..136b4d94c8 100644 --- a/packages/agent-runtime/src/types.ts +++ b/packages/agent-runtime/src/types.ts @@ -28,9 +28,8 @@ export type AgentRuntimeShellEnvironment = Record; export interface AgentRuntimeContributedEnvEntry { name: string; value: string | { serverPath: string }; - source: { plugin: string }; + source: { plugin: string } | { core: "machine-git" | "machine-environment" }; reason: string; - secret: boolean; } export type AgentRuntimeExecutionOptions = RuntimeThreadExecutionOptions; diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index e21484a147..8af49943f9 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -296,7 +296,6 @@ interface LauncherCliOptions { help: boolean; hostDaemonPort?: string; hostId?: string; - hostType?: string; joinCode?: string; json?: boolean; serverBindHost?: string; @@ -714,7 +713,6 @@ export function parseLauncherArgs(args: string[]): ParsedLauncherArgs { "enroll-key": { type: "string" }, "host-daemon-port": { type: "string" }, "host-id": { type: "string" }, - "host-type": { type: "string" }, "join-code": { type: "string" }, "server-bind-host": { type: "string" }, "server-port": { type: "string" }, @@ -735,7 +733,6 @@ export function parseLauncherArgs(args: string[]): ParsedLauncherArgs { const enrollKey = readStringOption(parsed.values["enroll-key"]); const hostDaemonPort = readStringOption(parsed.values["host-daemon-port"]); const hostId = readStringOption(parsed.values["host-id"]); - const hostType = readStringOption(parsed.values["host-type"]); const joinCode = readStringOption(parsed.values["join-code"]); const serverBindHost = readStringOption(parsed.values["server-bind-host"]); const serverPort = readStringOption(parsed.values["server-port"]); @@ -755,9 +752,6 @@ export function parseLauncherArgs(args: string[]): ParsedLauncherArgs { if (hostId !== undefined) { options.hostId = hostId; } - if (hostType !== undefined) { - options.hostType = hostType; - } if (joinCode !== undefined) { options.joinCode = joinCode; } @@ -876,9 +870,6 @@ function createEnvFromOptions( if (args.options.hostId !== undefined) { env.BB_HOST_ID = args.options.hostId; } - if (args.options.hostType !== undefined) { - env.BB_HOST_TYPE = args.options.hostType; - } if (args.options.joinCode !== undefined) { env.BB_HOST_ENROLL_KEY = args.options.joinCode; } @@ -909,14 +900,16 @@ function applyManagedConfigEnv( ): NodeJS.ProcessEnv { return { ...args.env, - ...(args.config.machineCredential !== undefined + ...(args.config.serverHeaders !== undefined || + args.config.machineCredential !== undefined ? { - BB_CONNECT_MACHINE_CREDENTIAL: args.config.machineCredential, + BB_SERVER_HEADERS: JSON.stringify( + args.config.serverHeaders ?? { + "x-bb-connect-machine": args.config.machineCredential, + }, + ), } : {}), - ...(args.config.connectMachineId !== undefined - ? { BB_CONNECT_MACHINE_ID: args.config.connectMachineId } - : {}), ...args.config.config, ...args.envFile.env, }; @@ -1087,6 +1080,12 @@ function mergeManagedConfig( if (patchConfig.serverUrl !== undefined) { nextConfig.serverUrl = patchConfig.serverUrl; } + if (patchConfig.serverHeaders !== undefined) { + nextConfig.serverHeaders = patchConfig.serverHeaders; + } + if (patchConfig.sharedSkillRoots !== undefined) { + nextConfig.sharedSkillRoots = patchConfig.sharedSkillRoots; + } if (patchConfig.machineCredential !== undefined) { nextConfig.machineCredential = patchConfig.machineCredential; } @@ -1114,28 +1113,12 @@ function mergeManagedConfig( function pruneManagedConfig( config: ManagedConfigForWrite, ): ManagedConfigForWrite { - const nextConfig: ManagedConfigForWrite = {}; - if (config.serverUrl !== undefined) { - nextConfig.serverUrl = config.serverUrl; - } - if (config.machineCredential !== undefined) { - nextConfig.machineCredential = config.machineCredential; - } - if (config.connectMachineId !== undefined) { - nextConfig.connectMachineId = config.connectMachineId; - } - if (config.config !== undefined && Object.keys(config.config).length > 0) { - nextConfig.config = config.config; - } - if (config.customModels !== undefined && config.customModels.length > 0) { - nextConfig.customModels = config.customModels; - } - if ( - config.customAcpAgents !== undefined && - config.customAcpAgents.length > 0 - ) { - nextConfig.customAcpAgents = config.customAcpAgents; - } + const nextConfig: ManagedConfigForWrite = { ...config }; + if (nextConfig.config && Object.keys(nextConfig.config).length === 0) + delete nextConfig.config; + if (nextConfig.customModels?.length === 0) delete nextConfig.customModels; + if (nextConfig.customAcpAgents?.length === 0) + delete nextConfig.customAcpAgents; return nextConfig; } @@ -2930,7 +2913,7 @@ export async function runBbHostDaemon( process.stdout.write(`bb-host-daemon Usage: - bb-host-daemon [--server-url ] [--host-daemon-port ] [--host-id ] [--host-type ] [--enroll-key ] [--auto-update] + bb-host-daemon [--server-url ] [--host-daemon-port ] [--host-id ] [--enroll-key ] [--auto-update] bb-host-daemon join --server-url [--host-daemon-port ] [--join-code --host-id ] [--auto-update] `); return; @@ -2976,7 +2959,7 @@ Usage: bb-app config refresh bb-app env set bb-app client ssh-target set [--host-id ] - bb-app host-daemon [--server-url ] [--host-daemon-port ] [--host-id ] [--host-type ] [--enroll-key ] [--auto-update] + bb-app host-daemon [--server-url ] [--host-daemon-port ] [--host-id ] [--enroll-key ] [--auto-update] bb-app host-daemon join --server-url [--host-daemon-port ] [--join-code --host-id ] [--auto-update] CLI: diff --git a/packages/bb-app/test/index.test.ts b/packages/bb-app/test/index.test.ts index 57dded4509..f3bc37b02e 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -746,8 +746,6 @@ describe("bb-app launcher", () => { "host_remote", "--host-daemon-port", "48887", - "--host-type", - "persistent", "--auto-update", ]), ).toEqual({ @@ -757,7 +755,6 @@ describe("bb-app launcher", () => { help: false, hostDaemonPort: "48887", hostId: "host_remote", - hostType: "persistent", joinCode: "bbde_supplied", json: false, serverUrl: "https://bb.example.test", @@ -2248,3 +2245,33 @@ describe("bb-app launcher", () => { expect(invalidSurfaceServerEnv.BB_APP_SURFACE).toBe("web"); }); }); + +it("preserves machine identity and access headers through real config set and unset", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "bb-app-config-machine-")); + const identity = { + serverUrl: "https://machine.example", + serverHeaders: { "x-bb-connect-machine": "private-machine-access" }, + machineCredential: "legacy-private", + connectMachineId: "cloud-device", + }; + try { + writeFileSync(join(dataDir, "config.json"), JSON.stringify(identity)); + await runBbApp([ + "--data-dir", + dataDir, + "config", + "set", + "BB_APP_URL", + "https://other.example", + ]); + expect( + JSON.parse(readFileSync(join(dataDir, "config.json"), "utf8")), + ).toMatchObject(identity); + await runBbApp(["--data-dir", dataDir, "config", "unset", "BB_APP_URL"]); + expect( + JSON.parse(readFileSync(join(dataDir, "config.json"), "utf8")), + ).toEqual(identity); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); diff --git a/packages/config/src/bb-app-managed-config.ts b/packages/config/src/bb-app-managed-config.ts index 8b7e62da36..c02688a2fb 100644 --- a/packages/config/src/bb-app-managed-config.ts +++ b/packages/config/src/bb-app-managed-config.ts @@ -154,6 +154,7 @@ export const bbAppManagedConfigSchema = z customAcpAgents: customAcpAgentsSchema.optional(), customModels: z.array(customProviderModelSchema).optional(), sharedSkillRoots: providerNativeSkillRootsSchema.optional(), + serverHeaders: z.record(z.string(), z.string()).optional(), machineCredential: z.string().min(1).optional(), connectMachineId: z.string().min(1).optional(), serverUrl: z.string().min(1).optional(), @@ -166,6 +167,7 @@ const bbAppManagedConfigBoundarySchema = z customAcpAgents: z.array(z.unknown()).optional(), customModels: z.array(z.unknown()).optional(), sharedSkillRoots: providerNativeSkillRootsSchema.optional(), + serverHeaders: z.record(z.string(), z.string()).optional(), machineCredential: z.string().min(1).optional(), connectMachineId: z.string().min(1).optional(), serverUrl: z.string().min(1).optional(), @@ -278,6 +280,9 @@ export function parseBbAppManagedConfig( if (parsed.serverUrl !== undefined) { config.serverUrl = parsed.serverUrl; } + if (parsed.serverHeaders !== undefined) { + config.serverHeaders = parsed.serverHeaders; + } if (parsed.machineCredential !== undefined) { config.machineCredential = parsed.machineCredential; } diff --git a/packages/config/src/env-vars.ts b/packages/config/src/env-vars.ts index 5c7385757c..2fd1c298ba 100644 --- a/packages/config/src/env-vars.ts +++ b/packages/config/src/env-vars.ts @@ -1,5 +1,6 @@ +import { z } from "zod"; import { delimiter } from "node:path"; -import { defaultFeatureFlags, hostTypeSchema, type HostType } from "@bb/domain"; +import { defaultFeatureFlags } from "@bb/domain"; import { DEFAULTS } from "./defaults.js"; import { defineEnvVar, type EnvVarParseArgs } from "./env.js"; import { @@ -139,20 +140,6 @@ function parseTranscriptionModelValue(args: EnvVarParseArgs): string { return validateTranscriptionModel(args.value); } -function parseHostTypeValue(args: EnvVarParseArgs): HostType | undefined { - const trimmedValue = args.value.trim(); - if (trimmedValue.length === 0) { - return undefined; - } - - const parsedHostType = hostTypeSchema.safeParse(trimmedValue); - if (!parsedHostType.success) { - throw new Error(`Invalid ${args.name} "${trimmedValue}"`); - } - - return parsedHostType.data; -} - export const BB_LOG_LEVEL_ENV = defineEnvVar({ description: "Log level: trace, debug, info, warn, error, fatal", name: "BB_LOG_LEVEL", @@ -313,6 +300,20 @@ export const BB_BRIDGE_DIR_ENV = defineEnvVar({ parse: parseOptionalTrimmedStringEnvValue, }); +export const BB_SERVER_HEADERS_ENV = defineEnvVar>({ + description: "Private JSON headers attached to machine server requests", + name: "BB_SERVER_HEADERS", + parse: ({ value }) => { + try { + return z.record(z.string(), z.string()).parse(JSON.parse(value)); + } catch { + throw new Error( + "BB_SERVER_HEADERS must be a JSON object with string values", + ); + } + }, +}); + export const BB_CONNECT_MACHINE_CREDENTIAL_ENV = defineEnvVar< string | undefined >({ @@ -356,12 +357,6 @@ export const BB_HOST_NAME_ENV = defineEnvVar({ parse: parseOptionalTrimmedStringEnvValue, }); -export const BB_HOST_TYPE_ENV = defineEnvVar({ - description: "Host type override for daemon bootstrap", - name: "BB_HOST_TYPE", - parse: parseHostTypeValue, -}); - export const DEFAULT_BB_APP_VERSION = DEFAULTS.appVersion; export const DEFAULT_BB_APP_SURFACE = DEFAULT_APP_SURFACE; export const DEFAULT_BB_APP_URL = ""; diff --git a/packages/config/src/host-daemon-entrypoint.ts b/packages/config/src/host-daemon-entrypoint.ts index cf28202d72..322e949a75 100644 --- a/packages/config/src/host-daemon-entrypoint.ts +++ b/packages/config/src/host-daemon-entrypoint.ts @@ -1,32 +1,28 @@ -import type { HostType } from "@bb/domain"; import { readOptionalEnvVar, resolveEnvLoader, type EnvLoaderArgs, } from "./env.js"; import { + BB_SERVER_HEADERS_ENV, BB_BRIDGE_DIR_ENV, BB_CLI_DIR_ENV, BB_CONNECT_MACHINE_CREDENTIAL_ENV, - BB_CONNECT_MACHINE_ID_ENV, BB_HOST_ENROLL_KEY_ENV, BB_HOST_DAEMON_AUTO_UPDATE_ENV, BB_HOST_ID_ENV, BB_HOST_NAME_ENV, - BB_HOST_TYPE_ENV, } from "./env-vars.js"; import { assignIfDefined } from "./objects.js"; export interface HostDaemonEntrypointConfig { BB_BRIDGE_DIR?: string; BB_CLI_DIR?: string; - BB_CONNECT_MACHINE_CREDENTIAL?: string; - BB_CONNECT_MACHINE_ID?: string; + BB_SERVER_HEADERS?: Record; BB_HOST_ENROLL_KEY?: string; BB_HOST_DAEMON_AUTO_UPDATE?: boolean; BB_HOST_ID?: string; BB_HOST_NAME?: string; - BB_HOST_TYPE?: HostType; } type LoadHostDaemonEntrypointConfigArgs = EnvLoaderArgs; @@ -61,11 +57,15 @@ export function loadHostDaemonEntrypointConfig( definition: BB_CONNECT_MACHINE_CREDENTIAL_ENV, env: loader.env, }); - const connectMachineId = readOptionalEnvVar({ - context: loader.context, - definition: BB_CONNECT_MACHINE_ID_ENV, - env: loader.env, - }); + const serverHeaders = + readOptionalEnvVar({ + context: loader.context, + definition: BB_SERVER_HEADERS_ENV, + env: loader.env, + }) ?? + (machineCredential === undefined + ? undefined + : { "x-bb-connect-machine": machineCredential }); const hostId = readOptionalEnvVar({ context: loader.context, definition: BB_HOST_ID_ENV, @@ -76,31 +76,21 @@ export function loadHostDaemonEntrypointConfig( definition: BB_HOST_NAME_ENV, env: loader.env, }); - const hostType = readOptionalEnvVar({ - context: loader.context, - definition: BB_HOST_TYPE_ENV, - env: loader.env, - }); assignIfDefined({ key: "BB_BRIDGE_DIR", target: config, value: bridgeDir, }); - assignIfDefined({ - key: "BB_CONNECT_MACHINE_ID", - target: config, - value: connectMachineId, - }); assignIfDefined({ key: "BB_CLI_DIR", target: config, value: cliDir, }); assignIfDefined({ - key: "BB_CONNECT_MACHINE_CREDENTIAL", + key: "BB_SERVER_HEADERS", target: config, - value: machineCredential, + value: serverHeaders, }); assignIfDefined({ key: "BB_HOST_DAEMON_AUTO_UPDATE", @@ -122,11 +112,5 @@ export function loadHostDaemonEntrypointConfig( target: config, value: hostName, }); - assignIfDefined({ - key: "BB_HOST_TYPE", - target: config, - value: hostType, - }); - return config; } diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts index 7cd1c18291..5b17842d0b 100644 --- a/packages/config/test/config.test.ts +++ b/packages/config/test/config.test.ts @@ -753,7 +753,6 @@ describe("consumer-specific config", () => { BB_HOST_DAEMON_AUTO_UPDATE: "true", BB_HOST_ID: " host-123 ", BB_HOST_NAME: " host-123 ", - BB_HOST_TYPE: "persistent", }, }); @@ -764,7 +763,6 @@ describe("consumer-specific config", () => { BB_HOST_DAEMON_AUTO_UPDATE: true, BB_HOST_ID: "host-123", BB_HOST_NAME: "host-123", - BB_HOST_TYPE: "persistent", }); }); @@ -775,22 +773,11 @@ describe("consumer-specific config", () => { BB_CLI_DIR: " ", BB_HOST_ENROLL_KEY: " ", BB_HOST_NAME: "", - BB_HOST_TYPE: "", }, }); expect(hostDaemonEntrypointConfig).toEqual({}); }); - - it("rejects invalid host-daemon entrypoint host types", () => { - expect(() => - loadHostDaemonEntrypointConfig({ - env: { - BB_HOST_TYPE: "ephemeral", - }, - }), - ).toThrow('Invalid BB_HOST_TYPE "ephemeral"'); - }); }); describe("provider model config", () => { diff --git a/packages/db/drizzle/0117_machine_providers.sql b/packages/db/drizzle/0117_machine_providers.sql new file mode 100644 index 0000000000..09a2358015 --- /dev/null +++ b/packages/db/drizzle/0117_machine_providers.sql @@ -0,0 +1,36 @@ +CREATE TABLE `environment_hook_operations` ( + `id` text PRIMARY KEY NOT NULL, + `operation_id` text NOT NULL, + `host_id` text NOT NULL, + `path` text NOT NULL, + `kind` text NOT NULL, + `started_at` integer NOT NULL, + `finished_at` integer, + `error` text +); +--> statement-breakpoint +ALTER TABLE `hosts` ADD `machine_provider_id` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `launch_key` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `machine_inputs` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `machine_attempt` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `hosts` ADD `pending_log` text DEFAULT '' NOT NULL;--> statement-breakpoint +ALTER TABLE `hosts` ADD `machine_operation_id` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `server_access_provider_id` text;--> statement-breakpoint +UPDATE `hosts` SET `server_access_provider_id` = 'connect' WHERE `connect_machine_id` IS NOT NULL;--> statement-breakpoint +ALTER TABLE `hosts` ADD `server_access_grant_id` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `resource` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `phase` text DEFAULT 'active' NOT NULL;--> statement-breakpoint +ALTER TABLE `hosts` ADD `suspended_at` integer;--> statement-breakpoint +ALTER TABLE `hosts` ADD `status_message` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `suspend_retry_at` integer;--> statement-breakpoint +ALTER TABLE `hosts` ADD `remove_retry_at` integer;--> statement-breakpoint +ALTER TABLE `hosts` ADD `teardown_attempt` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `hosts` ADD `teardown_status` text;--> statement-breakpoint +CREATE UNIQUE INDEX `hosts_live_launch_key_idx` ON `hosts` (`launch_key`) WHERE "hosts"."destroyed_at" is null;--> statement-breakpoint +ALTER TABLE `project_sources` ADD `owns_path` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `host_daemon_sessions` DROP COLUMN `host_type`; +--> statement-breakpoint +UPDATE hosts +SET machine_provider_id = 'manual', resource = json_object('version', 1, 'hostId', id) +WHERE machine_provider_id IS NULL + AND id NOT IN (SELECT id FROM temp.bb_migration_local_host); diff --git a/packages/db/drizzle/meta/0117_snapshot.json b/packages/db/drizzle/meta/0117_snapshot.json new file mode 100644 index 0000000000..861ca4d610 --- /dev/null +++ b/packages/db/drizzle/meta/0117_snapshot.json @@ -0,0 +1,4256 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "27c10ca3-a2ee-47a9-a994-add8034719a6", + "prevId": "f6d5efcf-3a42-4db6-9243-08e95d50ebed", + "tables": { + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "caffeinate": { + "name": "caffeinate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_keyboard_hints": { + "name": "show_keyboard_hints", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "steer_active_thread_on_enter": { + "name": "steer_active_thread_on_enter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "show_unhandled_provider_events": { + "name": "show_unhandled_provider_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_memory_enabled": { + "name": "codex_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "claude_code_memory_enabled": { + "name": "claude_code_memory_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "codex_subagents_disabled": { + "name": "codex_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_subagents_disabled": { + "name": "claude_code_subagents_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claude_code_workflows_disabled": { + "name": "claude_code_workflows_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "keybinding_overrides": { + "name": "keybinding_overrides", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_settings_values": { + "name": "app_settings_values", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_theme": { + "name": "app_theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favicon_color": { + "name": "favicon_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apikey_key_unique": { + "name": "apikey_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "referenceId" + ], + "isUnique": false + }, + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "configId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "referenceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_hook_operations": { + "name": "environment_hook_operations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_git_repo": { + "name": "is_git_repo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_worktree": { + "name": "is_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_id": { + "name": "environment_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_plugin_id": { + "name": "environment_provider_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_owns_path": { + "name": "provider_owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "environment_provider_selection": { + "name": "environment_provider_selection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_provider_instance_key": { + "name": "environment_provider_instance_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_at": { + "name": "retire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_message": { + "name": "teardown_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_thread_id": { + "name": "owner_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "claim_path": { + "name": "claim_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environments_project_host_path_idx": { + "name": "environments_project_host_path_idx", + "columns": [ + "project_id", + "host_id", + "path" + ], + "isUnique": true + }, + "environments_host_path_lookup_idx": { + "name": "environments_host_path_lookup_idx", + "columns": [ + "host_id", + "path" + ], + "isUnique": false + }, + "environments_owner_thread_idx": { + "name": "environments_owner_thread_idx", + "columns": [ + "owner_thread_id" + ], + "isUnique": true + }, + "environments_claim_idx": { + "name": "environments_claim_idx", + "columns": [ + "host_id", + "claim_path" + ], + "isUnique": false + }, + "environments_project_idx": { + "name": "environments_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "environments_status_idx": { + "name": "environments_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "environments_provider_instance_idx": { + "name": "environments_provider_instance_idx", + "columns": [ + "environment_provider_id", + "environment_provider_instance_key" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environments_project_id_projects_id_fk": { + "name": "environments_project_id_projects_id_fk", + "tableFrom": "environments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_host_id_hosts_id_fk": { + "name": "environments_host_id_hosts_id_fk", + "tableFrom": "environments", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_kind": { + "name": "scope_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "events_thread_sequence_idx": { + "name": "events_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "events_delegating_item_lookup_idx": { + "name": "events_delegating_item_lookup_idx", + "columns": [ + "thread_id", + "item_id", + "sequence", + "item_kind" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" IN ('toolCall', 'delegation')" + }, + "events_plan_steps_thread_sequence_idx": { + "name": "events_plan_steps_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "(\"events\".\"item_kind\" = 'planSteps' AND \"events\".\"type\" = 'item/completed') OR \"events\".\"type\" = 'turn/plan/updated'" + }, + "events_parent_tool_call_thread_parent_sequence_idx": { + "name": "events_parent_tool_call_thread_parent_sequence_idx", + "columns": [ + "thread_id", + "parent_tool_call_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"parent_tool_call_id\" IS NOT NULL" + }, + "events_thread_type_item_kind_sequence_idx": { + "name": "events_thread_type_item_kind_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_kind", + "sequence" + ], + "isUnique": false + }, + "events_background_task_thread_type_item_sequence_idx": { + "name": "events_background_task_thread_type_item_sequence_idx", + "columns": [ + "thread_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"item_kind\" = 'backgroundTask'" + }, + "events_thread_type_sequence_idx": { + "name": "events_thread_type_sequence_idx", + "columns": [ + "thread_id", + "type", + "sequence" + ], + "isUnique": false + }, + "events_thread_turn_type_item_sequence_idx": { + "name": "events_thread_turn_type_item_sequence_idx", + "columns": [ + "thread_id", + "turn_id", + "type", + "item_id", + "sequence" + ], + "isUnique": false + }, + "events_item_lifecycle_thread_item_sequence_idx": { + "name": "events_item_lifecycle_thread_item_sequence_idx", + "columns": [ + "thread_id", + "item_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('item/started', 'item/completed', 'item/backgroundTask/completed')" + }, + "events_environment_idx": { + "name": "events_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "events_completed_item_truncation_idx": { + "name": "events_completed_item_truncation_idx", + "columns": [ + "item_kind", + "created_at", + "id" + ], + "isUnique": false, + "where": "\"events\".\"type\" = 'item/completed'" + }, + "events_thread_state_thread_sequence_idx": { + "name": "events_thread_state_thread_sequence_idx", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": false, + "where": "\"events\".\"type\" IN ('thread/goal/updated', 'thread/goal/cleared', 'thread/extensionState/updated')" + } + }, + "foreignKeys": { + "events_thread_id_threads_id_fk": { + "name": "events_thread_id_threads_id_fk", + "tableFrom": "events", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "events_environment_id_environments_id_fk": { + "name": "events_environment_id_environments_id_fk", + "tableFrom": "events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "events_scope_shape_check": { + "name": "events_scope_shape_check", + "value": "(\n (\"events\".\"scope_kind\" = 'turn' AND \"events\".\"turn_id\" IS NOT NULL)\n OR\n (\"events\".\"scope_kind\" = 'thread' AND \"events\".\"turn_id\" IS NULL)\n )" + } + } + }, + "host_daemon_sessions": { + "name": "host_daemon_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_name": { + "name": "host_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_dir": { + "name": "data_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "heartbeat_interval_ms": { + "name": "heartbeat_interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_timeout_ms": { + "name": "lease_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_daemon_sessions_host_status_idx": { + "name": "host_daemon_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "host_daemon_sessions_host_latest_idx": { + "name": "host_daemon_sessions_host_latest_idx", + "columns": [ + "host_id", + "updated_at", + "created_at", + "id" + ], + "isUnique": false + }, + "host_daemon_sessions_closed_prune_idx": { + "name": "host_daemon_sessions_closed_prune_idx", + "columns": [ + "status", + "closed_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_daemon_sessions_host_id_hosts_id_fk": { + "name": "host_daemon_sessions_host_id_hosts_id_fk", + "tableFrom": "host_daemon_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hosts": { + "name": "hosts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connect_machine_id": { + "name": "connect_machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_provider_id": { + "name": "machine_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "launch_key": { + "name": "launch_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_inputs": { + "name": "machine_inputs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_attempt": { + "name": "machine_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "machine_operation_id": { + "name": "machine_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_provider_id": { + "name": "server_access_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_access_grant_id": { + "name": "server_access_grant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_message": { + "name": "status_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspend_retry_at": { + "name": "suspend_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remove_retry_at": { + "name": "remove_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "teardown_attempt": { + "name": "teardown_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "teardown_status": { + "name": "teardown_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_permission_mode": { + "name": "max_permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'full'" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_rejected_protocol_version": { + "name": "last_rejected_protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hosts_last_seen_idx": { + "name": "hosts_last_seen_idx", + "columns": [ + "last_seen_at" + ], + "isUnique": false + }, + "hosts_live_launch_key_idx": { + "name": "hosts_live_launch_key_idx", + "columns": [ + "launch_key" + ], + "isUnique": true, + "where": "\"hosts\".\"destroyed_at\" is null" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "catalog_entry_id": { + "name": "catalog_entry_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_marketplace_name": { + "name": "catalog_marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'path'" + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_builtin_name": { + "name": "source_builtin_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_package": { + "name": "source_npm_package", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_registry": { + "name": "source_npm_registry", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_requested_spec": { + "name": "source_npm_requested_spec", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_npm_spec_kind": { + "name": "source_npm_spec_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_url": { + "name": "source_git_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_subdirectory": { + "name": "source_git_subdirectory", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_requested_ref": { + "name": "source_git_requested_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_ref_kind": { + "name": "source_git_ref_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_range": { + "name": "source_git_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_tag_prefix": { + "name": "source_git_tag_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_resolved_tag": { + "name": "source_git_resolved_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm_integrity": { + "name": "npm_integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_check_at": { + "name": "last_update_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "available_compatible_version": { + "name": "available_compatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "newest_incompatible_version": { + "name": "newest_incompatible_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "update_status_detail": { + "name": "update_status_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_version": { + "name": "last_failure_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failure_detail": { + "name": "last_failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_artifact_id": { + "name": "active_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "normalization_version": { + "name": "normalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "root_dir": { + "name": "root_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_active_artifact_id_plugin_artifacts_id_fk": { + "name": "plugins_active_artifact_id_plugin_artifacts_id_fk", + "tableFrom": "plugins", + "tableTo": "plugin_artifacts", + "columnsFrom": [ + "active_artifact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_scan_cursors": { + "name": "maintenance_scan_cursors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_kind": { + "name": "item_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_created_at": { + "name": "last_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_event_id": { + "name": "last_event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "maintenance_scan_cursors_path_idx": { + "name": "maintenance_scan_cursors_path_idx", + "columns": [ + "policy", + "version", + "item_kind", + "output_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_interactions": { + "name": "pending_interactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provider'" + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_thread_id": { + "name": "provider_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "renderer_id": { + "name": "renderer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pending_interactions_provider_request_idx": { + "name": "pending_interactions_provider_request_idx", + "columns": [ + "provider_id", + "provider_thread_id", + "provider_request_id" + ], + "isUnique": true + }, + "pending_interactions_thread_created_idx": { + "name": "pending_interactions_thread_created_idx", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_thread_status_created_idx": { + "name": "pending_interactions_thread_status_created_idx", + "columns": [ + "thread_id", + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_status_created_idx": { + "name": "pending_interactions_status_created_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "pending_interactions_plugin_status_created_idx": { + "name": "pending_interactions_plugin_status_created_idx", + "columns": [ + "plugin_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "pending_interactions_thread_id_threads_id_fk": { + "name": "pending_interactions_thread_id_threads_id_fk", + "tableFrom": "pending_interactions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_artifacts": { + "name": "plugin_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "npm_resolved_version": { + "name": "npm_resolved_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_resolved_commit": { + "name": "git_resolved_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_checkout_root": { + "name": "git_checkout_root", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integrity": { + "name": "integrity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "validation_result": { + "name": "validation_result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "validated_at": { + "name": "validated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "plugin_artifacts_plugin_idx": { + "name": "plugin_artifacts_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_kv": { + "name": "plugin_kv", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_kv_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_kv_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplace_icons": { + "name": "plugin_marketplace_icons", + "columns": { + "marketplace_name": { + "name": "marketplace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_marketplace_icons_marketplace_name_entry_id_pk": { + "columns": [ + "marketplace_name", + "entry_id" + ], + "name": "plugin_marketplace_icons_marketplace_name_entry_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_marketplaces": { + "name": "plugin_marketplaces", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https'" + }, + "manifest_url": { + "name": "manifest_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_git_ref": { + "name": "source_git_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_git_commit": { + "name": "source_git_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manifest_json": { + "name": "manifest_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stats_json": { + "name": "stats_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_successful_refresh_at": { + "name": "last_successful_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_attempted_refresh_at": { + "name": "last_attempted_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_schedules": { + "name": "plugin_schedules", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_schedules_plugin_id_name_pk": { + "columns": [ + "plugin_id", + "name" + ], + "name": "plugin_schedules_plugin_id_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_settings": { + "name": "plugin_settings", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "plugin_settings_plugin_id_key_pk": { + "columns": [ + "plugin_id", + "key" + ], + "name": "plugin_settings_plugin_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_state_snapshots": { + "name": "plugin_state_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_artifact_id": { + "name": "from_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_artifact_id": { + "name": "to_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_path": { + "name": "snapshot_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "database_path": { + "name": "database_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state_path": { + "name": "state_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secrets_path": { + "name": "secrets_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_path": { + "name": "registration_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollback_candidate_version": { + "name": "rollback_candidate_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_source_fingerprint": { + "name": "rollback_source_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_bb_version": { + "name": "rollback_bb_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_sdk_version": { + "name": "rollback_sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rollback_detail": { + "name": "rollback_detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_until": { + "name": "retained_until", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_state_snapshots_plugin_idx": { + "name": "plugin_state_snapshots_plugin_idx", + "columns": [ + "plugin_id" + ], + "isUnique": false + }, + "plugin_state_snapshots_retention_idx": { + "name": "plugin_state_snapshots_retention_idx", + "columns": [ + "retained_until" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_execution_defaults": { + "name": "project_execution_defaults", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_execution_defaults_project_idx": { + "name": "project_execution_defaults_project_idx", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_execution_defaults_project_id_projects_id_fk": { + "name": "project_execution_defaults_project_id_projects_id_fk", + "tableFrom": "project_execution_defaults", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_sources": { + "name": "project_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owns_path": { + "name": "owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_sources_project_idx": { + "name": "project_sources_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "project_sources_host_idx": { + "name": "project_sources_host_idx", + "columns": [ + "host_id" + ], + "isUnique": false + }, + "project_sources_project_host_idx": { + "name": "project_sources_project_host_idx", + "columns": [ + "project_id", + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_sources_project_id_projects_id_fk": { + "name": "project_sources_project_id_projects_id_fk", + "tableFrom": "project_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_sources_host_id_hosts_id_fk": { + "name": "project_sources_host_id_hosts_id_fk", + "tableFrom": "project_sources", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_sources_shape_check": { + "name": "project_sources_shape_check", + "value": "(\n \"project_sources\".\"type\" = 'local_path' AND \"project_sources\".\"host_id\" IS NOT NULL AND \"project_sources\".\"path\" IS NOT NULL\n )" + } + } + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'standard'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'V'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_updated_idx": { + "name": "projects_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "projects_deleted_idx": { + "name": "projects_deleted_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "projects_sort_idx": { + "name": "projects_sort_idx", + "columns": [ + "sort_key", + "id" + ], + "isUnique": false + }, + "projects_personal_singleton_idx": { + "name": "projects_personal_singleton_idx", + "columns": [ + "kind" + ], + "isUnique": true, + "where": "\"projects\".\"kind\" = 'personal'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_history_entries": { + "name": "prompt_history_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "prompt_history_entries_thread_request_idx": { + "name": "prompt_history_entries_thread_request_idx", + "columns": [ + "thread_id", + "request_sequence" + ], + "isUnique": true + }, + "prompt_history_entries_project_scope_created_idx": { + "name": "prompt_history_entries_project_scope_created_idx", + "columns": [ + "project_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + }, + "prompt_history_entries_thread_scope_created_idx": { + "name": "prompt_history_entries_thread_scope_created_idx", + "columns": [ + "thread_id", + "scope", + "created_at", + "request_sequence", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "prompt_history_entries_project_id_projects_id_fk": { + "name": "prompt_history_entries_project_id_projects_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "prompt_history_entries_thread_id_threads_id_fk": { + "name": "prompt_history_entries_thread_id_threads_id_fk", + "tableFrom": "prompt_history_entries", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queued_thread_messages": { + "name": "queued_thread_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "system_notice": { + "name": "system_notice", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_thread_id": { + "name": "sender_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_tier": { + "name": "service_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_with_next": { + "name": "group_with_next", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_on": { + "name": "waiting_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "wait_holder": { + "name": "wait_holder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inline'" + }, + "retry_of_turn_request_id": { + "name": "retry_of_turn_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_attempt": { + "name": "retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_key": { + "name": "sort_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queued_thread_messages_thread_created_idx": { + "name": "queued_thread_messages_thread_created_idx", + "columns": [ + "thread_id", + "created_at", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_thread_sort_idx": { + "name": "queued_thread_messages_thread_sort_idx", + "columns": [ + "thread_id", + "sort_key", + "id" + ], + "isUnique": false + }, + "queued_thread_messages_due_idx": { + "name": "queued_thread_messages_due_idx", + "columns": [ + "send_at", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"send_at\" IS NOT NULL AND \"queued_thread_messages\".\"claimed_at\" IS NULL AND \"queued_thread_messages\".\"claim_token\" IS NULL" + }, + "queued_thread_messages_wait_holder_idx": { + "name": "queued_thread_messages_wait_holder_idx", + "columns": [ + "wait_holder", + "id" + ], + "isUnique": false, + "where": "\"queued_thread_messages\".\"wait_holder\" IS NOT NULL" + } + }, + "foreignKeys": { + "queued_thread_messages_thread_id_threads_id_fk": { + "name": "queued_thread_messages_thread_id_threads_id_fk", + "tableFrom": "queued_thread_messages", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "retained_event_outputs": { + "name": "retained_event_outputs", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "output_path": { + "name": "output_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "retained_event_outputs_expiry_idx": { + "name": "retained_event_outputs_expiry_idx", + "columns": [ + "expires_at", + "event_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "retained_event_outputs_event_id_events_id_fk": { + "name": "retained_event_outputs_event_id_events_id_fk", + "tableFrom": "retained_event_outputs", + "tableTo": "events", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_experiments": { + "name": "system_experiments", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "daemon_session_id": { + "name": "daemon_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_cwd": { + "name": "initial_cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cols": { + "name": "cols", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows": { + "name": "rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_user_input_at": { + "name": "last_user_input_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_thread_status_updated_idx": { + "name": "terminal_sessions_thread_status_updated_idx", + "columns": [ + "thread_id", + "status", + "updated_at" + ], + "isUnique": false + }, + "terminal_sessions_environment_status_idx": { + "name": "terminal_sessions_environment_status_idx", + "columns": [ + "environment_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_host_status_idx": { + "name": "terminal_sessions_host_status_idx", + "columns": [ + "host_id", + "status" + ], + "isUnique": false + }, + "terminal_sessions_daemon_session_idx": { + "name": "terminal_sessions_daemon_session_idx", + "columns": [ + "daemon_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_thread_id_threads_id_fk": { + "name": "terminal_sessions_thread_id_threads_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_environment_id_environments_id_fk": { + "name": "terminal_sessions_environment_id_environments_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_host_id_hosts_id_fk": { + "name": "terminal_sessions_host_id_hosts_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk": { + "name": "terminal_sessions_daemon_session_id_host_daemon_sessions_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "host_daemon_sessions", + "columnsFrom": [ + "daemon_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_conversation_outlines": { + "name": "thread_conversation_outlines", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "projection_key": { + "name": "projection_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items_json": { + "name": "items_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_conversation_outlines_thread_id_threads_id_fk": { + "name": "thread_conversation_outlines_thread_id_threads_id_fk", + "tableFrom": "thread_conversation_outlines", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_dynamic_context_file_states": { + "name": "thread_dynamic_context_file_states", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_status": { + "name": "content_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_dynamic_context_file_states_thread_file_idx": { + "name": "thread_dynamic_context_file_states_thread_file_idx", + "columns": [ + "thread_id", + "file_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "thread_dynamic_context_file_states_thread_id_threads_id_fk": { + "name": "thread_dynamic_context_file_states_thread_id_threads_id_fk", + "tableFrom": "thread_dynamic_context_file_states", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_search_segments": { + "name": "thread_search_segments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_seq": { + "name": "source_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_search_segments_source_idx": { + "name": "thread_search_segments_source_idx", + "columns": [ + "thread_id", + "source_kind", + "source_key" + ], + "isUnique": true + }, + "thread_search_segments_thread_source_seq_idx": { + "name": "thread_search_segments_thread_source_seq_idx", + "columns": [ + "thread_id", + "source_seq" + ], + "isUnique": false + } + }, + "foreignKeys": { + "thread_search_segments_thread_id_threads_id_fk": { + "name": "thread_search_segments_thread_id_threads_id_fk", + "tableFrom": "thread_search_segments", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_sections": { + "name": "thread_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "thread_sections_name_idx": { + "name": "thread_sections_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "thread_tabs": { + "name": "thread_tabs", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tabs_json": { + "name": "tabs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "thread_tabs_thread_id_threads_id_fk": { + "name": "thread_tabs_thread_id_threads_id_fk", + "tableFrom": "thread_tabs", + "tableTo": "threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "threads": { + "name": "threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_override": { + "name": "model_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_level_override": { + "name": "reasoning_level_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title_fallback": { + "name": "title_fallback", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'starting'" + }, + "startup_context": { + "name": "startup_context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_thread_id": { + "name": "parent_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_thread_id": { + "name": "source_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_plugin_id": { + "name": "origin_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'visible'" + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_sort_key": { + "name": "pin_sort_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latest_attention_at": { + "name": "latest_attention_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "threads_project_updated_idx": { + "name": "threads_project_updated_idx", + "columns": [ + "project_id", + "updated_at" + ], + "isUnique": false + }, + "threads_project_archived_deleted_idx": { + "name": "threads_project_archived_deleted_idx", + "columns": [ + "project_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_pin_sort_idx": { + "name": "threads_pin_sort_idx", + "columns": [ + "archived_at", + "deleted_at", + "pin_sort_key", + "id" + ], + "isUnique": false, + "where": "\"threads\".\"pinned_at\" IS NOT NULL" + }, + "threads_environment_idx": { + "name": "threads_environment_idx", + "columns": [ + "environment_id" + ], + "isUnique": false + }, + "threads_parent_idx": { + "name": "threads_parent_idx", + "columns": [ + "parent_thread_id" + ], + "isUnique": false + }, + "threads_source_origin_idx": { + "name": "threads_source_origin_idx", + "columns": [ + "source_thread_id", + "origin_kind" + ], + "isUnique": false + }, + "threads_origin_plugin_archived_idx": { + "name": "threads_origin_plugin_archived_idx", + "columns": [ + "origin_plugin_id", + "archived_at" + ], + "isUnique": false + }, + "threads_section_archived_deleted_idx": { + "name": "threads_section_archived_deleted_idx", + "columns": [ + "section_id", + "archived_at", + "deleted_at", + "id" + ], + "isUnique": false + }, + "threads_archived_status_idx": { + "name": "threads_archived_status_idx", + "columns": [ + "archived_at", + "status" + ], + "isUnique": false + }, + "threads_environment_archived_deleted_idx": { + "name": "threads_environment_archived_deleted_idx", + "columns": [ + "environment_id", + "archived_at", + "deleted_at" + ], + "isUnique": false + }, + "threads_active_maintenance_idx": { + "name": "threads_active_maintenance_idx", + "columns": [ + "status" + ], + "isUnique": false, + "where": "\"threads\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "threads_project_id_projects_id_fk": { + "name": "threads_project_id_projects_id_fk", + "tableFrom": "threads", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "threads_environment_id_environments_id_fk": { + "name": "threads_environment_id_environments_id_fk", + "tableFrom": "threads", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_section_id_thread_sections_id_fk": { + "name": "threads_section_id_thread_sections_id_fk", + "tableFrom": "threads", + "tableTo": "thread_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_parent_thread_id_threads_id_fk": { + "name": "threads_parent_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "parent_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "threads_source_thread_id_threads_id_fk": { + "name": "threads_source_thread_id_threads_id_fk", + "tableFrom": "threads", + "tableTo": "threads", + "columnsFrom": [ + "source_thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ui_preferences": { + "name": "ui_preferences", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 96049dca12..0b8de214d2 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -820,6 +820,13 @@ "when": 1789075667774, "tag": "0116_majestic_swordsman", "breakpoints": true + }, + { + "idx": 117, + "version": "6", + "when": 1789081162875, + "tag": "0117_machine_providers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/environments.ts b/packages/db/src/data/environments.ts index 290513819a..34cb10ea74 100644 --- a/packages/db/src/data/environments.ts +++ b/packages/db/src/data/environments.ts @@ -202,6 +202,34 @@ export function listEnvironments( return paged.all(); } +export function markHostEnvironmentsDestroyed( + db: EnvironmentWriteConnection, + notifier: DbNotifier, + hostId: string, +): EnvironmentRow[] { + const updated = db + .update(environments) + .set({ + path: null, + resource: null, + retireAt: null, + status: "destroyed", + teardownMessage: null, + teardownStatus: "removed", + updatedAt: Date.now(), + }) + .where(eq(environments.hostId, hostId)) + .returning() + .all(); + for (const environment of updated) { + notifier.notifyEnvironment(environment.id, [ + "metadata-changed", + "status-changed", + ]); + } + return updated; +} + interface EnvironmentMetadataUpdateColumns { baseBranch?: string | null; branchName?: string | null; diff --git a/packages/db/src/data/hosts.ts b/packages/db/src/data/hosts.ts index e01064864d..9823046171 100644 --- a/packages/db/src/data/hosts.ts +++ b/packages/db/src/data/hosts.ts @@ -1,5 +1,5 @@ -import { and, eq, inArray, isNull } from "drizzle-orm"; -import type { HostChangeKind, HostType, PermissionMode } from "@bb/domain"; +import { and, eq, inArray, isNull, ne } from "drizzle-orm"; +import type { HostChangeKind, JsonValue, PermissionMode } from "@bb/domain"; import type { DbConnection, DbTransaction } from "../connection.js"; import type { DbNotifier } from "../notifier.js"; import { hosts } from "../schema.js"; @@ -11,15 +11,36 @@ export interface UpsertHostInput { connectMachineId?: string | null; id?: string; name: string; - type: HostType; + type?: "persistent" | "ephemeral"; destroyedAt?: number | null; } export interface UpdateHostInput { + type?: "persistent" | "ephemeral"; + machineOperationId?: string | null; + launchKey?: string | null; + inputs?: JsonValue | null; + attempt?: number; + pendingLog?: string; destroyedAt?: number | null; lastRejectedProtocolVersion?: number | null; maxPermissionMode?: PermissionMode; name?: string; + machineProviderId?: string | null; + phase?: + | "creating" + | "active" + | "suspending" + | "suspended" + | "removing" + | "destroyed"; + resource?: JsonValue | null; + removeRetryAt?: number | null; + statusMessage?: string | null; + suspendRetryAt?: number | null; + suspendedAt?: number | null; + teardownAttempt?: number; + teardownStatus?: "running" | "failed" | "removed" | null; } function notifyHostMutation( @@ -67,7 +88,7 @@ export function upsertHost( const updated = db .update(hosts) .set({ - type: input.type, + type: input.type ?? existing.type, connectMachineId: input.connectMachineId !== undefined ? input.connectMachineId @@ -91,8 +112,17 @@ export function upsertHost( .values({ id, name: input.name, - type: input.type, + type: input.type ?? "persistent", connectMachineId: input.connectMachineId ?? null, + machineProviderId: null, + resource: null, + phase: "active", + suspendedAt: null, + statusMessage: null, + suspendRetryAt: null, + removeRetryAt: null, + teardownAttempt: 0, + teardownStatus: null, destroyedAt: input.destroyedAt ?? null, lastSeenAt: null, lastRejectedProtocolVersion: null, @@ -131,15 +161,36 @@ export function getNonDestroyedHost(db: DbConnection, id: string) { ); } +export function getNonDestroyedHostByLaunchKey( + db: HostWriteConnection, + launchKey: string, +) { + return ( + db + .select() + .from(hosts) + .where(and(eq(hosts.launchKey, launchKey), isNull(hosts.destroyedAt))) + .get() ?? null + ); +} + export function listHosts(db: DbConnection) { return db.select().from(hosts).all(); } -export function listPublicHosts(db: DbConnection) { +export function listPublicHosts( + db: DbConnection, + options?: { includeCreating?: boolean }, +) { return db .select() .from(hosts) - .where(and(eq(hosts.type, "persistent"), isNull(hosts.destroyedAt))) + .where( + and( + isNull(hosts.destroyedAt), + ...(options?.includeCreating ? [] : [ne(hosts.phase, "creating")]), + ), + ) .all(); } @@ -172,6 +223,7 @@ export function updateHost( const now = Date.now(); db.update(hosts) .set({ + ...(input.type !== undefined ? { type: input.type } : {}), ...(input.destroyedAt !== undefined ? { destroyedAt: input.destroyedAt } : {}), @@ -182,6 +234,38 @@ export function updateHost( ...(input.lastRejectedProtocolVersion !== undefined ? { lastRejectedProtocolVersion: input.lastRejectedProtocolVersion } : {}), + ...(input.machineProviderId !== undefined + ? { machineProviderId: input.machineProviderId } + : {}), + ...(input.machineOperationId !== undefined + ? { machineOperationId: input.machineOperationId } + : {}), + ...(input.launchKey !== undefined ? { launchKey: input.launchKey } : {}), + ...(input.inputs !== undefined ? { inputs: input.inputs } : {}), + ...(input.attempt !== undefined ? { attempt: input.attempt } : {}), + ...(input.pendingLog !== undefined + ? { pendingLog: input.pendingLog } + : {}), + ...(input.phase !== undefined ? { phase: input.phase } : {}), + ...(input.resource !== undefined ? { resource: input.resource } : {}), + ...(input.removeRetryAt !== undefined + ? { removeRetryAt: input.removeRetryAt } + : {}), + ...(input.suspendedAt !== undefined + ? { suspendedAt: input.suspendedAt } + : {}), + ...(input.statusMessage !== undefined + ? { statusMessage: input.statusMessage } + : {}), + ...(input.suspendRetryAt !== undefined + ? { suspendRetryAt: input.suspendRetryAt } + : {}), + ...(input.teardownAttempt !== undefined + ? { teardownAttempt: input.teardownAttempt } + : {}), + ...(input.teardownStatus !== undefined + ? { teardownStatus: input.teardownStatus } + : {}), updatedAt: now, }) .where(eq(hosts.id, hostId)) diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index 6d09746bb7..775576319a 100644 --- a/packages/db/src/data/index.ts +++ b/packages/db/src/data/index.ts @@ -53,6 +53,7 @@ export { listProjectSourcesByProjectIds, listProjectSourcesByHost, getProjectSourceByHost, + projectSourceOwnsPath, getDefaultProjectSource, updateProjectSource, deleteProjectSource, @@ -241,6 +242,7 @@ export { findForeignManagedEnvironmentAtHostPath, findProviderEnvironmentContainingPath, listRetiredLoadedEnvironmentIdsOnHost, + markHostEnvironmentsDestroyed, recordEnvironmentCurrentBranch, recordEnvironmentProviderProvenance, updateEnvironmentMetadata, @@ -251,6 +253,7 @@ export { upsertHost, getHost, getNonDestroyedHost, + getNonDestroyedHostByLaunchKey, listHosts, listNonDestroyedHostsByIds, listPublicHosts, @@ -486,3 +489,4 @@ export { shouldCompactDatabase, shouldRunIncrementalVacuum, } from "./maintenance.js"; +export * from "./machines.js"; diff --git a/packages/db/src/data/machines.ts b/packages/db/src/data/machines.ts new file mode 100644 index 0000000000..a80dd6271f --- /dev/null +++ b/packages/db/src/data/machines.ts @@ -0,0 +1,71 @@ +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import type { DbConnection, DbTransaction } from "../connection.js"; +import { environments, hosts, threads } from "../schema.js"; + +type Connection = DbConnection | DbTransaction; + +const liveThreadCondition = or( + and(isNull(threads.archivedAt), isNull(threads.deletedAt)), + eq(threads.status, "stopping"), + eq(threads.status, "active"), +); + +export function listProviderMachines(db: Connection, providerId: string) { + return db + .select() + .from(hosts) + .where(eq(hosts.machineProviderId, providerId)) + .all(); +} + +export function machineHasLiveThreads(db: Connection, hostId: string): boolean { + return ( + db + .select({ id: threads.id }) + .from(threads) + .innerJoin(environments, eq(threads.environmentId, environments.id)) + .where(and(eq(environments.hostId, hostId), liveThreadCondition)) + .limit(1) + .get() !== undefined + ); +} + +export function machineHasLiveThreadLaunch( + db: Connection, + hostId: string, +): boolean { + return ( + db + .select({ id: threads.id }) + .from(hosts) + .innerJoin(threads, eq(hosts.launchKey, threads.id)) + .where( + and( + eq(hosts.id, hostId), + isNull(hosts.destroyedAt), + liveThreadCondition, + ), + ) + .limit(1) + .get() !== undefined + ); +} + +export function machineHasProvisioningEnvironment( + db: Connection, + hostId: string, +): boolean { + return ( + db + .select({ id: environments.id }) + .from(environments) + .where( + and( + eq(environments.hostId, hostId), + inArray(environments.status, ["creating", "provisioning"]), + ), + ) + .limit(1) + .get() !== undefined + ); +} diff --git a/packages/db/src/data/project-sources.ts b/packages/db/src/data/project-sources.ts index 74f394a058..416f2c961f 100644 --- a/packages/db/src/data/project-sources.ts +++ b/packages/db/src/data/project-sources.ts @@ -13,6 +13,7 @@ export interface CreateLocalPathProjectSourceInput { hostId: string; path: string; isDefault?: boolean; + ownsPath?: boolean; } export type CreateProjectSourceInput = CreateLocalPathProjectSourceInput; @@ -65,6 +66,7 @@ export function createProjectSource( hostId: input.hostId, path: input.path, isDefault: shouldBeDefault, + ownsPath: input.ownsPath ?? false, createdAt: now, updatedAt: now, }) @@ -286,3 +288,10 @@ export function deleteProjectSource( notifier.notifyProject(deleted, ["project-sources-changed"]); return true; } + + +export function projectSourceOwnsPath(db: DbConnection, projectId: string, hostId: string, path: string): boolean { + return db.select({ ownsPath: projectSources.ownsPath }).from(projectSources).where(and( + eq(projectSources.projectId, projectId), eq(projectSources.hostId, hostId), eq(projectSources.path, path), + )).get()?.ownsPath ?? false; +} diff --git a/packages/db/src/data/sessions.ts b/packages/db/src/data/sessions.ts index 46bb452504..7dcbcd5800 100644 --- a/packages/db/src/data/sessions.ts +++ b/packages/db/src/data/sessions.ts @@ -1,5 +1,4 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"; -import type { HostType } from "@bb/domain"; import type { DbConnection, DbTransaction } from "../connection.js"; import type { DbNotifier } from "../notifier.js"; import { hostDaemonSessions } from "../schema.js"; @@ -25,7 +24,6 @@ export interface OpenSessionInput { hostId: string; instanceId: string; hostName: string; - hostType: HostType; dataDir: string; protocolVersion: number; heartbeatIntervalMs: number; @@ -63,7 +61,6 @@ export function openSession( hostId: input.hostId, instanceId: input.instanceId, hostName: input.hostName, - hostType: input.hostType, dataDir: input.dataDir, protocolVersion: input.protocolVersion, heartbeatIntervalMs: input.heartbeatIntervalMs, diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 19501a5ec3..f7bd33e5e2 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -1485,6 +1485,20 @@ export function migrate(db: DbConnection, options: MigrateOptions = {}): void { const migrationsFolder = resolveMigrationsFolder(); const sqlite = db.$client; + sqlite.exec( + "CREATE TEMP TABLE IF NOT EXISTS bb_migration_local_host (id TEXT PRIMARY KEY)", + ); + sqlite.exec("DELETE FROM bb_migration_local_host"); + if (sqlite.name !== ":memory:") { + const identityPath = join(dirname(sqlite.name), "host-id"); + if (existsSync(identityPath)) { + const hostId = readFileSync(identityPath, "utf8").trim(); + if (hostId) + sqlite + .prepare("INSERT INTO bb_migration_local_host (id) VALUES (?)") + .run(hostId); + } + } sqlite.pragma("foreign_keys = OFF"); try { assertNoDuplicatePendingInteractionProviderRequests(db); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index aa6443cc3a..2a4ffeb555 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -18,7 +18,6 @@ import type { JsonValue, EnvironmentStatus, FaviconColorPreference, - HostType, PendingInteractionStatus, PermissionMode, PromptHistoryScope, @@ -94,8 +93,36 @@ export const hosts = sqliteTable( { id: text("id").primaryKey(), name: text("name").notNull(), - type: text("type").$type().notNull(), + type: text("type").$type<"persistent" | "ephemeral">().notNull(), connectMachineId: text("connect_machine_id"), + machineProviderId: text("machine_provider_id"), + launchKey: text("launch_key"), + inputs: text("machine_inputs", { mode: "json" }).$type(), + attempt: integer("machine_attempt").notNull().default(0), + pendingLog: text("pending_log").notNull().default(""), + machineOperationId: text("machine_operation_id"), + serverAccessProviderId: text("server_access_provider_id"), + serverAccessGrantId: text("server_access_grant_id"), + resource: text("resource", { mode: "json" }).$type(), + phase: text("phase") + .$type< + | "creating" + | "active" + | "suspending" + | "suspended" + | "removing" + | "destroyed" + >() + .notNull() + .default("active"), + suspendedAt: integer("suspended_at"), + statusMessage: text("status_message"), + suspendRetryAt: integer("suspend_retry_at"), + removeRetryAt: integer("remove_retry_at"), + teardownAttempt: integer("teardown_attempt").notNull().default(0), + teardownStatus: text("teardown_status").$type< + "running" | "failed" | "removed" + >(), maxPermissionMode: text("max_permission_mode") .$type() .notNull() @@ -106,7 +133,12 @@ export const hosts = sqliteTable( createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at").notNull(), }, - (table) => [index("hosts_last_seen_idx").on(table.lastSeenAt)], + (table) => [ + index("hosts_last_seen_idx").on(table.lastSeenAt), + uniqueIndex("hosts_live_launch_key_idx") + .on(table.launchKey) + .where(sql`${table.destroyedAt} is null`), + ], ); export const projects = sqliteTable( @@ -418,6 +450,9 @@ export const projectSources = sqliteTable( type: text("type").$type().notNull(), hostId: text("host_id").references(() => hosts.id, { onDelete: "cascade" }), path: text("path"), + ownsPath: integer("owns_path", { mode: "boolean" }) + .notNull() + .default(false), isDefault: integer("is_default", { mode: "boolean" }) .notNull() .default(false), @@ -938,7 +973,6 @@ export const hostDaemonSessions = sqliteTable( .references(() => hosts.id, { onDelete: "cascade" }), instanceId: text("instance_id").notNull(), hostName: text("host_name").notNull(), - hostType: text("host_type").$type().notNull(), dataDir: text("data_dir").notNull(), protocolVersion: integer("protocol_version").notNull(), heartbeatIntervalMs: integer("heartbeat_interval_ms").notNull(), @@ -1064,3 +1098,17 @@ export const pendingInteractions = sqliteTable( ), ], ); + +export const environmentHookOperations = sqliteTable( + "environment_hook_operations", + { + id: text("id").primaryKey(), + operationId: text("operation_id").notNull(), + hostId: text("host_id").notNull(), + path: text("path").notNull(), + kind: text("kind").$type<"setup" | "teardown">().notNull(), + startedAt: integer("started_at").notNull(), + finishedAt: integer("finished_at"), + error: text("error"), + }, +); diff --git a/packages/db/test/data/completed-event-output-migration.test.ts b/packages/db/test/data/completed-event-output-migration.test.ts index cd4d8e451e..1486d69591 100644 --- a/packages/db/test/data/completed-event-output-migration.test.ts +++ b/packages/db/test/data/completed-event-output-migration.test.ts @@ -30,7 +30,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "completed-output-migration-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "completed-output-migration-project", diff --git a/packages/db/test/data/environment-lifecycle.test.ts b/packages/db/test/data/environment-lifecycle.test.ts index f28132773c..0cf6552bff 100644 --- a/packages/db/test/data/environment-lifecycle.test.ts +++ b/packages/db/test/data/environment-lifecycle.test.ts @@ -27,7 +27,7 @@ import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { const db = createMigratedConnection(); - const host = upsertHost(db, noopNotifier, { type: "persistent", + const host = upsertHost(db, noopNotifier, { name: "test-host", }); const { project } = createProject(db, noopNotifier, { diff --git a/packages/db/test/data/environments.test.ts b/packages/db/test/data/environments.test.ts index 306b0823fd..8ab9c13d61 100644 --- a/packages/db/test/data/environments.test.ts +++ b/packages/db/test/data/environments.test.ts @@ -7,6 +7,7 @@ import { findForeignManagedEnvironmentAtHostPath, findProviderEnvironmentContainingPath, listRetiredLoadedEnvironmentIdsOnHost, + markHostEnvironmentsDestroyed, recordEnvironmentCurrentBranch, recordProvisionedEnvironmentWorkspace, updateEnvironmentMetadata, @@ -20,7 +21,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", @@ -40,6 +40,58 @@ function createNotifierSpy(): DbNotifier { } describe("environments", () => { + it("marks every environment on a removed host as destroyed history", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(25_000); + const { db, host, project } = setup(); + const first = createEnvironment(db, noopNotifier, { + providerOwnsPath: true, + projectId: project.id, + hostId: host.id, + path: "/tmp/removed-host-first", + status: "ready", + }); + const second = createEnvironment(db, noopNotifier, { + providerOwnsPath: false, + projectId: project.id, + hostId: host.id, + path: "/tmp/removed-host-second", + status: "error", + }); + db.update(environments) + .set({ + resource: { provider: "state" }, + retireAt: 30_000, + teardownMessage: "previous failure", + teardownStatus: "failed", + }) + .where(eq(environments.id, second.id)) + .run(); + const notifier = createNotifierSpy(); + + const updated = markHostEnvironmentsDestroyed(db, notifier, host.id); + + expect(updated.map((environment) => environment.id)).toEqual([ + first.id, + second.id, + ]); + for (const environment of updated) { + expect(environment).toMatchObject({ + path: null, + resource: null, + retireAt: null, + status: "destroyed", + teardownMessage: null, + teardownStatus: "removed", + updatedAt: 25_000, + }); + expect(notifier.notifyEnvironment).toHaveBeenCalledWith(environment.id, [ + "metadata-changed", + "status-changed", + ]); + } + }); + it("keeps a path unique while provider teardown is pending", () => { const { db, host, project } = setup(); const first = createEnvironment(db, noopNotifier, { @@ -246,7 +298,6 @@ describe("environments", () => { const { db, host, project } = setup(); const otherHost = upsertHost(db, noopNotifier, { name: "other-host", - type: "persistent", }); const { project: otherProject } = createProject(db, noopNotifier, { name: "other-project", @@ -296,7 +347,11 @@ describe("environments", () => { describe("environment path claims", () => { function seedClaim( args: ReturnType, - input: { environmentProviderId: string; path: string; providerOwnsPath: boolean }, + input: { + environmentProviderId: string; + path: string; + providerOwnsPath: boolean; + }, ) { return createEnvironment(args.db, noopNotifier, { projectId: args.project.id, diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index e362b69466..e0a9a831d9 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -69,7 +69,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", @@ -4677,7 +4676,7 @@ describe("events", () => { it("lists the latest lifecycle row per open backgroundTask item on a host", () => { const db = createMigratedConnection(); - const host = upsertHost(db, noopNotifier, { type: "persistent", + const host = upsertHost(db, noopNotifier, { name: "task-host", }); const { project } = createProject(db, noopNotifier, { @@ -4781,7 +4780,6 @@ describe("events", () => { const otherHost = upsertHost(db, noopNotifier, { name: "other-host", - type: "persistent", }); expect( listOpenBackgroundTaskItemRowsForHost(db, { hostId: otherHost.id }), diff --git a/packages/db/test/data/hosts.test.ts b/packages/db/test/data/hosts.test.ts index 36ba3fa47b..a800e01110 100644 --- a/packages/db/test/data/hosts.test.ts +++ b/packages/db/test/data/hosts.test.ts @@ -23,12 +23,11 @@ describe("hosts", () => { const { db } = setup(); const host = upsertHost(db, noopNotifier, { name: "My Machine", - type: "persistent", }); expect(host.id).toMatch(/^host_/); expect(host.name).toBe("My Machine"); - expect(host.type).toBe("persistent"); + expect(host.machineProviderId).toBeNull(); expect(host.lastSeenAt).toBeNull(); }); @@ -37,7 +36,6 @@ describe("hosts", () => { const host1 = upsertHost(db, noopNotifier, { connectMachineId: "machine-1", name: "My Machine", - type: "persistent", }); markHostSeen(db, host1.id, 1_000); @@ -46,7 +44,6 @@ describe("hosts", () => { connectMachineId: "machine-2", id: host1.id, name: "Updated Reported Name", - type: "persistent", }); expect(host2.id).toBe(host1.id); @@ -60,20 +57,17 @@ describe("hosts", () => { const host = upsertHost(db, noopNotifier, { destroyedAt: 123, name: "Disconnected Host", - type: "persistent", }); const updated = upsertHost(db, noopNotifier, { id: host.id, name: "Disconnected Host Renamed", - type: "persistent", }); expect(updated).toMatchObject({ destroyedAt: 123, id: host.id, name: "Disconnected Host", - type: "persistent", }); }); @@ -90,7 +84,6 @@ describe("hosts", () => { const host = upsertHost(db, notifier, { destroyedAt: 123, name: "Persistent Host", - type: "persistent", }); notifyHost.mockClear(); @@ -98,7 +91,6 @@ describe("hosts", () => { destroyedAt: null, id: host.id, name: "Persistent Host", - type: "persistent", }); expect(notifyHost).toHaveBeenCalledWith(host.id, ["host-connected"]); @@ -116,14 +108,12 @@ describe("hosts", () => { }; const host = upsertHost(db, notifier, { name: "Persistent Host", - type: "persistent", }); notifyHost.mockClear(); upsertHost(db, notifier, { id: host.id, name: "Persistent Host Renamed", - type: "persistent", }); expect(notifyHost).not.toHaveBeenCalled(); @@ -133,7 +123,6 @@ describe("hosts", () => { const { db } = setup(); const host = upsertHost(db, noopNotifier, { name: "My Machine", - type: "persistent", }); const fetched = getHost(db, host.id); @@ -143,8 +132,8 @@ describe("hosts", () => { it("lists all hosts", () => { const { db } = setup(); - upsertHost(db, noopNotifier, { name: "Host 1", type: "persistent" }); - upsertHost(db, noopNotifier, { name: "Host 2", type: "persistent" }); + upsertHost(db, noopNotifier, { name: "Host 1" }); + upsertHost(db, noopNotifier, { name: "Host 2" }); const all = listHosts(db); expect(all).toHaveLength(2); @@ -155,18 +144,24 @@ describe("hosts", () => { const visibleHost = upsertHost(db, noopNotifier, { id: "host-visible", name: "Visible Host", - type: "persistent", + }); + const ephemeralHost = upsertHost(db, noopNotifier, { + id: "host-ephemeral", + name: "Ephemeral Host", }); const destroyedHost = upsertHost(db, noopNotifier, { id: "host-destroyed", name: "Destroyed Host", - type: "persistent", }); + updateHost(db, noopNotifier, ephemeralHost.id, { phase: "creating" }); updateHost(db, noopNotifier, destroyedHost.id, { destroyedAt: 123 }); expect(listPublicHosts(db).map((host) => host.id)).toEqual([ visibleHost.id, ]); + expect( + listPublicHosts(db, { includeCreating: true }).map((host) => host.id), + ).toEqual([visibleHost.id, ephemeralHost.id]); }); it("filters destroyed hosts from non-destroyed lookups", () => { @@ -174,12 +169,10 @@ describe("hosts", () => { const visibleHost = upsertHost(db, noopNotifier, { id: "host-visible", name: "Visible Host", - type: "persistent", }); const destroyedHost = upsertHost(db, noopNotifier, { id: "host-destroyed", name: "Destroyed Host", - type: "persistent", }); updateHost(db, noopNotifier, destroyedHost.id, { destroyedAt: 123 }); @@ -197,7 +190,6 @@ describe("hosts", () => { const { db } = setup(); const host = upsertHost(db, noopNotifier, { name: "Persistent Host", - type: "persistent", }); const updated = updateHost(db, noopNotifier, host.id, { @@ -223,7 +215,6 @@ describe("hosts", () => { }; const host = upsertHost(db, notifier, { name: "Persistent Host", - type: "persistent", }); notifyHost.mockClear(); @@ -249,7 +240,6 @@ describe("hosts", () => { }; const host = upsertHost(db, notifier, { name: "Persistent Host", - type: "persistent", }); notifyHost.mockClear(); @@ -272,7 +262,6 @@ describe("hosts", () => { }; const host = upsertHost(db, notifier, { name: "Transient Host", - type: "persistent", }); notifyHost.mockClear(); diff --git a/packages/db/test/data/machines.test.ts b/packages/db/test/data/machines.test.ts new file mode 100644 index 0000000000..53c03da6bc --- /dev/null +++ b/packages/db/test/data/machines.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { createEnvironment } from "../../src/data/environments.js"; +import { updateHost, upsertHost } from "../../src/data/hosts.js"; +import { + machineHasLiveThreadLaunch, + machineHasProvisioningEnvironment, +} from "../../src/data/machines.js"; +import { createProject } from "../../src/data/projects.js"; +import { archiveThread, createThread } from "../../src/data/threads.js"; +import { noopNotifier } from "../../src/notifier.js"; +import { environments } from "../../src/schema.js"; +import { createMigratedConnection } from "../helpers/migrated-connection.js"; + +function setup() { + const db = createMigratedConnection(); + const host = upsertHost(db, noopNotifier, { name: "test-host" }); + const { project } = createProject(db, noopNotifier, { + name: "test-project", + source: { type: "local_path", hostId: host.id, path: "/tmp/test" }, + }); + return { db, host, project }; +} + +describe("machine provisioning state", () => { + it("finds a live thread from the machine launch key until it is archived", () => { + const { db, host, project } = setup(); + const thread = createThread(db, noopNotifier, { + projectId: project.id, + providerId: "codex", + status: "starting", + }); + updateHost(db, noopNotifier, host.id, { launchKey: thread.id }); + + expect(machineHasLiveThreadLaunch(db, host.id)).toBe(true); + archiveThread(db, noopNotifier, thread.id); + expect(machineHasLiveThreadLaunch(db, host.id)).toBe(false); + }); + + it("finds a provisioning environment on the host until it is ready", () => { + const { db, host, project } = setup(); + const environment = createEnvironment(db, noopNotifier, { + projectId: project.id, + hostId: host.id, + path: "/tmp/environment", + providerOwnsPath: false, + status: "provisioning", + environmentProvider: null, + }); + + expect(machineHasProvisioningEnvironment(db, host.id)).toBe(true); + db.update(environments) + .set({ status: "ready" }) + .where(eq(environments.id, environment.id)) + .run(); + expect(machineHasProvisioningEnvironment(db, host.id)).toBe(false); + }); +}); diff --git a/packages/db/test/data/maintenance.test.ts b/packages/db/test/data/maintenance.test.ts index 5a320fde58..cb8923efad 100644 --- a/packages/db/test/data/maintenance.test.ts +++ b/packages/db/test/data/maintenance.test.ts @@ -53,7 +53,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "maintenance-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "maintenance-project", diff --git a/packages/db/test/data/pending-interactions.test.ts b/packages/db/test/data/pending-interactions.test.ts index 31d418598c..251b6cda9a 100644 --- a/packages/db/test/data/pending-interactions.test.ts +++ b/packages/db/test/data/pending-interactions.test.ts @@ -19,7 +19,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/packages/db/test/data/project-execution-defaults.test.ts b/packages/db/test/data/project-execution-defaults.test.ts index 851cfcfce4..1839d22964 100644 --- a/packages/db/test/data/project-execution-defaults.test.ts +++ b/packages/db/test/data/project-execution-defaults.test.ts @@ -12,7 +12,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "defaults-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "defaults-project", diff --git a/packages/db/test/data/project-sources.test.ts b/packages/db/test/data/project-sources.test.ts index f5ad5b7e44..14cc9f4f53 100644 --- a/packages/db/test/data/project-sources.test.ts +++ b/packages/db/test/data/project-sources.test.ts @@ -21,7 +21,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", @@ -35,7 +34,6 @@ describe("project-sources", () => { const { db, project } = setup(); const newHost = upsertHost(db, noopNotifier, { name: "source-test-host", - type: "persistent", }); const source = createProjectSource(db, noopNotifier, { projectId: project.id, @@ -57,11 +55,9 @@ describe("project-sources", () => { const { db, project } = setup(); const host2 = upsertHost(db, noopNotifier, { name: "test-host-2", - type: "persistent", }); const host3 = upsertHost(db, noopNotifier, { name: "test-host-3", - type: "persistent", }); createProjectSource(db, noopNotifier, { projectId: project.id, @@ -84,11 +80,9 @@ describe("project-sources", () => { const { db, host, project } = setup(); const host2 = upsertHost(db, noopNotifier, { name: "project-host-2", - type: "persistent", }); const host3 = upsertHost(db, noopNotifier, { name: "project-host-3", - type: "persistent", }); const { project: otherProject } = createProject(db, noopNotifier, { name: "other-project", @@ -121,7 +115,6 @@ describe("project-sources", () => { const { db, project } = setup(); const secondaryHost = upsertHost(db, noopNotifier, { name: "secondary-host", - type: "persistent", }); const initialDefault = getDefaultProjectSource(db, project.id); const source = createProjectSource(db, noopNotifier, { @@ -144,7 +137,6 @@ describe("project-sources", () => { const { db, project } = setup(); const secondaryHost = upsertHost(db, noopNotifier, { name: "test-host-2", - type: "persistent", }); const secondarySource = createProjectSource(db, noopNotifier, { projectId: project.id, @@ -162,7 +154,6 @@ describe("project-sources", () => { const { db, project } = setup(); const missingHost = upsertHost(db, noopNotifier, { name: "missing-host", - type: "persistent", }); expect(getProjectSourceByHost(db, project.id, missingHost.id)).toBeNull(); @@ -172,7 +163,6 @@ describe("project-sources", () => { const { db, project } = setup(); const secondaryHost = upsertHost(db, noopNotifier, { name: "source-id-host", - type: "persistent", }); const source = createProjectSource(db, noopNotifier, { projectId: project.id, @@ -214,7 +204,6 @@ describe("project-sources", () => { const { db, project } = setup(); const conflictHost = upsertHost(db, noopNotifier, { name: "default-conflict-host", - type: "persistent", }); const now = Date.now(); @@ -241,7 +230,6 @@ describe("project-sources", () => { const { db, project } = setup(); const updateHost = upsertHost(db, noopNotifier, { name: "update-test-host", - type: "persistent", }); const source = createProjectSource(db, noopNotifier, { projectId: project.id, @@ -265,7 +253,6 @@ describe("project-sources", () => { const { db, project } = setup(); const deleteHost = upsertHost(db, noopNotifier, { name: "delete-test-host", - type: "persistent", }); const source = createProjectSource(db, noopNotifier, { projectId: project.id, @@ -283,7 +270,6 @@ describe("project-sources", () => { const { db, project } = setup(); const host2 = upsertHost(db, noopNotifier, { name: "test-host-2", - type: "persistent", }); const second = createProjectSource(db, noopNotifier, { projectId: project.id, diff --git a/packages/db/test/data/projects.test.ts b/packages/db/test/data/projects.test.ts index 3c6f370adc..f53377d5f4 100644 --- a/packages/db/test/data/projects.test.ts +++ b/packages/db/test/data/projects.test.ts @@ -19,7 +19,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "projects-host", - type: "persistent", }); return { db, host }; } @@ -53,7 +52,6 @@ describe("projects", () => { const { db, host } = setup(); const otherHost = upsertHost(db, noopNotifier, { name: "other-projects-host", - type: "persistent", }); const first = findOrCreateProjectByLocalPathSource(db, noopNotifier, { diff --git a/packages/db/test/data/queued-message-waits.test.ts b/packages/db/test/data/queued-message-waits.test.ts index e933b392fd..be180d6ddd 100644 --- a/packages/db/test/data/queued-message-waits.test.ts +++ b/packages/db/test/data/queued-message-waits.test.ts @@ -40,7 +40,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/packages/db/test/data/queued-thread-messages.test.ts b/packages/db/test/data/queued-thread-messages.test.ts index 620a8c109b..60c03e619c 100644 --- a/packages/db/test/data/queued-thread-messages.test.ts +++ b/packages/db/test/data/queued-thread-messages.test.ts @@ -36,7 +36,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/packages/db/test/data/retained-event-outputs.test.ts b/packages/db/test/data/retained-event-outputs.test.ts index 34e035337b..bb6e22d0fd 100644 --- a/packages/db/test/data/retained-event-outputs.test.ts +++ b/packages/db/test/data/retained-event-outputs.test.ts @@ -31,7 +31,6 @@ function setup(options: CreateConnectionOptions = {}) { const db = createMigratedConnection(options); const host = upsertHost(db, noopNotifier, { name: "retained-output-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "retained-output-project", diff --git a/packages/db/test/data/sessions.test.ts b/packages/db/test/data/sessions.test.ts index cb98ad1999..a83ae66abb 100644 --- a/packages/db/test/data/sessions.test.ts +++ b/packages/db/test/data/sessions.test.ts @@ -17,7 +17,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); return { db, host }; } @@ -30,7 +29,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -52,7 +50,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -79,7 +76,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -103,7 +99,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -114,7 +109,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-2", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -140,14 +134,12 @@ describe("sessions", () => { const { db, host } = setup(); const otherHost = upsertHost(db, noopNotifier, { name: "test-host-2", - type: "persistent", }); const firstSession = openSession(db, { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -157,7 +149,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-2", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -167,7 +158,6 @@ describe("sessions", () => { hostId: otherHost.id, instanceId: "inst-3", hostName: "test-host-2", - hostType: "persistent", dataDir: "/tmp/test-host-data-2", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -177,7 +167,6 @@ describe("sessions", () => { hostId: otherHost.id, instanceId: "inst-4", hostName: "test-host-2", - hostType: "persistent", dataDir: "/tmp/test-host-data-2", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -214,7 +203,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -224,7 +212,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-2", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -264,7 +251,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -281,7 +267,6 @@ describe("sessions", () => { hostId: host.id, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, diff --git a/packages/db/test/data/sweeps.test.ts b/packages/db/test/data/sweeps.test.ts index affcebcb66..b1cead1ce7 100644 --- a/packages/db/test/data/sweeps.test.ts +++ b/packages/db/test/data/sweeps.test.ts @@ -32,7 +32,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", @@ -52,7 +51,6 @@ describe("pruneClosedSessions", () => { hostId: args.hostId, instanceId: args.instanceId, hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -91,7 +89,6 @@ describe("pruneClosedSessions", () => { hostId: host.id, instanceId: "inst-active", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -130,7 +127,6 @@ describe("pruneClosedSessions", () => { hostId: host.id, instanceId: "inst-active", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, diff --git a/packages/db/test/data/terminal-sessions.test.ts b/packages/db/test/data/terminal-sessions.test.ts index 05b1e6068e..23001eb743 100644 --- a/packages/db/test/data/terminal-sessions.test.ts +++ b/packages/db/test/data/terminal-sessions.test.ts @@ -179,7 +179,6 @@ function openTestSession(db: TestDb, hostId: string): TestSession { hostId, instanceId: "inst-1", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -191,7 +190,6 @@ function setup(): TerminalSessionFixture { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const session = openTestSession(db, host.id); const { project } = createProject(db, noopNotifier, { diff --git a/packages/db/test/data/thread-count.test.ts b/packages/db/test/data/thread-count.test.ts index 06031806f4..b937adfe48 100644 --- a/packages/db/test/data/thread-count.test.ts +++ b/packages/db/test/data/thread-count.test.ts @@ -19,11 +19,9 @@ function setup() { const db = createMigratedConnection(); const hostA = upsertHost(db, noopNotifier, { name: "host-a", - type: "persistent", }); const hostB = upsertHost(db, noopNotifier, { name: "host-b", - type: "persistent", }); const { project: projectA } = createProject(db, noopNotifier, { name: "project-a", diff --git a/packages/db/test/data/thread-lifecycle.test.ts b/packages/db/test/data/thread-lifecycle.test.ts index e913648b0a..7ad4450ecb 100644 --- a/packages/db/test/data/thread-lifecycle.test.ts +++ b/packages/db/test/data/thread-lifecycle.test.ts @@ -21,7 +21,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/packages/db/test/data/thread-running.test.ts b/packages/db/test/data/thread-running.test.ts index b23272f7c0..9d1e533be4 100644 --- a/packages/db/test/data/thread-running.test.ts +++ b/packages/db/test/data/thread-running.test.ts @@ -13,10 +13,10 @@ import { createMigratedConnection } from "../helpers/migrated-connection.js"; function setup() { const db = createMigratedConnection(); - const hostA = upsertHost(db, noopNotifier, { type: "persistent", + const hostA = upsertHost(db, noopNotifier, { name: "host-a", }); - const hostB = upsertHost(db, noopNotifier, { type: "persistent", + const hostB = upsertHost(db, noopNotifier, { name: "host-b", }); const { project } = createProject(db, noopNotifier, { diff --git a/packages/db/test/data/thread-search.test.ts b/packages/db/test/data/thread-search.test.ts index 8948ae987a..6226d59e35 100644 --- a/packages/db/test/data/thread-search.test.ts +++ b/packages/db/test/data/thread-search.test.ts @@ -34,7 +34,6 @@ function setup(): SetupResult { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/packages/db/test/data/threads.test.ts b/packages/db/test/data/threads.test.ts index 6ccdec96de..b6b3dc84b8 100644 --- a/packages/db/test/data/threads.test.ts +++ b/packages/db/test/data/threads.test.ts @@ -47,7 +47,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", @@ -1312,7 +1311,7 @@ describe("threads", () => { it("lists canonical thread environments for a host", () => { const { db, project, host } = setup(); - const otherHost = upsertHost(db, noopNotifier, { type: "persistent", + const otherHost = upsertHost(db, noopNotifier, { name: "other-host", }); const environment = createEnvironment(db, noopNotifier, { @@ -1355,7 +1354,7 @@ describe("threads", () => { it("lists host thread ids and detects pending shutdowns by environment", () => { const { db, project, host } = setup(); - const otherHost = upsertHost(db, noopNotifier, { type: "persistent", + const otherHost = upsertHost(db, noopNotifier, { name: "other-host", }); const environment = createEnvironment(db, noopNotifier, { diff --git a/packages/db/test/machine-provider-upgrade.test.ts b/packages/db/test/machine-provider-upgrade.test.ts new file mode 100644 index 0000000000..ecf315a420 --- /dev/null +++ b/packages/db/test/machine-provider-upgrade.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readMigrationFiles } from "drizzle-orm/migrator"; +import { expect, it } from "vitest"; +import { createConnection, migrate } from "../src/index.js"; + +it("upgrades the merged environment schema and preserves existing hosts", () => { + const directory = mkdtempSync(join(tmpdir(), "bb-machine-upgrade-")); + writeFileSync(join(directory, "host-id"), "local-host\n"); + const db = createConnection(join(directory, "bb.db")); + try { + const migrations = readMigrationFiles({ + migrationsFolder: fileURLToPath(new URL("../drizzle", import.meta.url)), + }); + db.$client.exec( + 'CREATE TABLE "__drizzle_migrations" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric)', + ); + for (const migration of migrations.slice(0, 115)) { + for (const statement of migration.sql) db.$client.exec(statement); + db.$client + .prepare( + 'INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)', + ) + .run(migration.hash, migration.folderMillis); + } + for (const id of ["local-host", "remote-host"]) + db.$client + .prepare( + "INSERT INTO hosts (id, name, type, created_at, updated_at) VALUES (?, ?, 'persistent', 10, 20)", + ) + .run(id, id); + migrate(db); + expect( + db.$client + .prepare( + "SELECT id, name, machine_provider_id, phase, created_at, updated_at FROM hosts ORDER BY id", + ) + .all(), + ).toEqual([ + { + id: "local-host", + name: "local-host", + machine_provider_id: null, + phase: "active", + created_at: 10, + updated_at: 20, + }, + { + id: "remote-host", + name: "remote-host", + machine_provider_id: "manual", + phase: "active", + created_at: 10, + updated_at: 20, + }, + ]); + expect( + db.$client + .prepare("SELECT resource FROM hosts WHERE id = 'remote-host'") + .get(), + ).toEqual({ + resource: JSON.stringify({ version: 1, hostId: "remote-host" }), + }); + expect( + db.$client + .prepare("SELECT count(*) AS count FROM __drizzle_migrations") + .get(), + ).toEqual({ count: migrations.length }); + expect(db.$client.pragma("foreign_key_check")).toEqual([]); + migrate(db); + expect( + db.$client + .prepare("SELECT count(*) AS count FROM __drizzle_migrations") + .get(), + ).toEqual({ count: migrations.length }); + } finally { + db.$client.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/db/test/manual-machine-migration.test.ts b/packages/db/test/manual-machine-migration.test.ts new file mode 100644 index 0000000000..068b7d7b6b --- /dev/null +++ b/packages/db/test/manual-machine-migration.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { + createConnection, + migrate, + noopNotifier, + upsertHost, +} from "../src/index.js"; + +it("backfills only non-local hosts, preserves all other columns and is idempotent", () => { + const directory = mkdtempSync(join(tmpdir(), "bb-manual-migration-")); + writeFileSync(join(directory, "host-id"), "local-host\n"); + const db = createConnection(join(directory, "bb.db")); + try { + migrate(db); + for (const id of ["local-host", "enrolled-a", "enrolled-b", "managed-host"]) + upsertHost(db, noopNotifier, { id, name: id }); + db.$client + .prepare( + "UPDATE hosts SET machine_provider_id = ?, resource = ? WHERE id = ?", + ) + .run("digitalocean", JSON.stringify({ dropletId: 123 }), "managed-host"); + const before = db.$client.prepare("SELECT * FROM hosts ORDER BY id").all(); + const sql = readFileSync( + new URL("../drizzle/0117_machine_providers.sql", import.meta.url), + "utf8", + ) + .split("--> statement-breakpoint") + .find((statement) => statement.includes("UPDATE hosts")); + if (sql === undefined) throw new Error("Missing manual machine backfill"); + db.$client.exec(sql); + const after = db.$client.prepare("SELECT * FROM hosts ORDER BY id").all(); + expect(after).toEqual( + before.map((row) => { + const parsed = hostRow(row); + return parsed.id === "local-host" || parsed.machine_provider_id !== null + ? row + : { + ...parsed, + machine_provider_id: "manual", + resource: JSON.stringify({ version: 1, hostId: parsed.id }), + }; + }), + ); + expect(db.$client.pragma("foreign_key_check")).toEqual([]); + db.$client.exec(sql); + migrate(db); + expect(db.$client.prepare("SELECT * FROM hosts ORDER BY id").all()).toEqual( + after, + ); + } finally { + db.$client.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); + +function hostRow(value: unknown): Record & { id: string } { + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" + ) + throw new Error("Invalid host row"); + return { ...value, id: value.id }; +} diff --git a/packages/db/test/migrate.test.ts b/packages/db/test/migrate.test.ts index 35a04a1f25..13b46fbb7f 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -409,6 +409,8 @@ const branchLocalThreadSearchRowidFtsMigrationWhen = 1781403656071; const rowidThreadSearchMigrationHash = "025358fe89253aec7f5bd970dc3eb88d0e834f0d58fb9d75329a5d39899340f4"; const legacyExperimentsMigrationWhen = 1781299832942; +const environmentProvisioningMigrationWhen = 1789075667774; +const machineProvidersMigrationWhen = 1789081162875; const eventLargeValuesMigrationWhen = 1781403656069; const eventLargeValuesRestoreMigrationWhen = 1781557200000; const cleanupModeDropMigrationWhen = 1781557300000; @@ -529,6 +531,12 @@ const eventLargeValuesMigrationPath = resolve( "drizzle", "0031_mysterious_zaran.sql", ); +const machineProvidersMigrationPath = resolve( + __dirname, + "..", + "drizzle", + "0117_machine_providers.sql", +); function closeConnection(db: DbConnection): void { db.$client.close(); } @@ -831,11 +839,78 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void { } } +function rewindMachineProvidersMigration(db: DbConnection): void { + db.$client.exec("DROP TABLE IF EXISTS environment_hook_operations"); + if ( + db.$client + .prepare<[], TableInfoRow>("PRAGMA table_info(project_sources)") + .all() + .some((column) => column.name === "owns_path") + ) { + db.$client.exec("ALTER TABLE project_sources DROP COLUMN owns_path"); + } + db.$client.exec("DROP INDEX IF EXISTS hosts_live_launch_key_idx"); + for (const column of [ + "machine_provider_id", + "launch_key", + "machine_inputs", + "machine_attempt", + "pending_log", + "machine_operation_id", + "server_access_provider_id", + "server_access_grant_id", + "resource", + "phase", + "suspended_at", + "status_message", + "suspend_retry_at", + "idle_since", + "remove_retry_at", + "teardown_attempt", + "teardown_status", + ]) { + const columns = db.$client + .prepare<[], TableInfoRow>("PRAGMA table_info(hosts)") + .all(); + if (columns.some((entry) => entry.name === column)) { + db.$client.exec(`ALTER TABLE hosts DROP COLUMN ${column}`); + } + } + const sessionColumns = db.$client + .prepare<[], TableInfoRow>("PRAGMA table_info(host_daemon_sessions)") + .all(); + if (!sessionColumns.some((column) => column.name === "host_type")) { + db.$client + .prepare( + "ALTER TABLE host_daemon_sessions ADD COLUMN host_type text NOT NULL DEFAULT 'persistent'", + ) + .run(); + } + db.$client + .prepare<[number]>("DELETE FROM __drizzle_migrations WHERE created_at >= ?") + .run(machineProvidersMigrationWhen); +} + function rewindEnvironmentProvidersMigration(db: DbConnection): void { + rewindMachineProvidersMigration(db); rewindEnvironmentProvisioningMigration(db); - db.$client.exec("DROP TABLE IF EXISTS environment_hook_operations"); + db.$client.exec("DROP TABLE IF EXISTS machine_workspace_setups"); + db.$client.exec("DROP TABLE IF EXISTS environment_setup_outcomes"); db.$client.exec("DROP TABLE IF EXISTS environment_launches"); db.$client.exec("DROP INDEX IF EXISTS environments_project_host_path_idx"); + const hostColumns = new Set( + db.$client + .prepare<[], TableInfoRow>("PRAGMA table_info(hosts)") + .all() + .map((column) => column.name), + ); + if (!hostColumns.has("type")) { + db.$client + .prepare( + "ALTER TABLE hosts ADD COLUMN type text NOT NULL DEFAULT 'persistent'", + ) + .run(); + } const lifecycleColumns = [ "environment_provider_plugin_id", "canonical_path", @@ -1621,7 +1696,6 @@ describe("migrate", () => { const host = upsertHost(db, noopNotifier, { id: "host-retained-output-migration", name: "Migration Host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "Migration Project", @@ -1656,9 +1730,10 @@ describe("migrate", () => { expect( db.$client - .prepare<[], MigratedEventDataRow>( - "SELECT data FROM events WHERE id = 'evt-retained-output-migration'", - ) + .prepare< + [], + MigratedEventDataRow + >("SELECT data FROM events WHERE id = 'evt-retained-output-migration'") .get(), ).toEqual({ data: eventData }); expect( @@ -1729,9 +1804,10 @@ describe("migrate", () => { expect( db.$client - .prepare<[], { id: string; root: string | null }>( - "SELECT id, git_checkout_root AS root FROM plugin_artifacts ORDER BY id", - ) + .prepare< + [], + { id: string; root: string | null } + >("SELECT id, git_checkout_root AS root FROM plugin_artifacts ORDER BY id") .all(), ).toEqual([ { id: "collision", root: `/cache/repo/${commit}` }, @@ -1859,14 +1935,18 @@ describe("migrate", () => { showDiagnosticEvents: true, providerOrder: [], defaultProviderId: null, + machineServerUrl: null, + defaultMachineAccess: null, + machineGitCredentialsEnabled: true, streamerMode: false, managedBranchPrefix: "bb/", }); expect( db.$client - .prepare<[], { key: string; value: string }>( - "SELECT key, value FROM app_settings_values WHERE key LIKE 'codex%' OR key LIKE 'claudeCode%' ORDER BY key", - ) + .prepare< + [], + { key: string; value: string } + >("SELECT key, value FROM app_settings_values WHERE key LIKE 'codex%' OR key LIKE 'claudeCode%' ORDER BY key") .all(), ).toEqual([ { key: "claudeCodeMemoryEnabled", value: "true" }, @@ -1880,9 +1960,10 @@ describe("migrate", () => { ]); expect( db.$client - .prepare<[], { updatedAt: number }>( - "SELECT updated_at AS updatedAt FROM app_settings_values WHERE key = 'showKeyboardHints'", - ) + .prepare< + [], + { updatedAt: number } + >("SELECT updated_at AS updatedAt FROM app_settings_values WHERE key = 'showKeyboardHints'") .get(), ).toEqual({ updatedAt: 1234 }); } finally { @@ -1928,9 +2009,7 @@ describe("migrate", () => { .prepare< [], { pluginId: string; key: string; value: string; updatedAt: number } - >( - "SELECT plugin_id AS pluginId, key, value, updated_at AS updatedAt FROM plugin_settings ORDER BY plugin_id, key", - ) + >("SELECT plugin_id AS pluginId, key, value, updated_at AS updatedAt FROM plugin_settings ORDER BY plugin_id, key") .all(), ).toEqual([ { @@ -1966,9 +2045,10 @@ describe("migrate", () => { ]); expect( db.$client - .prepare<[], { key: string }>( - "SELECT key FROM app_settings_values ORDER BY key", - ) + .prepare< + [], + { key: string } + >("SELECT key FROM app_settings_values ORDER BY key") .all(), ).toEqual([{ key: "showKeyboardHints" }]); } finally { @@ -2037,9 +2117,10 @@ describe("migrate", () => { expect( db.$client - .prepare<[], { count: number }>( - "SELECT COUNT(*) AS count FROM app_settings_values", - ) + .prepare< + [], + { count: number } + >("SELECT COUNT(*) AS count FROM app_settings_values") .get(), ).toEqual({ count: 0 }); expect(getAppSettings(db)).toEqual(defaultAppSettings); @@ -2115,9 +2196,10 @@ describe("migrate", () => { expect( db.$client - .prepare<[], { count: number }>( - "SELECT COUNT(*) AS count FROM app_settings_values", - ) + .prepare< + [], + { count: number } + >("SELECT COUNT(*) AS count FROM app_settings_values") .get(), ).toEqual({ count: 0 }); expect(getAppSettings(db).steerActiveThreadOnEnter).toBe(true); @@ -2159,9 +2241,10 @@ describe("migrate", () => { expect( db.$client - .prepare<[], { value: string; updatedAt: number }>( - "SELECT value, updated_at AS updatedAt FROM app_settings_values WHERE key = 'steerActiveThreadOnEnter'", - ) + .prepare< + [], + { value: string; updatedAt: number } + >("SELECT value, updated_at AS updatedAt FROM app_settings_values WHERE key = 'steerActiveThreadOnEnter'") .get(), ).toEqual({ value: "true", updatedAt: 1234 }); expect(getAppSettings(db).steerActiveThreadOnEnter).toBe(true); @@ -2177,7 +2260,6 @@ describe("migrate", () => { migrate(db); const host = upsertHost(db, noopNotifier, { name: "side-chat-adoption-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "side-chat-adoption-project", @@ -2222,9 +2304,10 @@ describe("migrate", () => { runMigrationFile({ db, migrationPath: sideChatPluginOnlyMigrationPath }); const rows = db.$client - .prepare<[], MigratedThreadOriginRow>( - "SELECT id, origin_kind AS originKind, origin_plugin_id AS originPluginId, visibility FROM threads", - ) + .prepare< + [], + MigratedThreadOriginRow + >("SELECT id, origin_kind AS originKind, origin_plugin_id AS originPluginId, visibility FROM threads") .all(); const byId = new Map(rows.map((row) => [row.id, row])); @@ -2257,7 +2340,6 @@ describe("migrate", () => { migrate(db); const host = upsertHost(db, noopNotifier, { name: "permission-migration-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "permission-migration-project", @@ -5143,9 +5225,10 @@ describe("migrate", () => { expect(readTableNames(db)).not.toContain("marketplaces"); expect( db.$client - .prepare<[], { source: string }>( - "SELECT source FROM plugins WHERE id = 'third-party'", - ) + .prepare< + [], + { source: string } + >("SELECT source FROM plugins WHERE id = 'third-party'") .get(), ).toEqual({ source: "git:https://example.test/tasks@main" }); } finally { @@ -5215,9 +5298,10 @@ describe("migrate", () => { }); expect( db.$client - .prepare<[], MigrationCountRow>( - "SELECT COUNT(*) AS count FROM plugin_catalog", - ) + .prepare< + [], + MigrationCountRow + >("SELECT COUNT(*) AS count FROM plugin_catalog") .get(), ).toEqual({ count: 0 }); } finally { @@ -5262,16 +5346,18 @@ describe("migrate", () => { expect( db.$client - .prepare<[], { name: string }>( - "SELECT name FROM plugin_marketplaces ORDER BY name", - ) + .prepare< + [], + { name: string } + >("SELECT name FROM plugin_marketplaces ORDER BY name") .all(), ).toEqual([{ name: "acme" }, { name: "bb-community" }]); expect( db.$client - .prepare<[], { marketplaceName: string }>( - "SELECT marketplace_name AS marketplaceName FROM plugin_marketplace_icons ORDER BY marketplace_name", - ) + .prepare< + [], + { marketplaceName: string } + >("SELECT marketplace_name AS marketplaceName FROM plugin_marketplace_icons ORDER BY marketplace_name") .all(), ).toEqual([ { marketplaceName: "acme" }, @@ -5279,9 +5365,10 @@ describe("migrate", () => { ]); expect( db.$client - .prepare<[], { id: string; catalogMarketplaceName: string | null }>( - "SELECT id, catalog_marketplace_name AS catalogMarketplaceName FROM plugins ORDER BY id", - ) + .prepare< + [], + { id: string; catalogMarketplaceName: string | null } + >("SELECT id, catalog_marketplace_name AS catalogMarketplaceName FROM plugins ORDER BY id") .all(), ).toEqual([ { id: "local", catalogMarketplaceName: null }, @@ -5294,9 +5381,7 @@ describe("migrate", () => { .prepare< [], { name: string; etag: string | null; lastModified: string | null } - >( - "SELECT name, etag, last_modified AS lastModified FROM plugin_marketplaces ORDER BY name", - ) + >("SELECT name, etag, last_modified AS lastModified FROM plugin_marketplaces ORDER BY name") .all(), ).toEqual([ { @@ -5407,7 +5492,6 @@ describe("migrate", () => { const host = upsertHost(db, noopNotifier, { id: "host-side-chat-visibility", name: "Migration Host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "Migration Project", @@ -5476,7 +5560,6 @@ describe("migrate", () => { migrate(db); const host = upsertHost(db, noopNotifier, { name: "event-parent-migration-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "event-parent-migration-project", @@ -5592,9 +5675,9 @@ describe("environment providers migration", () => { rewindEnvironmentRowFactsMigration(db); rewindEnvironmentProvidersMigration(db); db.$client - .prepare<[number]>( - "DELETE FROM __drizzle_migrations WHERE created_at >= ?", - ) + .prepare< + [number] + >("DELETE FROM __drizzle_migrations WHERE created_at >= ?") .run(environmentProvidersMigrationWhen); db.$client.exec(` INSERT INTO hosts (id, name, type, created_at, updated_at) @@ -5669,9 +5752,10 @@ describe("environment providers migration", () => { seedPreProviderEnvironments(db); migrate(db); const rows = db.$client - .prepare<[], { provider: string; owner: string | null }>( - "SELECT DISTINCT environment_provider_id AS provider, environment_provider_plugin_id AS owner FROM environments ORDER BY provider", - ) + .prepare< + [], + { provider: string; owner: string | null } + >("SELECT DISTINCT environment_provider_id AS provider, environment_provider_plugin_id AS owner FROM environments ORDER BY provider") .all(); expect(rows).toEqual([ { provider: "git-worktree", owner: "environment-git-worktree" }, @@ -5896,9 +5980,10 @@ describe("environment providers migration", () => { expect( db.$client - .prepare<[], { id: string; status: string }>( - "SELECT id, status FROM environments ORDER BY id", - ) + .prepare< + [], + { id: string; status: string } + >("SELECT id, status FROM environments ORDER BY id") .all(), ).toEqual([ { id: "env_default", status: "ready" }, @@ -5955,12 +6040,55 @@ describe("environment providers migration", () => { }); }); +describe("machine providers migration", () => { + it("backfills server access for machines with a legacy access identity", () => { + const db = createConnection(":memory:"); + try { + db.$client.exec(` + CREATE TABLE hosts ( + id text PRIMARY KEY NOT NULL, + name text NOT NULL, + type text NOT NULL, + connect_machine_id text, + destroyed_at integer + ); + CREATE TABLE project_sources (id text PRIMARY KEY NOT NULL); + CREATE TABLE host_daemon_sessions ( + id text PRIMARY KEY NOT NULL, + host_type text NOT NULL + ); + CREATE TEMP TABLE bb_migration_local_host (id text PRIMARY KEY NOT NULL); + INSERT INTO hosts VALUES + ('legacy', 'Legacy', 'persistent', 'cloud-machine', NULL), + ('direct', 'Direct', 'persistent', NULL, NULL); + `); + + runMigrationFile({ db, migrationPath: machineProvidersMigrationPath }); + + expect( + db.$client + .prepare< + [], + { id: string; providerId: string | null; type: string } + >("SELECT id, server_access_provider_id AS providerId, type FROM hosts ORDER BY id") + .all(), + ).toEqual([ + { id: "direct", providerId: null, type: "persistent" }, + { id: "legacy", providerId: "connect", type: "persistent" }, + ]); + } finally { + closeConnection(db); + } + }); +}); + describe("environment and thread startup ownership migration", () => { it.each(["creating", "cancelled"])( "preserves %s allocation checkpoints and keeps attached environment resources authoritative", (phase) => { const db = createMigratedConnection(); try { + rewindMachineProvidersMigration(db); rewindEnvironmentProvisioningMigration(db); const legacySchema = readFileSync( resolve( @@ -5970,9 +6098,12 @@ describe("environment and thread startup ownership migration", () => { "utf8", ).split("--> statement-breakpoint")[0]!; db.$client.exec(legacySchema); - db.$client.exec( - "DELETE FROM __drizzle_migrations WHERE created_at = (SELECT MAX(created_at) FROM __drizzle_migrations)", - ); + db.$client.exec("DROP TABLE IF EXISTS environment_hook_operations"); + db.$client + .prepare<[number]>( + "DELETE FROM __drizzle_migrations WHERE created_at >= ?", + ) + .run(environmentProvisioningMigrationWhen); db.$client.exec(` INSERT INTO hosts (id, name, type, created_at, updated_at) VALUES ('host_ownership', 'test', 'persistent', 1, 1); INSERT INTO projects (id, name, created_at, updated_at) VALUES ('proj_ownership', 'test', 1, 1); diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index 0e1ba6aa21..12cea56eb6 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -134,7 +134,6 @@ function setup(): TestDb { migrate(db); const host = upsertHost(db, noopNotifier, { name: "query-plan-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "query-plan-project", @@ -678,7 +677,6 @@ describe("slow query index plans", () => { hostId: host.id, instanceId: "closed-prune-query-plan", hostName: "query-plan-host", - hostType: "persistent", dataDir: "/tmp/query-plan-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts index 273a89933d..ef9dd34328 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -225,7 +225,6 @@ describe("db rebuild schema", () => { hostId, instanceId: "instance-1", hostName: "Local host", - hostType: "persistent", dataDir: "/tmp/test-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -512,7 +511,6 @@ describe("db rebuild schema", () => { hostId, instanceId: "instance-1", hostName: "Local host", - hostType: "persistent", dataDir: "/tmp/test-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -770,7 +768,6 @@ describe("db rebuild schema", () => { hostId, instanceId: "instance", hostName: "host", - hostType: "persistent", protocolVersion: 1, heartbeatIntervalMs: 1_000, leaseTimeoutMs: 10_000, diff --git a/packages/domain/src/app-settings.ts b/packages/domain/src/app-settings.ts index 4c6a89125c..d33b306f01 100644 --- a/packages/domain/src/app-settings.ts +++ b/packages/domain/src/app-settings.ts @@ -21,6 +21,20 @@ export const appSettingsSchema = z defaultProviderId: z.string().min(1).nullable(), streamerMode: z.boolean(), managedBranchPrefix: managedBranchPrefixSchema, + machineServerUrl: z + .string() + .url() + .refine((value) => { + const url = new URL(value); + return ( + ["http:", "https:"].includes(url.protocol) && + !url.username && + !url.password + ); + }) + .nullable(), + machineGitCredentialsEnabled: z.boolean(), + defaultMachineAccess: z.string().min(1).nullable(), }) .strict(); export type AppSettings = z.infer; @@ -33,6 +47,9 @@ export const defaultAppSettings: AppSettings = { defaultProviderId: null, streamerMode: false, managedBranchPrefix: DEFAULT_MANAGED_BRANCH_PREFIX, + machineServerUrl: null, + defaultMachineAccess: null, + machineGitCredentialsEnabled: true, }; export const appSettingsUpdateSchema = z.union([ diff --git a/packages/domain/src/environment.ts b/packages/domain/src/environment.ts index 6a65fdb88c..d85a0997f5 100644 --- a/packages/domain/src/environment.ts +++ b/packages/domain/src/environment.ts @@ -1,10 +1,14 @@ import { jsonValueSchema } from "./json-value.js"; import { z } from "zod"; -export const environmentMachineSelectionSchema = z.object({ - type: z.literal("existing"), - hostId: z.string().min(1), -}); +export const environmentMachineSelectionSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("existing"), hostId: z.string().min(1) }), + z.object({ + type: z.literal("new"), + machineProviderId: z.string().min(1), + inputs: jsonValueSchema.nullable(), + }), +]); export type EnvironmentMachineSelection = z.infer< typeof environmentMachineSelectionSchema >; diff --git a/packages/domain/src/host.ts b/packages/domain/src/host.ts index dbc8e07659..03e11cb4ce 100644 --- a/packages/domain/src/host.ts +++ b/packages/domain/src/host.ts @@ -1,18 +1,37 @@ import { z } from "zod"; import { permissionModeSchema } from "./shared-types.js"; -const hostTypeValues = ["persistent"] as const; -export const hostTypeSchema = z.enum(hostTypeValues); -export type HostType = z.infer; - const hostStatusValues = ["connected", "disconnected"] as const; export const hostStatusSchema = z.enum(hostStatusValues); +export const machineLifecycleSchema = z.object({ + phase: z.enum([ + "creating", + "active", + "suspending", + "suspended", + "removing", + "destroyed", + ]), + suspendedAt: z.number().nullable(), + message: z.string().nullable(), + pendingLog: z.string(), + teardown: z + .object({ + status: z.enum(["running", "failed", "removed"]), + attempt: z.number().int().nonnegative(), + }) + .nullable(), +}); +export type MachineLifecycle = z.infer; + export const hostSchema = z.object({ id: z.string(), name: z.string(), - type: hostTypeSchema, + type: z.enum(["persistent", "ephemeral"]), status: hostStatusSchema, + machineProviderId: z.string().nullable(), + lifecycle: machineLifecycleSchema, maxPermissionMode: permissionModeSchema, lastSeenAt: z.number().nullable(), lastRejectedProtocolVersion: z.number().int().positive().nullable(), diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 66306c24f2..0aae32ef8a 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.56"; +export const PLUGIN_SDK_VERSION = "0.4.83"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/domain/src/provider-event.ts b/packages/domain/src/provider-event.ts index b98325c7fe..40348da82e 100644 --- a/packages/domain/src/provider-event.ts +++ b/packages/domain/src/provider-event.ts @@ -705,6 +705,9 @@ const unscopedProviderEventSchema = z.discriminatedUnion("type", [ source: z.union([ z.literal("shell"), z.object({ plugin: z.string() }).strict(), + z + .object({ core: z.enum(["machine-git", "machine-environment"]) }) + .strict(), ]), value: z.union([ z.string(), diff --git a/packages/domain/src/queued-message.ts b/packages/domain/src/queued-message.ts index bd1db14774..079aa7f41c 100644 --- a/packages/domain/src/queued-message.ts +++ b/packages/domain/src/queued-message.ts @@ -31,7 +31,7 @@ import { * follow-ups and steers wait on this: a thread's first message rides the * cold-start command instead. * - `host-offline` — the thread's workspace exists, but the machine it runs on - * has no live daemon session, so nothing can be delivered to it. Distinct + * is disconnected or pausing/resuming, so execution waits for readiness. Distinct * from `provisioning` because the two are cleared by different events and * read differently to a user: a provisioning workspace is being built and * will finish on its own, while an offline host is waiting on a machine that diff --git a/packages/domain/test/environment.test.ts b/packages/domain/test/environment.test.ts index ceb124249c..cdeb6990a1 100644 --- a/packages/domain/test/environment.test.ts +++ b/packages/domain/test/environment.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - environmentProviderSelectionSchema, - resolveEnvironmentMergeBaseBranch, -} from "../src/environment.js"; +import { resolveEnvironmentMergeBaseBranch } from "../src/environment.js"; describe("resolveEnvironmentMergeBaseBranch", () => { it("prefers an explicit merge-base override", () => { @@ -35,27 +32,3 @@ describe("resolveEnvironmentMergeBaseBranch", () => { ).toBe("main"); }); }); - -describe("environment provider machine selection", () => { - it("requires an existing enrolled host in the nested machine selection", () => { - expect( - environmentProviderSelectionSchema.parse({ - machine: { type: "existing", hostId: "host_1" }, - inputs: null, - }), - ).toEqual({ - machine: { type: "existing", hostId: "host_1" }, - inputs: null, - }); - for (const selection of [ - { machine: { type: "new", providerId: "external" }, inputs: null }, - { machine: { type: "existing", hostId: "" }, inputs: null }, - { hostId: "host_1", inputs: null }, - { inputs: null }, - ]) { - expect( - environmentProviderSelectionSchema.safeParse(selection).success, - ).toBe(false); - } - }); -}); diff --git a/packages/domain/test/host.test.ts b/packages/domain/test/host.test.ts new file mode 100644 index 0000000000..70c2aa3881 --- /dev/null +++ b/packages/domain/test/host.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { hostSchema } from "../src/host.js"; + +describe("host contract", () => { + it("exposes persistent and ephemeral host types", () => { + expect(hostSchema.shape.type.options).toEqual(["persistent", "ephemeral"]); + expect(hostSchema.shape.type.safeParse("temporary").success).toBe(false); + }); +}); diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index ce0474b6f4..50bd1c5cfb 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -190,9 +190,13 @@ export const hostDaemonContributedEnvEntrySchema = z z.string(), z.object({ serverPath: z.string().startsWith("/") }).strict(), ]), - source: z.object({ plugin: z.string().min(1) }).strict(), + source: z.union([ + z.object({ plugin: z.string().min(1) }).strict(), + z + .object({ core: z.enum(["machine-git", "machine-environment"]) }) + .strict(), + ]), reason: z.string(), - secret: z.boolean(), }) .strict(); export type HostDaemonContributedEnvEntry = z.infer< @@ -576,6 +580,8 @@ const projectCloneDefaultPathCommandSchema = z const projectCloneCommandSchema = z .object({ type: z.literal("project.clone"), + operationId: z.string().min(1), + contributedEnv: z.array(hostDaemonContributedEnvEntrySchema).default([]), remoteUrl: z.string().min(1), projectSlug: z.string().min(1), targetPath: z.string().min(1).optional(), @@ -600,7 +606,8 @@ const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647; const environmentHookRunCommandSchema = z .object({ type: z.literal("environment.hook.run"), - resumeOnly: z.boolean(), + contributedEnv: z.array(hostDaemonContributedEnvEntrySchema).default([]), + resumeOnly: z.boolean().default(false), operationId: z.string().min(1), path: z.string().min(1), kind: z.enum(["setup", "teardown"]), @@ -618,6 +625,7 @@ const environmentHookCancelCommandSchema = z const pluginHostCallCommandSchema = z .object({ type: z.literal("plugin.host.call"), + contributedEnv: z.array(hostDaemonContributedEnvEntrySchema).default([]), pluginId: z.string().min(1), generation: z.string().min(1), artifact: pluginHostArtifactSchema, diff --git a/packages/host-daemon-contract/src/local-state.ts b/packages/host-daemon-contract/src/local-state.ts index 6308ca8fa9..b187ae3375 100644 --- a/packages/host-daemon-contract/src/local-state.ts +++ b/packages/host-daemon-contract/src/local-state.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { hostTypeSchema } from "@bb/domain"; export const HOST_AUTH_FILE_NAME = "auth.json"; export const HOST_ID_FILE_NAME = "host-id"; @@ -14,18 +13,26 @@ export function normalizeServerUrl(serverUrl: string): string { return url.href.replace(/\/$/u, ""); } -export const hostAuthStateSchema = z +const currentHostAuthStateSchema = z .object({ hostId: z.string().min(1), hostKey: nonEmptyTrimmedStringSchema, - hostType: hostTypeSchema, + }) + .strict(); + +const legacyHostAuthStateSchema = z + .object({ + hostId: z.string().min(1), + hostKey: nonEmptyTrimmedStringSchema, + hostType: z.literal("persistent").optional(), serverUrl: z.unknown().optional(), }) .strict() - .transform(({ hostId, hostKey, hostType }) => ({ - hostId, - hostKey, - hostType, - })); + .transform(({ hostId, hostKey }) => ({ hostId, hostKey })); + +export const hostAuthStateSchema = z.union([ + currentHostAuthStateSchema, + legacyHostAuthStateSchema, +]); export type HostAuthState = z.infer; diff --git a/packages/host-daemon-contract/src/protocol.ts b/packages/host-daemon-contract/src/protocol.ts index bb9309d48c..0f7873cae0 100644 --- a/packages/host-daemon-contract/src/protocol.ts +++ b/packages/host-daemon-contract/src/protocol.ts @@ -1,3 +1,3 @@ -export const HOST_DAEMON_PROTOCOL_VERSION = 200 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 203 as const; export const HOST_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024; diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index 1e97fad6a2..2a7e09ad34 100644 --- a/packages/host-daemon-contract/src/session.ts +++ b/packages/host-daemon-contract/src/session.ts @@ -1,10 +1,10 @@ +import { hostDaemonContributedEnvEntrySchema } from "./commands.js"; import { desktopBrowserChangedSchema } from "./desktop-browser.js"; import type { Hono } from "hono"; import { hc } from "hono/client"; import { discoveredWorkspacePropertiesSchema, ENVIRONMENT_CHANGE_KINDS, - hostTypeSchema, jsonValueSchema, pendingInteractionCreateSchema, pendingInteractionStatusSchema, @@ -94,20 +94,20 @@ const hostDaemonPluginHostGenerationSchema = z }) .strict(); -export const hostDaemonSessionOpenRequestSchema = z.object({ - hostId: z.string().min(1), - instanceId: z.string().min(1), - hostName: z.string().min(1), - hostType: hostTypeSchema, - connectMachineId: z.string().min(1).optional(), - hasMachineCredential: z.boolean(), - platform: hostPlatformSchema, - dataDir: z.string().min(1), - localApiPort: z.number().int().min(1).max(65_535).nullable().default(null), - protocolVersion: z.number().int().positive(), - activeThreads: z.array(hostDaemonActiveThreadSchema), - loadedEnvironments: z.array(hostDaemonLoadedEnvironmentSchema).default([]), -}); +export const hostDaemonSessionOpenRequestSchema = z + .object({ + hostId: z.string().min(1), + instanceId: z.string().min(1), + hostName: z.string().min(1), + hasMachineCredential: z.boolean(), + platform: hostPlatformSchema, + dataDir: z.string().min(1), + localApiPort: z.number().int().min(1).max(65_535).nullable().default(null), + protocolVersion: z.number().int().positive(), + activeThreads: z.array(hostDaemonActiveThreadSchema), + loadedEnvironments: z.array(hostDaemonLoadedEnvironmentSchema).default([]), + }) + .strict(); export type HostDaemonSessionOpenRequest = z.output< typeof hostDaemonSessionOpenRequestSchema >; @@ -116,8 +116,6 @@ export const hostDaemonEnrollRequestSchema = z .object({ hostId: z.string().min(1), hostName: z.string().min(1), - hostType: hostTypeSchema, - connectMachineId: z.string().min(1).optional(), }) .strict(); export type HostDaemonEnrollRequest = z.infer< @@ -511,6 +509,7 @@ const hostDaemonTerminalOpenTargetSchema = z.discriminatedUnion("kind", [ const hostDaemonTerminalOpenMessageSchema = z .object({ type: z.literal("terminal.open"), + contributedEnv: z.array(hostDaemonContributedEnvEntrySchema).default([]), requestId: terminalRequestIdSchema, terminalId: terminalIdSchema, threadId: z.string().min(1).optional(), @@ -575,6 +574,11 @@ const hostDaemonTerminalCloseMessageSchema = z .strict(); export const hostDaemonServerWsMessageSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("machine.shutdown"), + }) + .strict(), z .object({ type: z.literal("session-close"), @@ -605,6 +609,12 @@ const hostDaemonHeartbeatMessageSchema = z }) .strict(); +const hostDaemonMachineShutdownAckMessageSchema = z + .object({ + type: z.literal("machine.shutdown-ack"), + }) + .strict(); + const hostDaemonEnvironmentChangeMessageSchema = hostDaemonEnvironmentChangePayloadSchema .extend({ @@ -715,6 +725,7 @@ const hostDaemonTerminalErrorMessageSchema = z export const hostDaemonDaemonWsMessageSchema = z.union([ desktopBrowserChangedSchema, + hostDaemonMachineShutdownAckMessageSchema, hostDaemonHeartbeatMessageSchema, hostDaemonEnvironmentChangeMessageSchema, hostDaemonEnvironmentMetadataChangeMessageSchema, diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 10d7ea9919..c1e381f6ef 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -994,13 +994,12 @@ const CONTRIBUTED_ENV = [ value: { serverPath: "/plugins/auth-proxy/api" }, source: { plugin: "auth-proxy" }, reason: "Route provider traffic through the plugin", - secret: true, }, ] as const; describe("host-daemon command schemas", () => { it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(200); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(203); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); @@ -1105,11 +1104,9 @@ describe("host-daemon command schemas", () => { hostDaemonEnrollRequestSchema.parse({ hostId: "host_123", hostName: "test-host", - hostType: "persistent", }), ).toMatchObject({ hostId: "host_123", - hostType: "persistent", }); expect( @@ -3052,7 +3049,7 @@ describe("host-daemon session schemas", () => { hostDaemonEnrollRequestSchema.safeParse({ hostId: "host_123", hostName: "test-host", - hostType: "ephemeral", + hostType: "persistent", }).success, ).toBe(false); expect( @@ -3060,7 +3057,7 @@ describe("host-daemon session schemas", () => { hostId: "host_123", instanceId: "instance_1", hostName: "test-host", - hostType: "ephemeral", + hostType: "persistent", hasMachineCredential: true, platform: "linux", dataDir: "/tmp/bb-data", @@ -3076,7 +3073,6 @@ describe("host-daemon session schemas", () => { hostDaemonSessionOpenRequestSchema.parse({ hostId: "host_123", instanceId: "instance_1", - hostType: "persistent", hostName: "Michael's MacBook", hasMachineCredential: true, platform: "darwin", @@ -3091,7 +3087,6 @@ describe("host-daemon session schemas", () => { }), ).toMatchObject({ hostId: "host_123", - hostType: "persistent", hasMachineCredential: true, loadedEnvironments: [], }); @@ -3101,7 +3096,6 @@ describe("host-daemon session schemas", () => { hostId: "host_123", instanceId: "instance_1", hostName: "Michael's MacBook", - hostType: "persistent", hasMachineCredential: false, platform: "darwin", dataDir: "/tmp/bb-data", @@ -3127,7 +3121,6 @@ describe("host-daemon session schemas", () => { hostId: "host_123", instanceId: "instance_1", hostName: "Michael's MacBook", - hostType: "persistent", hasMachineCredential: true, platform: "darwin", dataDir: "/tmp/bb-data", @@ -3146,7 +3139,6 @@ describe("host-daemon session schemas", () => { hostId: "host_123", instanceId: "instance_1", hostName: "Michael's MacBook", - hostType: "persistent", hasMachineCredential: true, platform: "darwin", dataDir: "/tmp/bb-data", @@ -3163,7 +3155,6 @@ describe("host-daemon session schemas", () => { hostId: "host_123", instanceId: "instance_1", hostName: "Michael's MacBook", - hostType: "persistent", hasMachineCredential: true, platform: "darwin", dataDir: "/tmp/bb-data", @@ -3855,6 +3846,7 @@ describe("host-daemon session schemas", () => { expect( hostDaemonServerWsMessageSchema.safeParse({ type: "terminal.open", + contributedEnv: [], requestId: "request-1", terminalId: "term_123", threadId: "thr_123", diff --git a/packages/host-daemon-contract/test/local.test.ts b/packages/host-daemon-contract/test/local.test.ts index 1eae65607c..538ad78aa7 100644 --- a/packages/host-daemon-contract/test/local.test.ts +++ b/packages/host-daemon-contract/test/local.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { HOST_DAEMON_PROTOCOL_VERSION, PATHS_EXIST_MAX_PATHS, + hostAuthStateSchema, hostPlatformSchema, pathsExistRequestSchema, pathsExistResponseSchema, @@ -11,6 +12,20 @@ import { statusResponseSchema, } from "../src/index.js"; +describe("hostAuthStateSchema", () => { + it("persists host identity without a host type", () => { + expect( + hostAuthStateSchema.parse({ + hostId: "host_modal", + hostKey: "secret", + }), + ).toEqual({ + hostId: "host_modal", + hostKey: "secret", + }); + }); +}); + describe("hostPlatformSchema", () => { it("accepts the supported platform values", () => { for (const value of ["darwin", "linux", "wsl", "unknown"] as const) { diff --git a/packages/host-watcher/test/workspace-root-ignores.test.ts b/packages/host-watcher/test/workspace-root-ignores.test.ts index 7117728102..8474c4fb92 100644 --- a/packages/host-watcher/test/workspace-root-ignores.test.ts +++ b/packages/host-watcher/test/workspace-root-ignores.test.ts @@ -212,9 +212,9 @@ describe("workspace root watch events inside nested heavy directories (#1779)", "module.exports={changed:true}\n", ); await fs.writeFile(nestedGitFile, "marker\n"); + await fs.writeFile(visibleFile, "visible\n"); await vi.waitFor( - async () => { - await fs.writeFile(visibleFile, `visible ${Date.now()}\n`); + () => { expect( events.some((event) => event.changedPaths.includes(visibleFile)), ).toBe(true); diff --git a/packages/host-workspace/src/git.ts b/packages/host-workspace/src/git.ts index f543a04555..452e499972 100644 --- a/packages/host-workspace/src/git.ts +++ b/packages/host-workspace/src/git.ts @@ -38,6 +38,7 @@ export interface RunGitOptions extends GitProcessOptions { signal?: AbortSignal; maxBufferBytes?: number; allowTruncatedStdout?: boolean; + onStderr?: (chunk: string) => void; } interface ResolveGitProcessEnvArgs { @@ -273,7 +274,7 @@ export async function runGit( throw createGitCommandCancelledError(args, options.signal.reason); } try { - const result = await execFileAsync("git", args, { + const processOptions = { cwd: options.cwd, encoding: "utf8", env: resolveGitProcessEnv({ @@ -283,7 +284,29 @@ export async function runGit( maxBuffer: options.maxBufferBytes ?? DEFAULT_BUFFER_BYTES, signal: options.signal, timeout: options.timeoutMs, - }); + } as const; + const stderrListener = options.onStderr; + const result = + stderrListener === undefined + ? await execFileAsync("git", args, processOptions) + : await new Promise<{ stdout: string; stderr: string }>( + (resolve, reject) => { + const child = execFile( + "git", + args, + processOptions, + (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + } else resolve({ stdout, stderr }); + }, + ); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", stderrListener); + }, + ); return { stdout: result.stdout, stderr: result.stderr, diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts index 29f317d47f..1c552cf760 100644 --- a/packages/plugin-api-map/src/surfaces.ts +++ b/packages/plugin-api-map/src/surfaces.ts @@ -553,6 +553,8 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "thread-events", "dispatch-hook", "environment-providers", + "machine-providers", + "server-access", "host-workers", ], }, @@ -583,7 +585,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Receive the thread and project it was invoked from, when bb knows them", "Make the plugin usable from scripts and automations, not only from the UI", ], - apiSymbols: ["PluginCli"], + apiSymbols: ["PluginCli", "PluginCliResult"], firstParty: [ "Automations", "Custom instructions", @@ -687,6 +689,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Subscribe to threads being created, going active or idle, failing, being archived or unarchived, or being deleted", "Subscribe to messages being queued behind a wait, dispatching when it clears, or being cancelled before dispatch", "Subscribe when a thread receives a pending interaction", + "Observe debounced experimental_thread.events notifications with the latest sequence and current thread, or experimental_terminal.input without keystroke contents", "Subscribe to a turn failing, with the provider's error and rate-limit windows attached", "Respond by sending a notification, asking for a retry, or writing to its own storage", ], @@ -738,6 +741,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Declare what it needs from the request as a zod inputs schema; bb parses the request with it before the thread exists, publishes it as JSON Schema for the CLI, and hands create the parsed value as inputs", "Validate a resolved selection once before thread creation; host-dependent preflight requires connectivity, and create checks conditions that can change afterward", "Read the facts as typed values on the create context: host is always non-null, while projectCheckout and gitRemote are non-null exactly when required", + "Read projectCheckout.experimental_ownsPath to distinguish core clones from user-maintained attachments; core runs environment hooks for owned paths", "Render its own control for those inputs beside the picked provider with app.slots.experimental_environmentProviderInputs, reporting either ready inputs or a blocked reason", "Use experimental_BranchPicker for a standard branch choice, or compose experimental_useBranches with experimental_useCheckoutState when it needs checkout-aware branch selection", "Run one idempotent long create call that returns a created directory or failure; a failed create is terminal and an explicit retry starts a new attempt on the same environment; provider policy exposes only retirement grace and path-key strategy", @@ -747,6 +751,8 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Name a branch the way bb would, from the suggestedBranchName core hands every create, and stream progress with report.step and report.log", "Honor create and remove abort signals; core aborts create before asking remove to clean everything under the same path key", "Work on the existing enrolled machine carried by the selection, returning the path it produced", + "Environment input controls receive target: { kind: 'existing-host', hostId } or { kind: 'new-host' }; compositions reuse the underlying control before provisioning, and backend create receives the real host", + "Register a composition with machineProviderId and environmentProviderId instead of lifecycle callbacks; core creates the machine and uses the concrete environment provider, preserving its checkout ownership", "Return an opaque JSON resource handle from a created launch; core keeps up to 16 KiB private and supplies it only to recovery and removal callbacks from the recorded owning plugin", ], apiSymbols: [ @@ -780,6 +786,101 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ firstParty: ["Project checkout", "Personal workspace", "Worktree"], experimental: true, }, + { + id: "machine-providers", + tagline: "Create and own execution machines", + title: "Machine providers", + summary: + "Adds plugin-provisioned machines that compose with environment providers. With this, a plugin can:", + bullets: [ + "Register bb.experimental_machines with a display name, one-line description, and required glyph, plugin-relative SVG, declared icon, or React icon", + "Show the provider display name and icon as the kind next to every machine it creates; manually enrolled machines have no kind", + "Declare ephemeral machines, Standard Schema inputs, availability and validation; core parses and persists inputs and checks availability before create", + "Keep secrets in plugin settings because persisted machine inputs are readable by every plugin; pass only non-secret configuration or references", + "Register an environment composition with machineProviderId and environmentProviderId to create a machine and then use a concrete environment provider; machine registration alone adds no picker option; CLI selects the composition with --environment-provider", + "Create machines that belong to no project; projects reach a machine later through project sources", + "Make create idempotent by its host launch key so a restart after enrolment recovers the same machine", + "Bootstrap over a MachineExecutor, or omit it for a manual connection; never put credentials in resource JSON or output", + "Install a daemon for pending enrollment and restart an enrolled identity after snapshot restore", + "Stream progress on the creating host and honor abort signals for create, suspend, resume and remove", + "Keep credentials out of report.step and report.log: core persists progress and copies it into thread transcripts; core exposes manual enrollment commands transiently by host ID", + "Await create.checkpoint(resource) immediately after allocation so cancellation can remove it without waiting for bootstrap", + "Implement reconcileCleanup to discover and remove uncertain allocations from the durable key and last checkpointed resource, never create or bootstrap; return failed while allocation intent is unresolved so core retries on its cleanup interval", + "Await suspend.checkpoint(resource) before destructive cleanup", + "Await resume.checkpoint(resource) before bootstrap; core fences provider ownership, phase and operation and recovers the same enrollment after restart", + "Allocation checkpoints are recovery records, not filesystem saves; daemon-connected does not mean agent-ready", + "Read the current persisted machine resource by host ID with bb.experimental_machines.getResource; reads work across plugins and return null for absent hosts or resources", + "Own idle timing in the plugin using thread-sequence and terminal-input events plus background schedules", + "Render one compact machine-inputs control in composed thread creation with app.slots.experimental_machineProviderInputs, reporting a ready non-secret JSON value on mount or a one-sentence blocked reason", + + "Request suspend/resume through the host SDK; calls return the updated host when the tracked operation starts, core coordinates drain, persisted live thread launches, provisioning environments, and project checkout setup reject suspend with machine_busy, and plugins own idle policy", + "Read maintenance state and lifecycle failures from each host's lifecycle phase and message", + "Await suspend.checkpoint(resource) to persist opaque resource state before termination; schedule vendor maintenance in the plugin using bb.background.schedule and bb.sdk.hosts.experimental_suspend", + "Optionally declare suspend and resume together; plugins own idle timing and core coordinates transitions", + "Return an opaque JSON resource that core persists and passes back to lifecycle operations; never include credentials", + "Return a readable machine name from create; core supplies a provider-name fallback for older plugins", + "Treat a failed create as terminal and retry vendor API hiccups inside the create call", + ], + apiSymbols: [ + "PluginMachines", + "HostsArea.experimental_create", + "HostsArea.experimental_getEnrollmentCommand", + "HostsArea.experimental_listProviders", + "HostsArea.experimental_suspend", + "HostsArea.experimental_resume", + "HostsArea.experimental_retryCleanup", + "PluginMachines.getResource", + "MachineExecutorRequest", + "MachineExecutor", + "MachineBootstrapRequest", + "MachineBootstrapApi", + "PluginMachineProviderDeclaration", + "PluginMachineValidateDecision", + "PluginMachineProviderInputsRegistration", + "PluginMachineProviderInputsProps", + "PluginMachineProviderInputsChange", + "PluginMachineProviderDefinition", + "PluginMachineProviderInputsSchema", + "PluginMachineProviderAvailability", + "PluginMachineProviderValidateContext", + "PluginMachineProviderCreateContext", + "PluginMachineProviderCreateResult", + "PluginMachineProviderLifecycleContext", + "PluginMachineProviderProgress", + "PluginMachineProviderResourceResult", + "PluginMachineProviderRemoveResult", + ], + firstParty: [ + "Modal sandbox", + "DigitalOcean", + "SSH machine", + "Tailscale", + ], + experimental: true, + }, + { + id: "server-access", + tagline: "Connect machines to their server", + title: "Machine server access", + summary: + "Registers server access for enrolment and ongoing machine runtime requests. With this, a plugin can:", + bullets: [ + "Register bb.experimental_serverAccess with picker copy, availability (including an optional public serverUrl), idempotent acquire and release", + "Call recheck when access is gained or lost so Machines settings stops showing a stale availability", + "Return { id, serverUrl, headers? }; machines attach headers to all server requests without provider-specific redemption", + "Choose a General default or override access for a machine; automatic selection uses the first registered provider, or direct when none are registered", + "Use the Server URL reachable by machines setting or BB_EXTERNAL_URL fallback; the URL is not a reachability guarantee", + "Return { status: 'failed', message } for a user-safe recovery message; persist acquisition intent and keep credentials in secret storage; release receives key, hostId and a nullable grantId to reconcile interrupted acquisitions before enrollment", + ], + apiSymbols: [ + "PluginServerAccess", + "ServerAccessProviderDeclaration", + "ServerAccessGrant", + "ServerAccessSelection", + ], + firstParty: ["Connect", "Tailscale"], + experimental: true, + }, { id: "host-workers", tagline: "Run code on enrolled machines", @@ -818,6 +919,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Stores the plugin's data on the bb server. With this, a plugin can:", bullets: [ "Get a key-value store for small values such as flags and cursors", + "Store internal credentials in plugin KV without exposing settings fields", "Get its own SQLite database, with migrations, for larger or relational data", "Reject a changed or reused migration number before it can hide a schema change", "Read and write only its own namespace; other plugins cannot see it", @@ -844,7 +946,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Calls bb's own API from the plugin's server code. With this, a plugin can:", bullets: [ "Create threads, send messages to them, and manage projects", - "List enrolled machines", + "List machines and suspend, resume, or remove provider-managed machines", "Reach the same operations the [bb CLI](cli) and the bb UI use", "Have the threads it creates attributed back to the plugin", "Read the server's loopback URL, public app URL, and data directory when it needs server facts", diff --git a/packages/plugin-api-map/test/surfaces.test.ts b/packages/plugin-api-map/test/surfaces.test.ts index 918bbea6c5..ad5ba70585 100644 --- a/packages/plugin-api-map/test/surfaces.test.ts +++ b/packages/plugin-api-map/test/surfaces.test.ts @@ -208,6 +208,47 @@ describe("surface card copy", () => { expect(eventCopy).toContain("cancelled before dispatch"); }); + it("documents durable machine lifecycle checkpoints", () => { + const machineProviders = SURFACES_BY_ID.get("machine-providers"); + expect(machineProviders?.apiSymbols).toContain( + "PluginMachineProviderLifecycleContext", + ); + expect(machineProviders?.bullets.join(" ")).toContain( + "Await suspend.checkpoint(resource) before destructive cleanup", + ); + expect(machineProviders?.bullets.join(" ")).toContain( + "host's lifecycle phase and message", + ); + }); + + it("maps bootstrap and checkpointed allocation to the machine surface", () => { + const machines = SURFACES_BY_ID.get("machine-providers"); + expect(machines?.apiSymbols).toEqual( + expect.arrayContaining([ + "MachineExecutorRequest", + "MachineExecutor", + "MachineBootstrapRequest", + "MachineBootstrapApi", + "PluginMachineProviderCreateContext", + "PluginMachineProviderInputsProps", + "PluginMachineProviderInputsChange", + "PluginMachineProviderInputsRegistration", + ]), + ); + expect(machines?.bullets.join(" ")).toContain( + "Await create.checkpoint(resource)", + ); + expect(machines?.bullets.join(" ")).toContain("--environment-provider"); + expect(SURFACES_BY_ID.get("server-access")?.apiSymbols).toEqual( + expect.arrayContaining([ + "PluginServerAccess", + "ServerAccessProviderDeclaration", + "ServerAccessGrant", + "ServerAccessSelection", + ]), + ); + }); + it("follows the lead-then-bullets template", () => { for (const group of SURFACE_GROUPS) { for (const surface of group.surfaces) { diff --git a/packages/plugin-build/src/builtin-server-artifacts.test.ts b/packages/plugin-build/src/builtin-server-artifacts.test.ts index 2409ee78fc..5191e1c903 100644 --- a/packages/plugin-build/src/builtin-server-artifacts.test.ts +++ b/packages/plugin-build/src/builtin-server-artifacts.test.ts @@ -73,15 +73,18 @@ describe("builtin server artifacts", () => { { pluginDir: "environment-project-checkout" }, { pluginDir: "environment-git-worktree" }, { pluginDir: "environment-personal-workspace" }, + { pluginDir: "environment-modal-sandbox" }, ])( "inlines the environment-provider runtime into the $pluginDir server entry", async ({ pluginDir }) => { const root = await mkdtemp(join(repositoryRoot, ".builtin-server-test-")); tempDirs.push(root); const source = join(repositoryRoot, "plugins", pluginDir); + const fileNames = (await readdir(source)).filter( (fileName) => fileName === "package.json" || + fileName === "Dockerfile" || fileName.endsWith(".ts") || fileName.endsWith(".svg"), ); @@ -98,6 +101,18 @@ describe("builtin server artifacts", () => { ); const built = await buildPluginServer(root, "0.9.0-test", toolchain); + if (pluginDir === "environment-modal-sandbox") { + await cp(join(source, "scripts"), join(root, "scripts"), { + recursive: true, + }); + await import( + pathToFileURL(join(root, "scripts/stage-assets.mjs")).href + ); + expect(await readFile(join(root, "dist/Dockerfile"), "utf8")).toEqual( + await readFile(join(source, "Dockerfile"), "utf8"), + ); + } + const bundle = await readFile(built.jsPath, "utf8"); expect( bundledSdkSpecifiers(bundle).filter( diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 54bd05ebfb..ac4ef4d9ed 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -9,18 +9,6 @@ The authoritative contracts are the exported declarations in [`src/app-contract.ts`](src/app-contract.ts). Keep author-facing guidance in the built-in `bb-plugin-authoring` skill synchronized with those declarations. -## Environment providers - -`bb.experimental_environments.register` lets plugins create and remove thread -workspaces on enrolled machines. The type-only `./environment-provider` entry -contains the resource-operation contract. Core owns durable launches, -cancellation, retries, retirement, and teardown. Selections persist non-secret -inputs alongside `machine: { type: "existing", hostId }`. - -The bundled Project checkout, Worktree, and Personal workspace plugins are the -reference implementations. See the Plugin Guide for registration, availability, -validation, lifecycle policy, and app inputs controls. - ## Composer customization Composer UI extensions register through `app.composer.customize(...)`. A diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 8324978216..c8a4214999 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.56", + "version": "0.4.83", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" @@ -56,6 +56,12 @@ "import": "./dist/environment-provider.js", "default": "./dist/environment-provider.js" }, + "./machine-provider": { + "source": "./src/machine-provider.ts", + "types": "./bundled-types/bb-plugin-sdk-machine-provider.d.ts", + "import": "./dist/machine-provider.js", + "default": "./dist/machine-provider.js" + }, "./app": { "source": "./src/app.ts", "types": "./bundled-types/bb-plugin-sdk-app.d.ts", diff --git a/packages/plugin-sdk/scripts/build-bundled-dts.mjs b/packages/plugin-sdk/scripts/build-bundled-dts.mjs index 84e6a30d67..134c8285b1 100644 --- a/packages/plugin-sdk/scripts/build-bundled-dts.mjs +++ b/packages/plugin-sdk/scripts/build-bundled-dts.mjs @@ -72,6 +72,10 @@ const outputs = { pkgRoot, "src/environment-provider.ts", ), + "bb-plugin-sdk-machine-provider.d.ts": path.join( + pkgRoot, + "src/machine-provider.ts", + ), "bb-plugin-sdk-internal-composer-customization-validation.d.ts": path.join( pkgRoot, "src/internal/composer-customization-validation.ts", diff --git a/packages/plugin-sdk/scripts/build-runtime.mjs b/packages/plugin-sdk/scripts/build-runtime.mjs index 6a77630064..fab3610e75 100644 --- a/packages/plugin-sdk/scripts/build-runtime.mjs +++ b/packages/plugin-sdk/scripts/build-runtime.mjs @@ -77,6 +77,11 @@ const entries = [ output: "dist/environment-provider.js", external: ["zod", "zod/*"], }, + { + source: "src/machine-provider.ts", + output: "dist/machine-provider.js", + external: ["zod", "zod/*"], + }, { source: "src/internal/composer-customization-validation.ts", output: "dist/internal/composer-customization-validation.js", diff --git a/packages/plugin-sdk/src/__tests__/package-exports.test.ts b/packages/plugin-sdk/src/__tests__/package-exports.test.ts index 4231b3fee6..9914ee5948 100644 --- a/packages/plugin-sdk/src/__tests__/package-exports.test.ts +++ b/packages/plugin-sdk/src/__tests__/package-exports.test.ts @@ -28,6 +28,7 @@ describe("packed plugin SDK exports", () => { "./provider-bridge/testing", "./provider-bridge/acp", "./environment-provider", + "./machine-provider", "./app", "./host", "./internal/composer-customization-validation", diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts index ceb61c3996..1448636af1 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -10,6 +10,8 @@ type ExpectedBbPluginApiKey = | "experimental_aiServices" | "experimental_environments" | "experimental_hooks" + | "experimental_machines" + | "experimental_serverAccess" | "hosts" | "http" | "log" @@ -77,6 +79,9 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginInteractionResult", "PluginKvStorage", "PluginLogger", + "PluginMachineProviderDeclaration", + "PluginMachineValidateDecision", + "PluginMachines", "PluginMentionItem", "PluginMentionProviderRegistration", "PluginMentionSearchContext", @@ -117,6 +122,10 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginThreadEventPayloads", "PluginTurnFailedEvent", "PluginUi", + "PluginServerAccess", + "ServerAccessGrant", + "ServerAccessProviderDeclaration", + "ServerAccessSelection", ] as const; const EXPECTED_BACKEND_ROOT_VALUE_EXPORTS = [ diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index ad217a9952..f0b9ae50e6 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1422,10 +1422,8 @@ export interface PluginTimelineRendererRegistration { export interface PluginEnvironmentProviderInputsProps { /** Project selected in the composer; null in projectless compose. */ projectId: string | null; - /** - * The enrolled machine the selection names; null before one is picked. - */ - hostId: string | null; + /** Whether setup uses an existing host or provisions a new host before create. */ + target: { kind: "existing-host"; hostId: string } | { kind: "new-host" }; /** * The `inputs` value the selection will carry: null until `onChange` * supplies one. @@ -1459,6 +1457,33 @@ export interface PluginEnvironmentProviderInputsRegistration { component: ComponentType; } +/** + * Props passed to an `experimental_machineProviderInputs` component. Machine + * inputs are persisted and readable by every plugin, so they must contain only + * non-secret configuration and references to credentials held in plugin + * settings. + */ +export interface PluginMachineProviderInputsProps { + /** The value persisted with the machine selection. */ + value: JsonValue | null; + /** Replace the submitted value or block submission with a visible reason. */ + onChange(next: PluginMachineProviderInputsChange): void; +} + +export type PluginMachineProviderInputsChange = + | { status: "ready"; value: JsonValue } + | { status: "blocked"; reason: string }; + +/** + * Supply the inputs control for one machine provider registered server-side + * through `bb.experimental_machines.register`. + */ +export interface PluginMachineProviderInputsRegistration { + /** The machine provider id this control supplies inputs for. */ + machineProviderId: string; + component: ComponentType; +} + // --------------------------------------------------------------------------- // definePluginApp // --------------------------------------------------------------------------- @@ -1535,7 +1560,7 @@ export interface PluginAppSlots { registration: PluginCommandPaletteActionRegistration, ): void; /** - * Draw one agent or environment provider's icon with an inline + * Draw one agent, environment, or machine provider's icon with an inline * React component instead of its ``-rendered logo file (see * {@link PluginProviderIconRegistration}). Experimental: see * docs/api_to_audit.md. @@ -1559,6 +1584,14 @@ export interface PluginAppSlots { experimental_environmentProviderInputs( registration: PluginEnvironmentProviderInputsRegistration, ): void; + /** + * Supply the non-secret machine inputs control rendered by machine creation + * surfaces (see {@link PluginMachineProviderInputsRegistration}). + * Experimental: see docs/api_to_audit.md. + */ + experimental_machineProviderInputs( + registration: PluginMachineProviderInputsRegistration, + ): void; } export interface PluginAppComposer { diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index 4c20998762..33bf5dedd8 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -1,3 +1,4 @@ +import type { MachineBootstrapApi } from "./machine-bootstrap.js"; import type Database from "better-sqlite3"; import type { Context } from "hono"; import type * as z from "zod"; @@ -24,6 +25,7 @@ import type { StartedOnBehalfOf, ThreadCreateOrigin, ThreadResponse, + TerminalSession, } from "@bb/server-contract"; import type { JsonValue } from "./json-value.js"; import type { @@ -258,6 +260,10 @@ export interface PluginTurnFailedEvent { * queued row GET /threads/:id/queued-messages serves. */ export interface PluginThreadEventPayloads { + /** Debounced per thread (at most once per second), with the latest sequence and current thread DTO. Reading history does not emit this event. */ + "experimental_thread.events": { thread: ThreadResponse; sequence: number }; + /** Real accepted terminal input; excludes output, keepalives and input contents. */ + "experimental_terminal.input": { terminal: TerminalSession }; /** Fired after a thread row is created. */ "thread.created": { thread: ThreadResponse }; /** Fired when a thread transitions into `active`. */ @@ -398,7 +404,17 @@ export interface PluginEnvironments { import("./environment-provider.js").PluginEnvironmentProviderInputsSchema = undefined, >( - declaration: PluginEnvironmentProviderDeclaration, + declaration: + | PluginEnvironmentProviderDeclaration + | { + id: string; + displayName: string; + icon?: string; + machineProviderId: string; + environmentProviderId: string; + create?: never; + remove?: never; + }, ): void; /** * Ask core to re-ask this plugin's waiting providers now instead of at their @@ -408,6 +424,76 @@ export interface PluginEnvironments { recheck(): Promise; } +export type PluginMachineValidateDecision = + | { action: "accept" } + | { action: "refuse"; message: string }; + +export type PluginMachineProviderDeclaration< + Inputs extends + import("./machine-provider.js").PluginMachineProviderInputsSchema = + import("./machine-provider.js").PluginMachineProviderInputsSchema, +> = import("./machine-provider.js").PluginMachineProviderDefinition; + +export interface ServerAccessGrant { + id: string; + serverUrl: string; + headers?: Record; +} + +export interface ServerAccessSelection { + providerId: string; +} + +export interface ServerAccessProviderDeclaration { + id: string; + displayName: string; + description: string; + availability(): + | (import("./machine-provider.js").PluginMachineProviderAvailability & { + serverUrl?: string; + }) + | Promise< + import("./machine-provider.js").PluginMachineProviderAvailability & { + serverUrl?: string; + } + >; + acquire(context: { + key: string; + hostId: string; + signal: AbortSignal; + }): Promise; + release(context: { + key: string; + hostId: string; + /** Null when acquisition was interrupted before a grant was returned. Reconcile using key and hostId. */ + grantId: string | null; + }): Promise; +} + +export interface PluginServerAccess { + register(declaration: ServerAccessProviderDeclaration): void; + /** + * Ask core to re-read this plugin's provider availability now instead of + * waiting for the next request that happens to load system configuration — + * call it whenever access is gained or lost (paired, unpaired, credential + * revoked). Core re-invokes `availability` and tells connected clients, so + * Machines settings stops showing a stale state. + */ + recheck(): void; +} + +export interface PluginMachines extends MachineBootstrapApi { + /** Read core’s current persisted provider resource, or null when the host or resource is absent. Available across plugins; resources must not contain credentials. */ + getResource(hostId: string): Promise; + register< + const Inputs extends + import("./machine-provider.js").PluginMachineProviderInputsSchema = + undefined, + >( + declaration: PluginMachineProviderDeclaration, + ): void; +} + /** * Where a thread is going to run, as far as core knows at the checkpoint. * Before provisioning attaches an environment this is the start intent the @@ -419,7 +505,13 @@ export type PluginDispatchEnvironmentIntent = | { kind: "provider"; environmentProviderId: string; - machine: { type: "existing"; hostId: string }; + machine: + | { type: "existing"; hostId: string } + | { + type: "new"; + machineProviderId: string; + inputs: JsonValue | null; + }; inputs: JsonValue | null; }; @@ -1524,7 +1616,6 @@ export interface ExperimentalPluginProviderEnvEntry { name: string; value: string | { serverPath: string }; reason: string; - secret: boolean; } export interface ExperimentalPluginProviderEnvHealthContext { @@ -1794,6 +1885,9 @@ export interface BbPluginApi { * docs/api_to_audit.md. */ readonly experimental_environments: PluginEnvironments; + /** Machine providers provision execution machines. Experimental: see docs/api_to_audit.md. */ + readonly experimental_machines: PluginMachines; + readonly experimental_serverAccess: PluginServerAccess; /** Plugin-reported status (needs-configuration). */ readonly status: PluginStatusApi; /** Read-only facts about the running server (loopback base URL). */ diff --git a/packages/plugin-sdk/src/environment-provider.ts b/packages/plugin-sdk/src/environment-provider.ts index 241eb1e83e..e3750aa766 100644 --- a/packages/plugin-sdk/src/environment-provider.ts +++ b/packages/plugin-sdk/src/environment-provider.ts @@ -14,8 +14,8 @@ export type PluginEnvironmentProviderInputsSchema = type Fact = R extends Record ? T : T | null; type Checkout = R extends { projectCheckout: true } | { gitCheckout: true } - ? { path: string } - : { path: string } | null; + ? { path: string; experimental_ownsPath?: boolean } + : { path: string; experimental_ownsPath?: boolean } | null; type InputsValue = S extends StandardSchemaV1 ? StandardSchemaV1InferOutput : null; diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index fc6baecfe2..064a525dbc 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -19,3 +19,5 @@ export type { ExperimentalDesktopBrowserCreateInput, ExperimentalDesktopBrowserAcquireInput, } from "@bb/sdk"; + +export type * from "./machine-bootstrap.js"; diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index 3eafffb11c..fd68dccb7b 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -24,6 +24,7 @@ import type { PluginHookHandler, PluginHookName, PluginMentionTrigger, + PluginMachineProviderDeclaration, PluginProviderCapabilities, PluginProviderComposerAction, PluginProviderDeclaration, @@ -108,7 +109,6 @@ const pluginProviderEnvEntrySchema = z z.object({ serverPath: z.string().startsWith("/") }).strict(), ]), reason: z.string(), - secret: z.boolean(), }) .strict(); @@ -2244,6 +2244,23 @@ export type NormalizedPluginEnvironmentProviderRequirements = { [K in (typeof ENVIRONMENT_PROVIDER_REQUIREMENT_NAMES)[number]]: boolean; }; +export const environmentCompositionSchema = z + .object({ + id: z.string().regex(ENVIRONMENT_PROVIDER_ID_PATTERN), + displayName: z + .string() + .trim() + .min(1) + .max(ENVIRONMENT_PROVIDER_DISPLAY_NAME_MAX_CHARS), + icon: z.string().min(1).optional(), + machineProviderId: z.string().regex(ENVIRONMENT_PROVIDER_ID_PATTERN), + environmentProviderId: z.string().regex(ENVIRONMENT_PROVIDER_ID_PATTERN), + }) + .strict(); +export type NormalizedPluginEnvironmentComposition = z.infer< + typeof environmentCompositionSchema +>; + export interface NormalizedPluginEnvironmentProvider { id: string; displayName: string; @@ -2419,4 +2436,142 @@ const environmentProviderPolicySchema = z }) .strict(); -export const MACHINE_PROVIDER_REQUIREMENT_NAMES = ["gitRemote"] as const; +export const MACHINE_PROVIDER_DESCRIPTION_MAX_CHARS = 200; + +export interface NormalizedPluginMachineProvider { + id: string; + displayName: string; + description: string; + icon: string; + ephemeral: boolean; + inputs: StandardSchemaV1 | null; + inputsJsonSchema: JsonValue | null; + availability: NonNullable< + PluginMachineProviderDeclaration["availability"] + > | null; + validate: NonNullable | null; + reconcileCleanup: PluginMachineProviderDeclaration["reconcileCleanup"]; + create: PluginMachineProviderDeclaration["create"]; + suspend: NonNullable | null; + resume: NonNullable | null; + remove: PluginMachineProviderDeclaration["remove"]; +} + +export function validatePluginMachineProviderDeclaration( + declaration: PluginMachineProviderDeclaration, +): NormalizedPluginMachineProvider { + if (typeof declaration !== "object" || declaration === null) { + throw new Error("machine provider declaration must be an object"); + } + const id = declaration.id; + if (typeof id !== "string" || !ENVIRONMENT_PROVIDER_ID_PATTERN.test(id)) { + throw new Error( + `invalid machine provider id ${JSON.stringify(id)} — use 2-64 lowercase letters, digits, or "-", starting with a letter or digit`, + ); + } + const displayName = + typeof declaration.displayName === "string" + ? declaration.displayName.trim() + : ""; + if ( + displayName.length === 0 || + displayName.length > ENVIRONMENT_PROVIDER_DISPLAY_NAME_MAX_CHARS + ) { + throw new Error( + `machine provider "${id}" needs a displayName of 1-${ENVIRONMENT_PROVIDER_DISPLAY_NAME_MAX_CHARS} characters`, + ); + } + const description = z + .string() + .trim() + .min(1) + .max(MACHINE_PROVIDER_DESCRIPTION_MAX_CHARS) + .parse(declaration.description); + const icon = z.string().trim().min(1).parse(declaration.icon); + if (isPluginOwnedIconPath(icon)) { + validateProviderRelativePath(icon, `"${id}" icon`); + } else if (!isNamespacedGlyph(icon) && /[/\\]/u.test(icon)) { + throw new Error( + `machine provider "${id}" icon must be a glyph, declared icon, or plugin-relative path`, + ); + } + const ephemeral = z.boolean().default(false).parse(declaration.ephemeral); + const inputs = normalizeMachineProviderInputs(id, declaration); + if ( + typeof declaration.create !== "function" || + typeof declaration.reconcileCleanup !== "function" || + typeof declaration.remove !== "function" + ) { + throw new Error( + `machine provider "${id}" must declare create, reconcileCleanup and remove functions`, + ); + } + const hasSuspend = typeof declaration.suspend === "function"; + const hasResume = typeof declaration.resume === "function"; + if (hasSuspend !== hasResume) { + throw new Error( + `machine provider "${id}" must declare suspend and resume together`, + ); + } + if ( + declaration.validate !== undefined && + typeof declaration.validate !== "function" + ) { + throw new Error( + `machine provider "${id}" declares a validate that is not a function`, + ); + } + if ( + declaration.availability !== undefined && + typeof declaration.availability !== "function" + ) { + throw new Error( + `machine provider "${id}" declares availability that is not a function`, + ); + } + + return { + id, + displayName, + description, + icon, + ephemeral, + inputs: inputs === null ? null : inputs.schema, + inputsJsonSchema: inputs === null ? null : inputs.jsonSchema, + availability: declaration.availability ?? null, + validate: declaration.validate ?? null, + reconcileCleanup: declaration.reconcileCleanup, + create: declaration.create, + suspend: declaration.suspend ?? null, + resume: declaration.resume ?? null, + remove: declaration.remove, + }; +} + +function normalizeMachineProviderInputs( + id: string, + declaration: PluginMachineProviderDeclaration, +): { schema: StandardSchemaV1; jsonSchema: JsonValue } | null { + const inputs = declaration.inputs; + if (inputs === undefined) return null; + if (!isStandardSchema(inputs)) { + throw new Error( + `machine provider "${id}" declares an inputs that is not a Standard Schema v1 validator`, + ); + } + let converted: unknown; + try { + converted = JSON.parse(JSON.stringify(standardSchemaToJsonSchema(inputs))); + } catch (error) { + throw new Error( + `machine provider "${id}" declares an inputs validator that cannot be published as JSON Schema (${error instanceof Error ? error.message : String(error)}) — declare it with zod 4 or a validator exposing toJSONSchema()`, + ); + } + const jsonSchema = jsonValueSchema.safeParse(converted); + if (!jsonSchema.success) { + throw new Error( + `machine provider "${id}" declares an inputs schema whose JSON Schema is not JSON-serializable`, + ); + } + return { schema: inputs, jsonSchema: jsonSchema.data }; +} diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 99b1886426..0c0c24854e 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -11,6 +11,7 @@ import type { PluginContentScriptRegistration, PluginDiffRendererRegistration, PluginEnvironmentProviderInputsRegistration, + PluginMachineProviderInputsRegistration, PluginFileOpenerRegistration, PluginHomepageSectionRegistration, PluginCommandPaletteActionRegistration, @@ -305,6 +306,7 @@ export interface CollectedPluginAppRegistrations { providerIcons: PluginProviderIconRegistration[]; timelineRenderers: PluginTimelineRendererRegistration[]; environmentProviderInputs: PluginEnvironmentProviderInputsRegistration[]; + machineProviderInputs: PluginMachineProviderInputsRegistration[]; contentScripts: PluginContentScriptRegistration[]; } @@ -354,6 +356,7 @@ export function collectPluginAppRegistrations( providerIcons: [], timelineRenderers: [], environmentProviderInputs: [], + machineProviderInputs: [], contentScripts: [], }; sidebarFooterItemsByRegistrationSet.set(collected, sidebarFooterItems); @@ -379,6 +382,7 @@ export function collectPluginAppRegistrations( providerIcon: new Set(), timelineRenderer: new Set(), environmentProviderInputs: new Set(), + machineProviderInputs: new Set(), contentScript: new Set(), }; @@ -802,6 +806,18 @@ export function collectPluginAppRegistrations( component: requireComponent(kind, registration.component), }); }, + experimental_machineProviderInputs(registration) { + const kind = "slots.experimental_machineProviderInputs"; + const machineProviderId = requireProviderId( + kind, + registration?.machineProviderId, + ); + requireUniqueId(kind, seenIds.machineProviderInputs, machineProviderId); + collected.machineProviderInputs.push({ + machineProviderId, + component: requireComponent(kind, registration.component), + }); + }, }, experimental_sidebarFooter: new SidebarFooterCollector( collected.experimentalSidebarFooterItems, diff --git a/packages/plugin-sdk/src/machine-bootstrap.ts b/packages/plugin-sdk/src/machine-bootstrap.ts new file mode 100644 index 0000000000..497ea850dd --- /dev/null +++ b/packages/plugin-sdk/src/machine-bootstrap.ts @@ -0,0 +1,28 @@ +import type { ServerAccessSelection } from "./backend-contract.js"; +import type { PluginMachineProviderProgress } from "./machine-provider.js"; + +export interface MachineExecutorRequest { + command: string[]; + timeoutMs: number; + signal: AbortSignal; + stdin?: string; + onOutput?: (chunk: string) => void; +} + +export interface MachineExecutor { + exec( + request: MachineExecutorRequest, + ): Promise<{ exitCode: number; stdout: string; stderr: string }>; +} + +export interface MachineBootstrapRequest { + key: string; + access?: ServerAccessSelection; + executor?: MachineExecutor; + report: PluginMachineProviderProgress; + signal: AbortSignal; +} + +export interface MachineBootstrapApi { + bootstrap(request: MachineBootstrapRequest): Promise<{ hostId: string }>; +} diff --git a/packages/plugin-sdk/src/machine-provider.ts b/packages/plugin-sdk/src/machine-provider.ts new file mode 100644 index 0000000000..83e86ecab9 --- /dev/null +++ b/packages/plugin-sdk/src/machine-provider.ts @@ -0,0 +1,105 @@ +import type { + JsonValue, + PluginMachineValidateDecision, + StandardSchemaV1, + StandardSchemaV1InferOutput, +} from "@get-bb/plugin-sdk"; + +export type PluginMachineProviderInputsSchema = StandardSchemaV1 | undefined; +type InputsValue = S extends StandardSchemaV1 + ? StandardSchemaV1InferOutput + : null; +export interface PluginMachineProviderProgress { + step(text: string): void; + log(text: string): void; +} + +export type PluginMachineProviderAvailability = + | { status: "available" } + | { status: "setup-required"; message: string } + | { status: "unavailable"; message: string }; + +export type PluginMachineProviderValidateContext< + S extends PluginMachineProviderInputsSchema = + PluginMachineProviderInputsSchema, +> = { + inputs: InputsValue; +}; + +export interface PluginMachineProviderLifecycleContext { + checkpoint(resource: JsonValue): Promise; + report: PluginMachineProviderProgress; + signal: AbortSignal; +} + +export type PluginMachineProviderCreateContext< + S extends PluginMachineProviderInputsSchema = + PluginMachineProviderInputsSchema, +> = PluginMachineProviderValidateContext & + PluginMachineProviderLifecycleContext & { + key: string; + attempt: number; + }; + +export type PluginMachineProviderCreateResult = + | { status: "created"; name: string; resource: JsonValue } + | { status: "failed"; message: string }; + +type PluginMachineProviderResourceLifecycleContext = + PluginMachineProviderLifecycleContext & { + hostId: string; + resource: JsonValue; + }; + +type PluginMachineProviderRemoveContext = Omit< + PluginMachineProviderResourceLifecycleContext, + "checkpoint" +>; + +export interface PluginMachineProviderResourceResult { + resource: JsonValue; +} + +export type PluginMachineProviderRemoveResult = + | { status: "removed" } + | { status: "failed"; message: string }; + +export interface PluginMachineProviderDefinition< + S extends PluginMachineProviderInputsSchema = + PluginMachineProviderInputsSchema, +> { + id: string; + displayName: string; + /** One line telling a user what choosing this provider gets them, shown wherever a machine is added. */ + description: string; + /** Provider glyph, declared icon, or plugin-relative icon path. */ + icon: string; + ephemeral?: boolean; + /** Persisted and readable by every plugin. Store secret references, never secrets. */ + inputs?: S; + availability?(): + | PluginMachineProviderAvailability + | Promise; + validate?( + context: PluginMachineProviderValidateContext, + ): PluginMachineValidateDecision | Promise; + create( + context: PluginMachineProviderCreateContext, + ): Promise; + /** Reconcile and remove an uncertain allocation by durable key without creating or bootstrapping. Return failed while allocation intent remains unresolved. */ + reconcileCleanup(context: { + key: string; + resource: JsonValue | null; + report: PluginMachineProviderProgress; + signal: AbortSignal; + }): Promise; + suspend?( + context: PluginMachineProviderResourceLifecycleContext, + ): Promise; + resume?( + context: PluginMachineProviderResourceLifecycleContext, + ): Promise; + remove( + context: PluginMachineProviderRemoveContext, + ): Promise; +} diff --git a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts index 23fe3ea64d..9b2a06e22c 100644 --- a/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts +++ b/packages/plugin-sdk/src/testing/__tests__/fake-plugin-host.test.ts @@ -26,8 +26,16 @@ describe("fixtures", () => { expect(makeHostResponse({ id: "host-target", name: "Target" })).toEqual({ id: "host-target", name: "Target", - status: "connected", type: "persistent", + status: "connected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, @@ -1692,7 +1700,6 @@ describe("providers.experimental_contributeEnv", () => { name: "PLUGIN_API_URL", value: { serverPath: "/plugins/auth-proxy/api" }, reason: "Route provider traffic through the plugin", - secret: true, }, ]; }); @@ -1708,7 +1715,6 @@ describe("providers.experimental_contributeEnv", () => { name: "PLUGIN_API_URL", value: { serverPath: "/plugins/auth-proxy/api" }, reason: "Route provider traffic through the plugin", - secret: true, }, ]); expect(contexts).toEqual([ @@ -1761,7 +1767,6 @@ describe("providers.experimental_contributeEnv", () => { name: "lowercase", value: "hidden", reason: "invalid name", - secret: false, }, ]); expect(() => @@ -1812,6 +1817,37 @@ describe("experimental_aiServices.register", () => { }); describe("environment targets", () => { + it("keeps compositions separate from concrete lifecycle providers", () => { + const { bb, harness } = createFakePluginHost(); + bb.experimental_environments.register({ + id: "sandbox", + displayName: "Sandbox", + machineProviderId: "cloud-machine", + environmentProviderId: "project-checkout", + }); + expect(harness.registrations.environmentProviders.has("sandbox")).toBe( + false, + ); + expect( + harness.registrations.environmentCompositions.get("sandbox"), + ).toMatchObject({ + machineProviderId: "cloud-machine", + environmentProviderId: "project-checkout", + }); + expect(() => + bb.experimental_environments.register({ + id: "sandbox", + displayName: "Conflicting concrete provider", + create: async () => ({ + status: "created", + path: "/checkout", + ownsPath: false, + }), + remove: async () => ({ status: "removed" }), + }), + ).toThrow("already registered as a composition"); + }); + it("normalizes a registration and exposes it to the harness", async () => { const { bb, harness } = createFakePluginHost(); const create = async () => ({ @@ -2066,6 +2102,96 @@ describe("environment targets", () => { }); }); + it("accepts a machine provider without suspend and resume", () => { + const { bb, harness } = createFakePluginHost(); + bb.experimental_machines.register({ + description: "Provision a test machine.", + icon: "Terminal", + id: "test-machine", + displayName: "Test machine", + + reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + name: "Test machine", + resource: { target: "staging" }, + }), + remove: async () => ({ status: "removed" }), + }); + expect( + harness.registrations.machineProviders.get("test-machine"), + ).toMatchObject({ + icon: "Terminal", + ephemeral: false, + suspend: null, + resume: null, + }); + }); + + it("normalizes ephemeral machine lifecycle policy", () => { + const { bb, harness } = createFakePluginHost(); + bb.experimental_machines.register({ + description: "Provision temporary compute.", + icon: "Terminal", + id: "temporary-machine", + displayName: "Temporary machine", + ephemeral: true, + reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + name: "Temporary machine", + resource: null, + }), + remove: async () => ({ status: "removed" }), + }); + expect( + harness.registrations.machineProviders.get("temporary-machine"), + ).toMatchObject({ ephemeral: true }); + }); + + it.each([ + { description: "", icon: "Terminal" }, + { description: "Provision a machine.", icon: " " }, + ])("rejects empty required machine metadata: %j", (metadata) => { + const { bb } = createFakePluginHost(); + expect(() => + bb.experimental_machines.register({ + id: "invalid-metadata", + displayName: "Invalid metadata", + ...metadata, + create: async () => ({ + status: "created", + name: "Test machine", + resource: null, + }), + reconcileCleanup: async () => ({ status: "removed" }), + remove: async () => ({ status: "removed" }), + }), + ).toThrow(); + }); + + it("requires machine suspend and resume as a pair", () => { + const create = async () => ({ + status: "created" as const, + name: "Test machine", + resource: null, + }); + const remove = async () => ({ status: "removed" as const }); + const lifecycle = async () => ({ resource: null }); + expect(() => + createFakePluginHost().bb.experimental_machines.register({ + description: "Provision a test machine.", + icon: "Terminal", + id: "half-lifecycle", + displayName: "Half lifecycle", + create, + reconcileCleanup: remove, + suspend: lifecycle, + remove, + }), + ).toThrow(/declare suspend and resume together/); + }); + it("delivers message.cancelled to a listener", async () => { const { bb, harness } = createFakePluginHost(); const seen: string[] = []; diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index 5d22a007ac..f62c6c22ed 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -73,6 +73,7 @@ import { type ExperimentalPermissionModePickerProps, type ExperimentalProviderModelPickerProps, type PluginEnvironmentProviderInputsRegistration, + type PluginMachineProviderInputsRegistration, type ThreadChatProps, type DiffProps, type SourceCodeProps, @@ -966,6 +967,7 @@ export interface CapturedPluginApp { providerIcons: PluginProviderIconRegistration[]; timelineRenderers: PluginTimelineRendererRegistration[]; environmentProviderInputs: PluginEnvironmentProviderInputsRegistration[]; + machineProviderInputs: PluginMachineProviderInputsRegistration[]; contentScripts: PluginContentScriptRegistration[]; } diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts index 591cb4c062..91981719a3 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -1,3 +1,8 @@ +import { + environmentCompositionSchema, + type NormalizedPluginEnvironmentComposition, +} from "../internal/host-policy.js"; +import type { MachineBootstrapApi } from "../machine-bootstrap.js"; import { createHash } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -21,7 +26,9 @@ import { isZodSchemaLike, storePluginHook, validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, type NormalizedPluginEnvironmentProvider, + type NormalizedPluginMachineProvider, KV_VALUE_MAX_BYTES, MENTION_PROVIDER_ID_PATTERN, normalizeMentionProviderTriggers, @@ -65,6 +72,7 @@ import type { PluginCliResult, PluginHookHandler, PluginEnvironments, + PluginMachines, PluginHookName, PluginHooks, PluginEvents, @@ -284,10 +292,19 @@ export interface FakePluginRegistrations { hooks: { [K in PluginHookName]: PluginHookHandler | null; }; + environmentCompositions: ReadonlyMap< + string, + NormalizedPluginEnvironmentComposition + >; environmentProviders: ReadonlyMap< string, NormalizedPluginEnvironmentProvider >; + machineProviders: ReadonlyMap; + serverAccessProviders: ReadonlyMap< + string, + import("../backend-contract.js").ServerAccessProviderDeclaration + >; mentionProviders: FakeMentionProviderRecord[]; /** Live provider registrations from `bb.providers.register` * (normalized declarations, registration order; dispose removes). */ @@ -478,6 +495,8 @@ export interface FakePluginHarness } export interface CreateFakePluginHostOptions { + machineBootstrap?: MachineBootstrapApi; + machineResource?: (hostId: string) => Promise; /** Defaults to "test-plugin". */ pluginId?: string; /** @@ -1779,6 +1798,8 @@ function createFakePluginHostInternal( const threadEventHandlers: { [E in PluginThreadEventName]: Array>; } = { + "experimental_thread.events": [], + "experimental_terminal.input": [], "thread.created": [], "thread.active": [], "thread.idle": [], @@ -1797,10 +1818,19 @@ function createFakePluginHostInternal( } = { "message.dispatch": null, }; + const environmentCompositions = new Map< + string, + NormalizedPluginEnvironmentComposition + >(); const environmentProviders = new Map< string, NormalizedPluginEnvironmentProvider >(); + const machineProviders = new Map(); + const serverAccessProviders = new Map< + string, + import("../backend-contract.js").ServerAccessProviderDeclaration + >(); const disposeHooks: Array<() => void | Promise> = []; const serviceControllers: AbortController[] = []; let nextInteractionId = 1; @@ -2112,8 +2142,25 @@ function createFakePluginHostInternal( }; const experimental_environments: PluginEnvironments = { - register(declaration) { + register( + declaration: + | import("@get-bb/plugin-sdk").PluginEnvironmentProviderDeclaration + | NormalizedPluginEnvironmentComposition, + ) { assertLive(); + if ("machineProviderId" in declaration) { + const composition = environmentCompositionSchema.parse(declaration); + if (environmentProviders.has(composition.id)) + throw new Error( + "Environment ID is already registered as a concrete provider", + ); + environmentCompositions.set(composition.id, composition); + return; + } + if (environmentCompositions.has(declaration.id)) + throw new Error( + "Environment ID is already registered as a composition", + ); const target = validatePluginEnvironmentProviderDeclaration(declaration); const problem = target.icon === null @@ -2129,6 +2176,33 @@ function createFakePluginHostInternal( }, }; + const unavailableMachineBootstrap = (): never => { + throw new Error( + "Configure machineBootstrap in createFakePluginHost to exercise machine bootstrap", + ); + }; + const experimental_machines: PluginMachines = { + async getResource(hostId) { + assertLive(); + return options.machineResource ? options.machineResource(hostId) : null; + }, + ...(options.machineBootstrap ?? { + bootstrap: unavailableMachineBootstrap, + }), + register(declaration) { + assertLive(); + const target = validatePluginMachineProviderDeclaration(declaration); + const problem = + target.icon === null + ? null + : undeclaredIconProblem(pluginId, declaredIconNames, target.icon); + if (problem !== null) { + throw new Error(providerIconRefusalMessage(target.id, problem)); + } + machineProviders.set(target.id, target); + }, + }; + const bb: BbPluginApi = { pluginId, log, @@ -2145,6 +2219,17 @@ function createFakePluginHostInternal( events, experimental_hooks, experimental_environments, + experimental_machines, + experimental_serverAccess: { + register(declaration) { + assertLive(); + serverAccessProviders.set(declaration.id, declaration); + }, + recheck() { + assertLive(); + requestedDrains += 1; + }, + }, status, server, hosts, @@ -2236,6 +2321,10 @@ function createFakePluginHostInternal( }, get threadEventHandlers() { return { + "experimental_thread.events": + threadEventHandlers["experimental_thread.events"].length, + "experimental_terminal.input": + threadEventHandlers["experimental_terminal.input"].length, "thread.created": threadEventHandlers["thread.created"].length, "thread.active": threadEventHandlers["thread.active"].length, "thread.idle": threadEventHandlers["thread.idle"].length, @@ -2255,10 +2344,18 @@ function createFakePluginHostInternal( get hooks() { return { ...hooks }; }, + get environmentCompositions() { + return new Map(environmentCompositions); + }, get environmentProviders() { return new Map(environmentProviders); }, - + get serverAccessProviders() { + return new Map(serverAccessProviders); + }, + get machineProviders() { + return new Map(machineProviders); + }, mentionProviders, providerRegistrations, providerEnvResolvers, diff --git a/packages/plugin-sdk/src/testing/fixtures.ts b/packages/plugin-sdk/src/testing/fixtures.ts index 50ff8c86f8..12e0952e0c 100644 --- a/packages/plugin-sdk/src/testing/fixtures.ts +++ b/packages/plugin-sdk/src/testing/fixtures.ts @@ -65,8 +65,16 @@ export function makeHostResponse( return { id: "host-1", name: "Test host", - status: "connected", type: "persistent", + status: "connected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, @@ -239,8 +247,16 @@ export function makeMessageDispatchHookContext( const hostDefaults: NonNullable = { id: "host-1", name: "Test host", - status: "connected", type: "persistent", + status: "connected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/packages/scripts/test/run-host-daemon.test.ts b/packages/scripts/test/run-host-daemon.test.ts index 8db83c87cd..2e92c9badf 100644 --- a/packages/scripts/test/run-host-daemon.test.ts +++ b/packages/scripts/test/run-host-daemon.test.ts @@ -43,7 +43,6 @@ function createTestRuntimeEnv({ BB_HOST_DAEMON_PORT: "3002", BB_HOST_ID: undefined, BB_HOST_NAME: undefined, - BB_HOST_TYPE: undefined, BB_SERVER_URL: serverUrl, NODE_ENV: "development", }; @@ -174,7 +173,6 @@ describe("run-host-daemon auto join", () => { JSON.stringify({ hostId: "host_existing", hostKey: "bbdh_existing", - hostType: "persistent", serverUrl: "http://127.0.0.1:3334", }), ); @@ -240,7 +238,6 @@ describe("run-host-daemon auto join", () => { expect(env.BB_HOST_ID).toBe(persistedHostId); expect(env.BB_HOST_ENROLL_KEY).toBe("bbde_test_enroll_key"); - expect(env.BB_HOST_TYPE).toBeUndefined(); expect(requests).toHaveLength(2); expect(requests[1]?.url).toBe( "http://127.0.0.1:3334/internal/hosts/enroll-key", @@ -297,7 +294,6 @@ describe("run-host-daemon auto join", () => { expect(env.BB_HOST_ID).toBe("host_generated"); expect(env.BB_HOST_ENROLL_KEY).toBe("bbde_generated_enroll_key"); - expect(env.BB_HOST_TYPE).toBeUndefined(); expect(requests[1]?.body).toBe(JSON.stringify({})); }); diff --git a/packages/sdk/src/areas/hosts.ts b/packages/sdk/src/areas/hosts.ts index 051642b2d3..bcc6c00471 100644 --- a/packages/sdk/src/areas/hosts.ts +++ b/packages/sdk/src/areas/hosts.ts @@ -2,10 +2,13 @@ import { hostProviderCliInstallEventSchema } from "@bb/server-contract"; import type { Host } from "@bb/domain"; import type { CreateHostJoinCodeResponse, + CreateMachineRequest, + HostEnrollmentCommandResponse, HostCloneDefaultPathQuery, HostCloneDefaultPathResponse, HostDirectoryListing, HostDirectoryQuery, + HostActionResponse, HostPathsExistRequest, HostPathsExistResponse, HostPickFolderRequest, @@ -15,6 +18,7 @@ import type { HostProviderCliStatusResponse, HostRetryUpdateResponse, UpdateHostRequest, + SystemMachineProvider, } from "@bb/server-contract"; import { signalRequestArgs, type CreateSdkAreaArgs } from "./common.js"; @@ -35,6 +39,10 @@ export interface HostRetryUpdateArgs { hostId: string; } +export interface HostActionArgs { + hostId: string; +} + export interface HostDirectoryArgs extends HostDirectoryQuery { hostId: string; signal?: AbortSignal; @@ -60,13 +68,24 @@ export interface HostProviderCliInstallArgs extends HostProviderCliInstallReques } export interface HostListArgs { + includeCreating?: boolean; + signal?: AbortSignal; +} + +export interface MachineCreateArgs extends CreateMachineRequest { + wait?: boolean; + signal?: AbortSignal; +} + +export interface MachineProviderListArgs { signal?: AbortSignal; } export type HostCreateJoinCodeResult = CreateHostJoinCodeResponse; export type HostDeleteResult = { ok: true }; export type HostDirectoryResult = HostDirectoryListing; -export type HostGetResult = Host; +export type HostGetResult = Host & { connectMachineId: string | null }; +export type HostEnrollmentCommandResult = HostEnrollmentCommandResponse; export type HostCloneDefaultPathResult = HostCloneDefaultPathResponse; export type HostProviderCliInstallResult = HostProviderCliInstallEvent[]; export type HostListResult = Host[]; @@ -74,9 +93,15 @@ export type HostPathsExistResult = HostPathsExistResponse; export type HostPickFolderResult = HostPickFolderResponse; export type HostProviderCliStatusResult = HostProviderCliStatusResponse; export type HostRetryUpdateResult = HostRetryUpdateResponse; +export type HostActionResult = HostActionResponse; export type HostUpdateResult = Host; +export type MachineProviderListResult = SystemMachineProvider[]; export interface HostsArea { + experimental_create(args: MachineCreateArgs): Promise; + experimental_getEnrollmentCommand( + args: HostGetArgs, + ): Promise; createJoinCode(): Promise; delete(args: HostDeleteArgs): Promise; directory(args: HostDirectoryArgs): Promise; @@ -88,19 +113,65 @@ export interface HostsArea { args: HostProviderCliInstallArgs, ): Promise; list(args?: HostListArgs): Promise; + experimental_listProviders( + args?: MachineProviderListArgs, + ): Promise; pathsExist(args: HostPathsExistArgs): Promise; pickFolder(args: HostPickFolderArgs): Promise; providerCliStatus(args: HostGetArgs): Promise; + experimental_resume(args: HostActionArgs): Promise; + experimental_retryCleanup(args: HostActionArgs): Promise; retryUpdate(args: HostRetryUpdateArgs): Promise; + experimental_suspend(args: HostActionArgs): Promise; update(args: HostUpdateArgs): Promise; } export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { const { transport } = args; return { + async experimental_create(input) { + let host = await transport.readJson( + transport.api.v1.hosts.$post( + { + json: { + machineProviderId: input.machineProviderId, + inputs: input.inputs, + ...(input.key === undefined ? {} : { key: input.key }), + }, + }, + ...signalRequestArgs(input.signal), + ), + ); + if (input.wait === false) return host; + for (;;) { + input.signal?.throwIfAborted(); + if (host.lifecycle.phase === "active") return host; + if ( + host.lifecycle.phase === "removing" || + host.lifecycle.phase === "destroyed" + ) + throw new Error( + host.lifecycle.message ?? "Machine creation cancelled", + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); + host = await this.get({ hostId: host.id, signal: input.signal }); + } + }, + async experimental_getEnrollmentCommand(input) { + return transport.readJson( + transport.api.v1.hosts[":id"]["enrollment-command"].$get( + { + param: { id: input.hostId }, + }, + ...signalRequestArgs(input.signal), + ), + ); + }, async createJoinCode() { return transport.readJson( - transport.api.v1.hosts["join-codes"].$post({ json: {} }), + transport.api.v1.hosts["join-codes"].$post({ + json: {}, + }), ); }, async delete(input) { @@ -153,7 +224,7 @@ export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { }, }), ); - const text = await Response.prototype.text.call(response); + const text: string = await response.text(); return text .split(/\r?\n/u) .filter((line) => line.trim().length > 0) @@ -163,9 +234,29 @@ export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { }, async list(input) { return transport.readJson( - transport.api.v1.hosts.$get({}, ...signalRequestArgs(input?.signal)), + transport.api.v1.hosts.$get( + { + query: { + ...(input?.includeCreating === undefined + ? {} + : { + includeCreating: input.includeCreating ? "true" : "false", + }), + }, + }, + ...signalRequestArgs(input?.signal), + ), ); }, + async experimental_listProviders(input) { + const response = await transport.readJson( + transport.api.v1.system["machine-providers"].$get( + {}, + ...signalRequestArgs(input?.signal), + ), + ); + return response.providers; + }, async pathsExist(input) { return transport.readJson( transport.api.v1.hosts[":id"].paths.exist.$post( @@ -198,6 +289,20 @@ export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { ), ); }, + async experimental_resume(input) { + return transport.readJson( + transport.api.v1.hosts[":id"].resume.$post({ + param: { id: input.hostId }, + }), + ); + }, + async experimental_retryCleanup(input) { + return transport.readJson( + transport.api.v1.hosts[":id"]["retry-cleanup"].$post({ + param: { id: input.hostId }, + }), + ); + }, async retryUpdate(input) { return transport.readJson( transport.api.v1.hosts[":id"]["retry-update"].$post({ @@ -205,6 +310,13 @@ export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { }), ); }, + async experimental_suspend(input) { + return transport.readJson( + transport.api.v1.hosts[":id"].suspend.$post({ + param: { id: input.hostId }, + }), + ); + }, async update(input) { return transport.readJson( transport.api.v1.hosts[":id"].$patch({ diff --git a/packages/sdk/src/areas/system.ts b/packages/sdk/src/areas/system.ts index 04321fdc3d..d22e9c8c66 100644 --- a/packages/sdk/src/areas/system.ts +++ b/packages/sdk/src/areas/system.ts @@ -1,3 +1,7 @@ +import type { + MachineEnvironmentReplace, + MachineEnvironmentList, +} from "@bb/server-contract"; import type { AppKeybindingOverrides, AppSettings, @@ -105,6 +109,10 @@ export interface SystemUiPreferencesArea { } export interface SystemArea { + machineEnvironment(): Promise; + replaceMachineEnvironment( + input: MachineEnvironmentReplace, + ): Promise; attention(args?: SystemAttentionArgs): Promise; config(args?: SystemConfigArgs): Promise; executionOptions( @@ -175,6 +183,16 @@ export function createSystemArea(args: CreateSdkAreaArgs): SystemArea { }; return { uiPreferences, + async machineEnvironment() { + return transport.readJson( + transport.api.v1.settings["machine-environment"].$get(), + ); + }, + async replaceMachineEnvironment(input) { + return transport.readJson( + transport.api.v1.settings["machine-environment"].$put({ json: input }), + ); + }, async attention(input) { return transport.readJson( transport.api.v1.system.attention.$get( diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index 16a00daaf4..8ba18880af 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -202,7 +202,8 @@ export type ThreadStorageFilesResult = ThreadStorageFileListResponse; export type ThreadStorageLocationResult = ThreadStorageLocationResponse; export type ThreadStoragePathsResult = ThreadStoragePathListResponse; export type ThreadChildSummaryResult = ThreadChildSummaryResponse; -export type ThreadDefaultExecutionOptionsResult = ResolvedThreadExecutionOptions | null; +export type ThreadDefaultExecutionOptionsResult = + ResolvedThreadExecutionOptions | null; export type ThreadConversationOutlineResult = ThreadConversationOutlineResponse; export type ThreadTimelineTurnSummaryDetailsResult = TimelineTurnSummaryDetailsResponse; diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 2b279c733c..83e306130f 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -274,16 +274,22 @@ type ExpectedGuideKey = "render"; type ExpectedHostsKey = | "cloneDefaultPath" + | "experimental_create" + | "experimental_getEnrollmentCommand" | "createJoinCode" | "delete" | "directory" | "get" | "installProviderCli" | "list" + | "experimental_listProviders" | "pathsExist" | "pickFolder" | "providerCliStatus" + | "experimental_resume" + | "experimental_retryCleanup" | "retryUpdate" + | "experimental_suspend" | "update"; type ExpectedPluginsKey = @@ -334,6 +340,8 @@ type ExpectedProvidersKey = "list" | "models"; type ExpectedStatusKey = "get"; type ExpectedSystemKey = + | "machineEnvironment" + | "replaceMachineEnvironment" | "attention" | "cliSkillsStatus" | "config" diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index 3a8af74601..b8f1196aa3 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -106,6 +106,129 @@ function createFetchQueue( } describe("@bb/sdk", () => { + it("creates a DigitalOcean machine through the SDK without a project", async () => { + const host = { + id: "host_do", + name: "Dev box", + type: "ephemeral", + status: "connected", + machineProviderId: "digitalocean", + lifecycle: { + phase: "active", + suspendedAt: null, + + message: null, + teardown: null, + }, + maxPermissionMode: "full", + lastSeenAt: 1, + lastRejectedProtocolVersion: null, + createdAt: 1, + updatedAt: 1, + }; + const creating = { + ...host, + status: "disconnected", + lifecycle: { + ...host.lifecycle, + phase: "creating", + message: "Creating DigitalOcean machine…", + }, + }; + const queue = createFetchQueue([{ body: creating }, { body: host }]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + expect( + await sdk.hosts.experimental_create({ + machineProviderId: "digitalocean", + inputs: {}, + }), + ).toEqual(host); + expect(queue.requests).toEqual([ + { + bodyText: JSON.stringify({ + machineProviderId: "digitalocean", + inputs: {}, + }), + method: "POST", + url: "http://bb.test/api/v1/hosts", + }, + { + bodyText: undefined, + method: "GET", + url: "http://bb.test/api/v1/hosts/host_do", + }, + ]); + }); + + it("requests a machine join code without a host type", async () => { + const queue = createFetchQueue([ + { body: { joinCode: "one", hostId: "host_1", expiresAt: 1 } }, + ]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + + await sdk.hosts.createJoinCode(); + + expect(queue.requests).toEqual([ + { + bodyText: JSON.stringify({}), + method: "POST", + url: "http://bb.test/api/v1/hosts/join-codes", + }, + ]); + }); + + it("reads provider installation events through the response instance", async () => { + const events = [ + { + type: "started", + provider: "codex", + command: "npm install --global @openai/codex", + }, + { + type: "completed", + provider: "codex", + exitCode: 0, + signal: null, + success: true, + }, + ]; + const response = new Response(null, { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); + Object.defineProperty(response, "text", { + value: async () => + events.map((event) => JSON.stringify(event)).join("\n"), + }); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: async () => response, + runtime: "node", + }), + }); + + await expect( + sdk.hosts.installProviderCli({ + hostId: "host_test", + provider: "codex", + actionKind: "install", + }), + ).resolves.toEqual(events); + }); + it("sends thread pane presentation actions through the typed transport", async () => { const queue = createFetchQueue([{ body: { delivered: 3 } }]); const sdk = createBbSdk({ diff --git a/packages/server-contract/src/api/hosts.ts b/packages/server-contract/src/api/hosts.ts index cfcb8f3a5c..05545b7c4b 100644 --- a/packages/server-contract/src/api/hosts.ts +++ b/packages/server-contract/src/api/hosts.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { permissionModeSchema } from "@bb/domain"; +import { jsonValueSchema, permissionModeSchema } from "@bb/domain"; import { pathsExistRequestSchema, providerCliInstallEventSchema, @@ -49,6 +49,25 @@ export type CreateHostJoinCodeRequest = z.infer< typeof createHostJoinCodeRequestSchema >; +export const createMachineRequestSchema = z + .object({ + machineProviderId: z.string().min(1), + inputs: jsonValueSchema.nullable(), + key: z.string().min(1).optional(), + }) + .strict(); +export type CreateMachineRequest = z.infer; + +export const hostEnrollmentCommandResponseSchema = z + .object({ + command: z.string().min(1), + expiresAt: z.number().int().positive(), + }) + .nullable(); +export type HostEnrollmentCommandResponse = z.infer< + typeof hostEnrollmentCommandResponseSchema +>; + export const createHostJoinCodeResponseSchema = z.object({ joinCode: z.string().min(1), hostId: z.string().min(1), @@ -74,9 +93,12 @@ export type UpdateHostPermissionCeilingRequest = z.infer< typeof updateHostPermissionCeilingRequestSchema >; -export const hostRetryUpdateResponseSchema = z +export const hostActionResponseSchema = z .object({ ok: z.literal(true) }) .strict(); +export type HostActionResponse = z.infer; + +export const hostRetryUpdateResponseSchema = hostActionResponseSchema; export type HostRetryUpdateResponse = z.infer< typeof hostRetryUpdateResponseSchema >; @@ -103,3 +125,8 @@ export type HostProviderCliInstallRequest = ProviderCliInstallRequest; export const hostProviderCliInstallEventSchema = providerCliInstallEventSchema; export type HostProviderCliInstallEvent = ProviderCliInstallEvent; + +export const hostListQuerySchema = z.object({ + includeCreating: z.enum(["true", "false"]).optional(), +}); +export type HostListQuery = z.input; diff --git a/packages/server-contract/src/api/machine-environment.ts b/packages/server-contract/src/api/machine-environment.ts new file mode 100644 index 0000000000..716ce8a4e8 --- /dev/null +++ b/packages/server-contract/src/api/machine-environment.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +export const machineEnvironmentNameSchema = z + .string() + .regex(/^[A-Z_][A-Z0-9_]*$/u) + .max(128); +export const machineEnvironmentSetSchema = z + .object({ + name: machineEnvironmentNameSchema, + value: z + .string() + .max(65536) + .refine( + (value) => !value.includes("\0"), + "Environment values cannot contain NUL", + ), + note: z.string().max(1024).nullable().default(null), + }) + .strict(); +export type MachineEnvironmentSet = z.infer; +export const machineEnvironmentVariableSchema = z + .object({ + name: machineEnvironmentNameSchema, + value: z.null(), + secret: z.literal(true), + note: z.string().nullable(), + }) + .strict(); +export type MachineEnvironmentVariable = z.infer< + typeof machineEnvironmentVariableSchema +>; +export const machineEnvironmentListSchema = z.object({ + builtInGit: z.object({ + status: z.enum(["logged in", "not logged in", "overridden", "disabled"]), + statusMessage: z.string(), + }), + variables: z.array(machineEnvironmentVariableSchema), +}); +export type MachineEnvironmentList = z.infer< + typeof machineEnvironmentListSchema +>; diff --git a/packages/server-contract/src/api/shared.ts b/packages/server-contract/src/api/shared.ts index b02a4d0777..68b8b89f60 100644 --- a/packages/server-contract/src/api/shared.ts +++ b/packages/server-contract/src/api/shared.ts @@ -111,7 +111,16 @@ export const projectDefaultEnvironmentSchema = z.object({ export const providerEnvironmentSchema = z.object({ type: z.literal("provider"), environmentProviderId: z.string().min(1), - machine: z.object({ type: z.literal("existing"), hostId: z.string().min(1) }), + machine: z + .discriminatedUnion("type", [ + z.object({ type: z.literal("existing"), hostId: z.string().min(1) }), + z.object({ + type: z.literal("new"), + machineProviderId: z.string().min(1), + inputs: jsonValueSchema.nullable().default(null), + }), + ]) + .optional(), inputs: jsonValueSchema.nullable().default(null), }); export type ProviderEnvironmentArgs = z.infer; diff --git a/packages/server-contract/src/api/system.ts b/packages/server-contract/src/api/system.ts index cd709e7f78..11a4640809 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -16,6 +16,34 @@ import { } from "@bb/domain"; import { providerHealthSchema as providerHealthSchema } from "@bb/provider-bridge-protocol/provider-maintenance"; import { hostPlatformSchema } from "@bb/host-daemon-contract/local"; +import { machineEnvironmentSetSchema } from "./machine-environment.js"; + +const machineEnvironmentReplacementVariableSchema = + machineEnvironmentSetSchema.extend({ + value: machineEnvironmentSetSchema.shape.value.nullable(), + }); + +export const machineEnvironmentReplaceSchema = z + .object({ + variables: z.array(machineEnvironmentReplacementVariableSchema), + }) + .strict() + .superRefine(({ variables }, context) => { + const names = new Set(); + for (const [index, variable] of variables.entries()) { + if (names.has(variable.name)) { + context.addIssue({ + code: "custom", + path: ["variables", index, "name"], + message: "Machine environment variable names must be unique", + }); + } + names.add(variable.name); + } + }); +export type MachineEnvironmentReplace = z.infer< + typeof machineEnvironmentReplaceSchema +>; export const systemExecutionOptionsModelLoadErrorCodeSchema = z.enum([ "provider_unavailable", @@ -123,7 +151,23 @@ export const systemAiServicesSchema = z.object({ }); export type SystemAiServices = z.infer; +export const serverAccessStatusSchema = z.object({ + providers: z.array( + z.object({ + id: z.string(), + displayName: z.string(), + description: z.string(), + pluginId: z.string().min(1).nullable(), + }), + ), + defaultProviderId: z.string(), + effectiveUrl: z.string().nullable(), + urlSource: z.enum(["setting", "BB_EXTERNAL_URL"]).nullable(), +}); +export type ServerAccessStatus = z.infer; + export const systemConfigResponseSchema = z.object({ + serverAccess: serverAccessStatusSchema, generalSettings: appSettingsSchema.extend({ showUnhandledProviderEvents: z.boolean().optional(), }), @@ -261,6 +305,8 @@ const systemEnvironmentProviderAvailabilitySchema = z.discriminatedUnion( ); export const systemEnvironmentProviderSchema = z.object({ + environmentProviderId: z.string().min(1).optional(), + machineProviderId: z.string().min(1).nullable(), id: z.string().min(1), displayName: z.string().min(1), icon: z.string().min(1).nullable(), @@ -279,6 +325,9 @@ export const systemEnvironmentProviderSchema = z.object({ z.string().min(1), systemEnvironmentProviderAvailabilitySchema.nullable(), ), + machineInputs: jsonValueSchema.nullable().optional(), + machineAcceptsEmptyInputs: z.boolean().optional(), + machineProviderPluginId: z.string().min(1).optional(), }); export type SystemEnvironmentProvider = z.infer< typeof systemEnvironmentProviderSchema @@ -308,3 +357,23 @@ export const systemEnvironmentProvidersQuerySchema = z export type SystemEnvironmentProvidersQuery = z.infer< typeof systemEnvironmentProvidersQuerySchema >; + +export const systemMachineProviderSchema = z.object({ + id: z.string().min(1), + displayName: z.string().min(1), + description: z.string().min(1), + icon: z.string().min(1), + logoUrl: z.string().min(1).nullable(), + pluginId: z.string().min(1), + inputs: jsonValueSchema.nullable(), + acceptsEmptyInputs: z.boolean(), + supportsSuspend: z.boolean(), +}); +export type SystemMachineProvider = z.infer; + +export const systemMachineProvidersResponseSchema = z.object({ + providers: z.array(systemMachineProviderSchema), +}); +export type SystemMachineProvidersResponse = z.infer< + typeof systemMachineProvidersResponseSchema +>; diff --git a/packages/server-contract/src/index.ts b/packages/server-contract/src/index.ts index 5759ed739f..75dd2dab83 100644 --- a/packages/server-contract/src/index.ts +++ b/packages/server-contract/src/index.ts @@ -62,3 +62,5 @@ export type { UnsubscribeMessage, JsonValue, } from "@bb/domain"; + +export * from "./api/machine-environment.js"; diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index 305cc443b0..da7f3eea69 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -1,3 +1,8 @@ +import { type MachineEnvironmentList } from "./api/machine-environment.js"; +import { + machineEnvironmentReplaceSchema, + type MachineEnvironmentReplace, +} from "./api/system.js"; import { desktopBrowserHostRequestSchema, desktopBrowserScopeSchema, @@ -75,6 +80,7 @@ import type { CopyProjectAttachmentsRequest, CreateHostJoinCodeRequest, CreateHostJoinCodeResponse, + CreateMachineRequest, CreateTerminalRequest, CreateProjectRequest, CreateProjectSourceRequest, @@ -106,6 +112,9 @@ import type { EnvironmentStatusResponse, HostDirectoryListing, HostDirectoryQuery, + HostEnrollmentCommandResponse, + HostListQuery, + HostActionResponse, HostCloneDefaultPathQuery, HostCloneDefaultPathResponse, HostFileListRequest, @@ -180,6 +189,7 @@ import type { SystemExecutionOptionsResponse, SystemEnvironmentProvidersQuery, SystemEnvironmentProvidersResponse, + SystemMachineProvidersResponse, SystemProviderInfo, SystemProvidersQuery, SystemProviderStatesResponse, @@ -268,6 +278,7 @@ import { restartTerminalRequestSchema, createProjectRequestSchema, createHostJoinCodeRequestSchema, + createMachineRequestSchema, createProjectSourceRequestSchema, createQueuedMessageRequestSchema, queuedMessageListQuerySchema, @@ -283,6 +294,7 @@ import { environmentPathsQuerySchema, environmentStatusQuerySchema, hostDirectoryQuerySchema, + hostListQuerySchema, hostCloneDefaultPathQuerySchema, hostFileListRequestSchema, hostFileReadRequestSchema, @@ -736,6 +748,14 @@ export const publicApiRoutes = { }, hosts: { + create: defineRoute({ + path: "/hosts", + method: "post", + request: jsonRequest( + createMachineRequestSchema, + ), + response: jsonResponse({ status: 201 }), + }), createJoinCode: defineRoute({ path: "/hosts/join-codes", method: "post", @@ -747,14 +767,22 @@ export const publicApiRoutes = { list: defineRoute({ path: "/hosts", method: "get", - request: noRequest(), + request: optionalQueryRequest( + hostListQuerySchema, + ), response: jsonResponse(), }), get: defineRoute({ path: "/hosts/:id", method: "get", request: noRequest(), - response: jsonResponse(), + response: jsonResponse(), + }), + enrollmentCommand: defineRoute({ + path: "/hosts/:id/enrollment-command", + method: "get", + request: noRequest(), + response: jsonResponse(), }), update: defineRoute({ path: "/hosts/:id", @@ -776,6 +804,24 @@ export const publicApiRoutes = { request: noRequest(), response: jsonResponse(), }), + suspend: defineRoute({ + path: "/hosts/:id/suspend", + method: "post", + request: noRequest(), + response: jsonResponse({ status: 202 }), + }), + resume: defineRoute({ + path: "/hosts/:id/resume", + method: "post", + request: noRequest(), + response: jsonResponse({ status: 202 }), + }), + retryCleanup: defineRoute({ + path: "/hosts/:id/retry-cleanup", + method: "post", + request: noRequest(), + response: jsonResponse(), + }), delete: defineRoute({ path: "/hosts/:id", method: "delete", @@ -1524,6 +1570,20 @@ export const publicApiRoutes = { }, system: { + machineEnvironment: defineRoute({ + path: "/settings/machine-environment", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), + replaceMachineEnvironment: defineRoute({ + path: "/settings/machine-environment", + method: "put", + request: jsonRequest( + machineEnvironmentReplaceSchema, + ), + response: jsonResponse(), + }), attention: defineRoute({ path: "/system/attention", method: "get", @@ -1652,6 +1712,12 @@ export const publicApiRoutes = { >(systemEnvironmentProvidersQuerySchema), response: jsonResponse(), }), + machineProviders: defineRoute({ + path: "/system/machine-providers", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), providers: defineRoute({ path: "/system/providers", method: "get", diff --git a/packages/server-contract/test/contract.test.ts b/packages/server-contract/test/contract.test.ts index 97389fece6..a86e8b222b 100644 --- a/packages/server-contract/test/contract.test.ts +++ b/packages/server-contract/test/contract.test.ts @@ -15,6 +15,7 @@ import { TERMINAL_DATA_MAX_BYTES, TERMINAL_ROWS_MAX, createTerminalRequestSchema, + createHostJoinCodeRequestSchema, createQueuedMessageRequestSchema, createProjectSourceRequestSchema, createPublicApiClient, @@ -84,6 +85,14 @@ const OPTIONAL_SERVER_FIELD_GROUPS: readonly OptionalServerFieldGroup[] = [ "forkThreadRequestSchema.environment.hostId", ], }, + { + reason: + "Composed environment providers choose their declared machine provider; concrete providers require an explicit machine selection.", + fields: [ + "createThreadRequestSchema.environment.machine", + "forkThreadRequestSchema.environment.machine", + ], + }, { reason: 'originPluginId is present exactly when origin is "plugin" (enforced by refinement); omission means a non-plugin origin.', @@ -589,6 +598,16 @@ describe("git branch name contract", () => { }); }); +describe("public host contracts", () => { + it("accepts an empty join-code request and rejects the deleted host type", () => { + expect(createHostJoinCodeRequestSchema.parse({})).toEqual({}); + expect( + createHostJoinCodeRequestSchema.safeParse({ hostType: "ephemeral" }) + .success, + ).toBe(false); + }); +}); + describe("public terminal contracts", () => { it("allows threadless terminal session responses", () => { expect( @@ -1914,9 +1933,58 @@ describe("server-contract clients", () => { }); describe("environment provider contracts", () => { + it("requires a machine selection and fills provider inputs with null at the boundary", () => { + expect( + createThreadRequestSchema.parse({ + projectId: "proj_123", + providerId: "codex", + origin: "app", + input: [{ type: "text", text: "Ship it" }], + environment: { + type: "provider", + environmentProviderId: "container", + machine: { type: "existing", hostId: "host_abc" }, + }, + }).environment, + ).toEqual({ + type: "provider", + environmentProviderId: "container", + machine: { type: "existing", hostId: "host_abc" }, + inputs: null, + }); + expect( + createThreadRequestSchema.parse({ + projectId: "proj_123", + providerId: "codex", + origin: "app", + input: [{ type: "text", text: "Ship it" }], + environment: { + type: "provider", + environmentProviderId: "container", + machine: { + type: "new", + machineProviderId: "modal-sandbox", + inputs: { region: "us-west" }, + }, + inputs: { image: "img", cpus: 4 }, + }, + }).environment, + ).toEqual({ + type: "provider", + environmentProviderId: "container", + machine: { + type: "new", + machineProviderId: "modal-sandbox", + inputs: { region: "us-west" }, + }, + inputs: { image: "img", cpus: 4 }, + }); + }); + it("lists provider requirements, input defaults, and availability", () => { const base = { id: "container", + machineProviderId: null, displayName: "Container", icon: null, logoUrl: null, diff --git a/packages/templates/src/templates/bb-guide-machines.md b/packages/templates/src/templates/bb-guide-machines.md index 4b14019ea6..1f4e532dab 100644 --- a/packages/templates/src/templates/bb-guide-machines.md +++ b/packages/templates/src/templates/bb-guide-machines.md @@ -5,14 +5,19 @@ summary: Command reference for listing and targeting execution machines. intent: Explain execution-machine discovery and selection from the CLI. editingNotes: Keep the user-facing noun machine; internal APIs and types use Host. --- + Machine commands -A machine is a host daemon that can run thread environments. Add remote -machines under Settings → Machines. +A host is an identity and daemon connection. A machine is a host with a +provider-owned lifecycle. The local host has no machine provider. Every other +host is a machine, including existing machines enrolled with the built-in +`manual` provider (Manual machine setup). Add machines under Settings → Machines +or from the composer machine picker. -The server listens on loopback by default. Remote execution machines need the -account-gated bb connect route or a private Tailscale Serve URL; generate their -installer while using that reachable server URL. +The server listens on loopback by default. Remote execution machines need +a server access provider: paired bb Connect, or a configured direct URL reachable +from the target, such as a private Tailscale Serve URL. A configured URL alone +does not prove reachability. The Settings installer first uses the exact `bb-app` tarball served by that bb server at `/install/bb-app.tgz`; only servers that do not implement the route @@ -36,17 +41,29 @@ directly under the selected data directory in `logs/server-stdio.log` and console output and startup errors; rotating application logs remain separate. Use `tail -F` to follow them without coupling service logging to the terminal. - bb machine list List machines with ID, connection - status, and relative last-seen time - --json Print the raw host list - bb machine show Show machine details - bb machine join-code Create a machine pairing code - bb machine rename Rename a machine - bb machine retry-update Retry a pending daemon update now - bb machine remove [--yes] Revoke and remove a machine - bb machine provider-cli status - bb machine provider-cli install - --action +bb machine list List machines with ID, connection +status, and relative last-seen time +--json Print the raw host list +bb machine providers List installed machine providers +--json Include inputs schemas and policy +bb machine create --provider Create a standalone machine +--key Reuse this creation on retries +--inputs Non-secret provider inputs +--project Optional project context +--json Print the created machine as JSON +bb machine enroll --bootstrap-file +--bootstrap-env Alternative private bundle source +bb machine show Show machine details +bb machine join-code Create a machine pairing code +bb machine rename Rename a machine +bb machine retry-update Retry a pending daemon update now +bb machine suspend Suspend a provider-managed machine +bb machine resume Resume a machine (already active is a no-op) +bb machine retry-cleanup Retry failed teardown now +bb machine remove [--yes] Revoke and remove a machine +bb machine provider-cli status +bb machine provider-cli install +--action Each machine has a permission limit: the highest permission mode any thread on that machine can run with. The default is Full Access. A thread that asks for @@ -58,19 +75,34 @@ machine cannot set it for any machine, so a sandbox machine can stay at Full Access while your laptop stays lower. `bb machine list --json` and `bb machine show` report the current limit. +Standalone create does not create a thread or workspace. Omitted inputs are +null; supply JSON when the provider schema requires it. Omit `--key` to let the +server generate one, or supply a stable key for retries. Creation is durable: +`--no-wait` returns the creating host ID immediately; otherwise the CLI polls +that host until active. SIGINT stops following and exits 130 while creation +continues. Use `bb machine show ` to inspect progress and `bb machine +remove ` to cancel and clean up. The SDK provides +`hosts.experimental_create`; pass `wait: false` to receive the creating host and +poll it with `hosts.get`. Aborting a caller signal never cancels the server operation. A connected daemon does not +yet imply an agent-ready checkout and authenticated provider. + +Suspend and resume are available only when the machine provider implements +both operations. Retry cleanup is accepted only for a retiring machine whose +provider teardown failed. + Updates commands One consolidated view of bb and provider CLI updates across machines — the CLI counterpart of Settings → Updates and the sidebar Updates badge. - bb updates [status] Show bb-app and provider CLI update - status for every machine - --machine Limit to one machine - --json Print the aggregate as JSON - bb updates apply Run every available provider CLI - install/update, one at a time - --machine Limit to one machine - --json Print per-target results as JSON +bb updates [status] Show bb-app and provider CLI update +status for every machine +--machine Limit to one machine +--json Print the aggregate as JSON +bb updates apply Run every available provider CLI +install/update, one at a time +--machine Limit to one machine +--json Print per-target results as JSON `bb updates apply` covers provider CLIs only. Update bb-app itself with the printed upgrade command (`npx bb-app@latest`) or the desktop app's relaunch; @@ -79,16 +111,184 @@ connected daemons then follow the server version automatically. Machine selectors accept either an exact machine ID or an unambiguous machine name. `--host` is an alias for `--machine`. - bb thread spawn --project --machine --prompt "..." - bb project create --name "..." --root --machine - bb project source add --machine --path +bb thread spawn --project --machine --prompt "..." +bb thread spawn --project --new-machine --prompt "..." +--machine-inputs +bb project create --name "..." --root --machine +bb project source add --machine --path For thread spawning, machine targeting works with an unmanaged workspace path, a new managed worktree, or the personal workspace. Do not combine it with an existing environment ID: the reused environment already selects its machine. +`--new-machine` creates through a machine provider and uses its advertised +environment row when declared. Otherwise add `--environment-provider ` +(required for SSH). Use `--environment-inputs ` for workspace configuration, +separately from `--machine-inputs `. Machine inputs are persisted and +readable by plugins; never put secrets there. Store credentials in plugin settings and pass only +non-secret configuration or references. + +When `--new-machine` selects an environment provider requiring a project +checkout, core clones the project's Git remote and registers a source on the +connected machine before creating that environment. Existing sources are reused. +Automatic setup uses a stable per-project target and shares concurrent setup on +the same host. After a server restart, it registers a completed checkout whose +remote matches instead of cloning again; a conflicting target is refused. +The project needs a Git remote and the machine needs Git access to it. Choosing +Personal workspace first does not clone a project. Standalone `bb machine create` +does not set up a project source. For project creation and sources, `--root`/`--path` refers to a path on the selected connected machine. Omit the selector to keep the existing local CLI machine fallback (normally the primary machine). Pass `--clone` to source add instead of `--path` to clone the project's Git remote there; `--remote-url` and `--target-path` optionally override the clone inputs. + +## Server access + +Set Machines → Server URL reachable by machines, or run `bb settings general +machineServerUrl https://bb.example.com`. An unset value uses BB_EXTERNAL_URL. +Select Manual to show the URL input. Set Default machine access with +`bb settings general defaultMachineAccess direct` or `connect`; `null` uses +bb connect, with setup required if unpaired. Manual never becomes the default implicitly. `bb settings show --json` +includes provider availability and the effective selection. Machines use this +access for ongoing runtime requests, including account-pool endpoints. + +The Tailscale plugin can supply private machine access without a Direct URL. +Use `bb tailscale devices`, `bb tailscale status`, and `bb tailscale configure +` to discover devices and validate a dedicated existing HTTPS Serve +mapping. Choose Tailscale explicitly; it is not selected by default. +The plugin skill documents SSH prerequisites and safe endpoint cleanup. + +## Local daemon lifecycle + +`install-machine.sh --start|--stop|--uninstall --host-id ` starts, stops or removes an +owned local installation. Optional `--server-url ` and `--data-dir ` +assert the expected installation. BB_DATA_DIR is treated as an assertion too. +An identity mismatch refuses the operation. These commands are local machine +primitives; `bb machine remove` asks the server to remove the provider resource. +They verify the canonical installer-owned directory, enrolled identity, and +service or process ownership before acting. Stop and uninstall safely succeed +when no matching installation exists; start requires an installation. They +refuse the default BB data directory. Stopping a daemon is distinct from +`bb machine suspend`, which invokes provider suspension and polls until the machine +is paused. `bb machine resume` likewise waits for provider restore and bootstrap. + +## Enroll a preinstalled machine + +`bb machine enroll --bootstrap-file ` or `bb machine enroll --bootstrap-env ` consumes a versioned private enrollment bundle prepared by core. Supply exactly one source. The environment source is removed from the CLI process environment after reading it; files remain under the caller's ownership. Neither command prints the bundle or credentials. + +The CLI refuses another host or server identity in the selected machine directory. Repeating enrollment with the same persisted identity succeeds without exchanging the credential again, including when the original bundle expired. Machine data defaults to `~/.bb-machines/`; `BB_DATA_DIR` can select another isolated machine directory, but enrollment refuses the default `~/.bb` directory. + +The manual copy command fetches `/install.sh` using a short-lived `X-BB-Enrollment` header. The server supplies the bootstrap only for a pending, unexpired, uncancelled manual enrollment whose credential has not been consumed; downloaded responses are not cached. The command contains no bootstrap JSON or access-provider credentials. + +The installer accepts `--bootstrap-env ` and uses the same enrollment command. It installs a private CLI and supplies `~/.local/bin/bb` without replacing an existing path. Non-login transports can use `command -v bb` with `~/.local/bin/bb` as a fallback. Linux machines without a systemd user session run a detached daemon; systemd and launchd machines receive a persistent service. + +Machine bootstrap v2 supplies optional server request headers. `bb machine enroll` +persists them privately as `serverHeaders`; the launcher passes `BB_SERVER_HEADERS` +to the daemon for enrollment, connection and runtime requests. Server-access +plugins redeem provider codes on the server. Pending encrypted v1 bundles are +upgraded by the server when prepared again. + +Delivered enrollment bundles from v1 remain valid until their expiry. The CLI accepts both file and environment forms, upgrades the bundle to v2 headers locally, and persists legacy Connect redemption before enrollment so a retry reuses it. The installer upgrades v1 environment bundles before authenticated artifact downloads. + +## DigitalOcean dev boxes + +`bb digitalocean configure ''` sets `idleMinutes` (null +turns idle stop off), `retention` (default 2), and `schedule` (null disables; +otherwise `weekdays` 0–6, `sleep`/`wake` HH:mm, and explicit IANA `timezone`). +`bb digitalocean snapshot-now ` drains through core, gracefully shuts +down, confirms off, snapshots and remains off. `sleep` does the same; `wake` +resumes through core. Busy threads and open terminals prevent sleep. Core also +wakes on dispatch. Empty boxes participate in opt-in idle stop; retirement stays +never. `status` and `cost` show live inventory and estimates; all accept `--json`. +`bb machine show --json` includes provider inventory in `providerDetails`. + +Powered-off droplets still bill; snapshot storage bills per GB. See +https://docs.digitalocean.com/products/droplets/details/pricing/ and +https://docs.digitalocean.com/products/snapshots/details/pricing/ . Configure a +weekday schedule from the plugin settings or CLI on an always-on BB server. +The latest missed action within eight days runs after recovery; busy sleep +retries each minute until superseded. See the plugin skill for DST and cleanup. + +Resume waits for any in-progress suspension before waking; an already-active +machine is left active. DigitalOcean sleep JSON retains saved power/backup +status if inventory is unavailable (`details.values.cost: null` and +`inventoryError`). Shared inventory reads cache for 30 seconds and invalidate +on mutations. Schedule changes invalidate selected, undispatched runs. + +Create DigitalOcean dev boxes from Settings → Machines or +`bb machine create --provider digitalocean --inputs '{}' --json`, without a +project. SDK creation uses `machineProviderId: "digitalocean", projectId: null, +inputs: {}`. Enrolled boxes appear as machine sections in the composer picker; +DigitalOcean contributes no new-machine/project-checkout shortcut row. + +Existing machines + +`bb machine create --provider manual` prints a private enrollment command and +follows the host until the daemon connects. Run that command on the target +machine; it installs bb if needed. Server access is resolved through the selected +default access provider, just like SSH or cloud machines. `--no-wait` returns the +creating host ID. The CLI prints the enrollment command and its expiry while it +follows. This command is built transiently from the in-memory pending bundle; +durable host progress contains no credential. After enrollment or removal, the +host-keyed command endpoint returns no command. Treat it as a credential. + +Use `bb machine show ` to recover progress and +`bb machine remove ` to cancel and revoke enrollment/access. Stopping +the CLI or closing the dialog only stops following; creation continues. +Manual machines never idle-suspend or automatically retire and do not expose +suspend/resume. Removing one revokes its server access without executing on the +machine. Run the original installer with `--uninstall --host-id ` on that box, with its +original `BB_DATA_DIR` if explicitly configured, to remove its installation. + +## Machine environment + +`bb machine env list --json` lists global machine variables and built-in GitHub +health. `bb machine env set NAME [--note text] --json` reads its value +from stdin, removing one trailing newline; values are never accepted in argv. +`bb machine env unset NAME --json` removes an override. All values are encrypted in the database and never returned by list or set. + +Settings → Machines → Machine environment edits variables inline. Add, remove, +or import .env rows, then Save variables; Discard changes leaves saved values +untouched. Saved secrets can be replaced but never revealed. The automatic +GH_TOKEN row shows server login health; a custom GH_TOKEN overrides it. User variables +override built-in values for all enrolled machine hosts, excluding local hosts. +Agent-provider variables win over these host values for agent turns. Changes +apply to the next turn, setup operation, or newly opened BB terminal; existing +terminals retain their launch environment. Runtime output is forwarded as-is, +so commands and providers can print contributed values. + +The server's gh login provides GitHub credentials, a Git environment-only HTTPS +helper and SSH rewrites, and commit identity. The built-in row reports logged in, +not logged in, or overridden. No credentials are installed in images or global +Git config. SDK: system.machineEnvironment() and +system.replaceMachineEnvironment({ variables }). Replacement is atomic; pass +every row to retain, using value: null for an unchanged saved secret. + +Thread startup does not install or update agent CLIs, probe authentication, or validate workspace fingerprints. Core runs repository setup when creating an owned environment and teardown before removing it. Resume does not rerun setup. + +`bb machine list --json` includes lifecycle phase, progress, and any suspension or resume error. +Maintenance interrupts active turns and closes terminals before saving. Submit a +new continuation turn after restore; interrupted turns are never reported successful. + +Resuming a machine restores its provider state without rerunning environment setup. + +Automatic machine GitHub credentials are enabled by default. Use +`bb settings general machineGitCredentialsEnabled false` to stop forwarding the +server gh credentials to machines; `true` enables them again. In Machines → +Advanced settings, the automatic GH_TOKEN switch controls the same setting. +This does not log the server out or suppress an explicit custom GH_TOKEN. +Changes apply to new turns, setup commands and terminals. + +Manual enrollment commands display the server expiry timestamp as a countdown. +After expiry, Add a machine offers Generate new command: it cancels the old +attempt and creates a fresh one. Manual owns the command and expiry in memory; +polling does not renew it. Restart machine setup if the plugin or server restarts. + +For a new thread on a new Modal sandbox, select the environment composition: +`bb thread spawn --project --environment-provider modal-sandbox --prompt "..."`. +It creates the machine, prepares the project checkout, and runs environment setup. +Progress and failures appear in the thread's provisioning details. If cloning +fails, the machine remains available for retry or explicit removal. +`--new-machine ` requires an explicit `--environment-provider `; machine +providers do not implicitly choose an environment. diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 1d69fa86b1..a7797fbc17 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -904,3 +904,16 @@ bot), agent-enrichment (agent surfaces), and composer-customization (all composer regions). Thread Hover Cards installs from the BB Community marketplace (source: the bb-plugins repo). + +Modal setup uses `bb modal account inspect --json` to check credentials, then +`bb machine create --provider modal-sandbox --project PROJECT --json` to create a +machine. Settings edits its shared Dockerfile; `bb modal image set --file PATH [--json]` saves it and `bb modal image reset [--json]` restores the bundled default for future machines; `bb modal image show [--json]` +reads the same file without cloud access. The image builds automatically and is reused across projects; +core installs the daemon on demand. Project dependencies and services belong in +`.bb-env-setup.sh`. Read the plugin's skill for connection and lifecycle details. + +Contributed commands may accept `--stdin`: the calling CLI transfers up to +256 KiB of multiline text as `--input-text`, without reading server-local files. +The existing `---stdin` form still accepts one line. + +Modal image debugging: `bb modal image build [--json]` prepares the saved image; `bb modal sandbox run [--json]` starts a 30-minute standalone sandbox; `bb modal sandbox exec ID [--json] -- COMMAND...` runs a command (60-second timeout); `bb modal sandbox stop ID [--json]` cleans up. These debug sandboxes skip BB enrollment, clone and setup. Logs are returned after the build finishes. diff --git a/packages/test-helpers/src/domain-fixtures.ts b/packages/test-helpers/src/domain-fixtures.ts index 5868ceb0f9..f10df295b1 100644 --- a/packages/test-helpers/src/domain-fixtures.ts +++ b/packages/test-helpers/src/domain-fixtures.ts @@ -64,8 +64,16 @@ export function makeHost(overrides: Partial = {}): Host { return { id: "host_test", name: "Test host", - status: "connected", type: "persistent", + status: "connected", + machineProviderId: null, + lifecycle: { + phase: "active", + suspendedAt: null, + message: null, + pendingLog: "", + teardown: null, + }, lastSeenAt: null, maxPermissionMode: "full", lastRejectedProtocolVersion: null, diff --git a/packages/thread-view/src/parse-operation-message.ts b/packages/thread-view/src/parse-operation-message.ts index a0963dc218..2202bd23a5 100644 --- a/packages/thread-view/src/parse-operation-message.ts +++ b/packages/thread-view/src/parse-operation-message.ts @@ -483,7 +483,12 @@ export function parseOperationMessage( const detail = decoded.entries .map((entry) => { - const source = entry.source === "shell" ? "shell" : entry.source.plugin; + const source = + entry.source === "shell" + ? "shell" + : "plugin" in entry.source + ? entry.source.plugin + : entry.source.core; const value = typeof entry.value === "string" ? entry.value : "••••••"; const reason = entry.reason ? ` — ${entry.reason}` : ""; return `${entry.name}=${value} (${source})${reason}`; diff --git a/plugins/automations/src/automations.test.ts b/plugins/automations/src/automations.test.ts index ec348005de..864df7b515 100644 --- a/plugins/automations/src/automations.test.ts +++ b/plugins/automations/src/automations.test.ts @@ -808,7 +808,6 @@ describe("automation data access", () => { { id: "host_test", name: "host", - type: "persistent", status: "disconnected", lastSeenAt: null, createdAt: 1, diff --git a/plugins/bb-guide/skills/bb-cli/SKILL.md b/plugins/bb-guide/skills/bb-cli/SKILL.md index 3b85d37401..415c364640 100644 --- a/plugins/bb-guide/skills/bb-cli/SKILL.md +++ b/plugins/bb-guide/skills/bb-cli/SKILL.md @@ -57,6 +57,27 @@ BB_HOST_DAEMON_PORT only for an intentional non-default target. providers that accept `{}` use it when the flag is omitted (`bb environment providers --json` prints both facts). `--base-branch` belongs to `--new-environment worktree` only. +- Enroll an existing machine with `bb machine create --provider manual`; run + the printed command on the target. `--no-wait` returns its host ID. + Cancel with `bb machine remove `. Removal revokes access; use the + original `install-machine.sh --uninstall --host-id ` on that box. +- Create a standalone machine with `bb machine create --provider `; use + `--inputs ` for non-secret provider inputs and `--key` for retry identity. +- List plugin-provisioned machine choices with `bb machine providers`. Create a + machine and an explicit environment with + `bb thread spawn --new-machine --environment-provider `; add + `--machine-inputs ` when its schema requires inputs. Machine inputs are + persisted and non-secret; credentials belong in plugin settings. Composed + environments choose their own machine: use `--environment-provider modal-sandbox` + without machine selectors and pass `--machine-inputs ` when configuring + the composition's machine provider. +- Use `bb machine enroll` for a private core-prepared bundle. Local lifecycle is + handled by `install-machine.sh --start|--stop|--uninstall --host-id `; + see references/thread-creation.md for ownership checks. +- Use `bb machine suspend|resume ` only for providers that expose + suspend and resume. Resume waits for pending suspension and is a no-op + when already active. Use `bb machine retry-cleanup ` to retry a + failed provider teardown immediately. - `bb environment providers` lists Project checkout, Worktree, then other installed providers by display name. With `--project --machine ` it also prints that machine's availability (`available`, `setup-required`, @@ -64,7 +85,7 @@ BB_HOST_DAEMON_PORT only for an intentional non-default target. through `bb settings show` and `bb settings general `. - The server keeps a registry of sidebar layout preferences (organization mode, section order, collapsed rows, navigation entries): `bb settings ui - list`, `get`, `set`, and `reset`. +list`, `get`, `set`, and `reset`. - Query provider models on the machine that will run the thread. - Prefer non-interactive commands and machine-readable output for automation. - Pass `--yes` for a confirmed destructive command in a non-interactive shell. @@ -112,3 +133,8 @@ plugins; do not add plugin command manuals here. ## Built-in browser control Use `bb browser instances --host --json` to discover a desktop. Commands `tabs`, `create`, `acquire`, `connection`, `release`, `reveal`, `capture`, `close`, and `watch` require explicit `--host`, `--instance`, `--generation`, and `--thread`. See `bb guide browser` and `bb browser --help` for flags. New tabs use separate automation profiles; personal-tab control needs an explicit handoff. Revealing tabs or acquiring control opens the side panel and selects the tab only in the already focused thread, without switching threads or activating the desktop window. Connection credentials are written with `connection --output ` and work only on the browser host; keep them out of chat and public port shares. `import-sources` and `import-cookies --from --profile [--into personal|automation:]` copy signed-in cookies from an installed browser into a BB browser profile; they need `--host`, `--instance`, and `--generation` only, and the source browser must be quit first. + +`bb machine show --json` includes provider-owned inventory and +estimates in `providerDetails` when available. Provider inventory failures are +reported; this is not billing/invoice data. Suspension requires idle live threads +and no open terminals; empty machines can use an opted-in provider idle policy. diff --git a/plugins/bb-guide/skills/bb-cli/references/app-settings.md b/plugins/bb-guide/skills/bb-cli/references/app-settings.md index 945770f08d..3ea832d03e 100644 --- a/plugins/bb-guide/skills/bb-cli/references/app-settings.md +++ b/plugins/bb-guide/skills/bb-cli/references/app-settings.md @@ -138,3 +138,18 @@ every window and client sees the same value. - Enable it with `bb settings experiment timelineWindowing true`. - It keeps stable timeline wrappers while mounting only rows near the active main or nested detail scrollport. + +Machine access: `bb settings general machineServerUrl https://bb.example.com` +sets the server URL reachable by machines. Set `null` to use BB_EXTERNAL_URL. +`bb settings general defaultMachineAccess direct` selects direct access; +`connect` selects bb Cloud; `null` selects paired Connect, otherwise direct +when a URL exists. `bb settings show --json` includes serverAccess with the +effective URL, its source and provider availability. These grants carry runtime +requests, including account-pool traffic, after enrolment. + +Automatic machine GitHub credentials are enabled by default. Use +`bb settings general machineGitCredentialsEnabled false` to stop forwarding the +server gh credentials to machines; `true` enables them again. In Machines → +Advanced settings, the automatic GH_TOKEN switch controls the same setting. +This does not log the server out or suppress an explicit custom GH_TOKEN. +Changes apply to new turns, setup commands and terminals. diff --git a/plugins/bb-guide/skills/bb-cli/references/command-index.md b/plugins/bb-guide/skills/bb-cli/references/command-index.md index 6e28ce4663..2fd103dcbc 100644 --- a/plugins/bb-guide/skills/bb-cli/references/command-index.md +++ b/plugins/bb-guide/skills/bb-cli/references/command-index.md @@ -67,16 +67,32 @@ This index lists every command path that the core CLI registers. Read the task-s ## machine - `bb machine` +- `bb machine providers` +- `bb machine enroll` +- `bb machine env` +- `bb machine env list` +- `bb machine env set` +- `bb machine env unset` +- `bb machine create` - `bb machine list` - `bb machine show` - `bb machine join-code` - `bb machine rename` - `bb machine remove` +- `bb machine suspend` +- `bb machine resume` +- `bb machine retry-cleanup` - `bb machine retry-update` - `bb machine provider-cli` - `bb machine provider-cli status` - `bb machine provider-cli install` +`bb thread spawn --new-machine ` creates a machine for a new +environment and requires `--environment-provider `. For a composed option, +use `--environment-provider modal-sandbox` alone. `--machine-inputs ` +configures the machine with optional configured `preset` and `image` names; +`--environment-inputs ` configures the workspace. Neither carries secrets. + ## updates - `bb updates` @@ -273,3 +289,8 @@ This index lists every command path that the core CLI registers. Read the task-s - `bb browser watch` - `bb browser import-sources` - `bb browser import-cookies` + +Machine creation is durable: `create --no-wait` returns the creating host ID. `machine show ` reads progress and `machine remove ` cancels it. SIGINT only stops following. + +Machine environment: `bb machine env list`, `bb machine env set NAME` +(value from stdin), and `bb machine env unset NAME`; all accept `--json`. diff --git a/plugins/bb-guide/skills/bb-cli/references/configuration.md b/plugins/bb-guide/skills/bb-cli/references/configuration.md index c4cd7bdf9e..fcaf2cdbbf 100644 --- a/plugins/bb-guide/skills/bb-cli/references/configuration.md +++ b/plugins/bb-guide/skills/bb-cli/references/configuration.md @@ -101,3 +101,33 @@ to warm Turbo while a live instance serves its existing files, then prepare the stable serving checkout before launch. See `docs/debugging-and-qa.md` and `bb guide environments`. These source-maintenance commands are separate from installed `bb` commands and `.bb-env-setup.sh`. + +## Machine access and isolated data + +Machine access `machineServerUrl` is the URL reachable by machines; unset uses +`BB_EXTERNAL_URL`. `defaultMachineAccess` selects an access provider; unset +prefers paired Connect, then direct when a URL exists. Inspect effective values +with `bb settings show --json` and change them with `bb settings general`. +`BB_DATA_DIR` selects isolated enrollment state. Local machine lifecycle commands +treat it as an ownership assertion and refuse the default BB installation; see +thread-creation.md and docs/configuration.md for the directory constraints. + +Machine enrollment v2 stores private `serverHeaders` in machine `config.json`. +The launcher transports these through `BB_SERVER_HEADERS` (JSON string map) for +all server requests. Do not print these headers; they can contain access tokens. + +## Machine environment + +Use `bb machine env list --json` for variables and built-in gh health. +`bb machine env set NAME [--note text] --json` reads the value from +stdin and removes one trailing newline; never pass secrets in argv. Runtime +output is forwarded as-is, so commands and providers can print contributed +values. `bb machine env unset NAME --json` removes an override. All values are +encrypted in the database and omitted from settings responses. + +These settings apply globally to enrolled machines, not local hosts, on each +agent turn, setup command, and new BB terminal. User values override built-ins; +agent-provider entries override host values. Reopen existing terminals after a +change. The server gh login provides GitHub Git/gh authentication and commit +identity by default; a user GH_TOKEN replaces it. See Settings → Machines → +Machine environment, and `bb machine env list` for builtInGit readiness. diff --git a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md index 41bbc005b9..6bd0f36b7c 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md @@ -15,6 +15,15 @@ `--environment-inputs ` only when the provider's schema does not accept an empty object; otherwise the CLI supplies `{}` when the flag is omitted. `--machine` picks the existing machine. +- List machine providers with `bb machine providers`. Create a + new provider machine with + `bb thread spawn --new-machine --environment-provider `. + For Modal's composed environment, use `--environment-provider modal-sandbox` + without machine selectors; `--machine-inputs ` configures that composed + machine with optional configured names such as + `{"preset":"Large","image":"Node 22"}`. These inputs are persisted and + readable by plugins, so keep credentials in plugin settings and send only + non-secret configuration or references. - Omit `--base-branch` for bb's default. Explicit values are exact; use `origin/` for a remote ref. It applies to `--new-environment worktree` only; a provider takes its branch through `--environment-inputs`. @@ -74,8 +83,8 @@ worktree` only; a provider takes its branch through `--environment-inputs`. surface that sets it, and machine credentials are refused — so read it from `bb machine list --json` or `bb machine show` and ask the user to change it in the app. -- `bb machine list`, `show`, `join-code`, `rename`, `retry-update`, - and `remove` cover the Settings → +- `bb machine providers`, `show`, `join-code`, `rename`, `retry-update`, + `suspend`, `resume`, `retry-cleanup`, and `remove` cover the Settings → Machines lifecycle. Use `bb machine provider-cli status|install` to inspect or install provider CLIs on a selected machine. - `bb updates` runs the default `bb updates status` action. It aggregates BB and provider @@ -162,9 +171,47 @@ or artifacts, validation performed, and blockers. `bb environment show ` includes the core-owned lifecycle phase, retirement deadline, and teardown status/attempt/message. Archive or delete the last live thread to begin its provider's retirement grace; unarchive cancels pending retirement. Teardown failures retry automatically. Checkout policy keeps its directory indefinitely. +### Standalone machine creation + +`bb machine create --provider [--key ] [--inputs ] +[--no-wait] [--json]` creates a machine without a thread. Omitted +inputs are null and must satisfy the provider schema; omitted key is generated +by the server. Supply a stable key to recover the same creation across retries. +Creation is durable. `--no-wait` returns the host ID; `bb machine show +` inspects it and `bb machine remove ` cancels it. SIGINT +stops following and exits with status 130 while creation continues. + +`bb machine show --json` includes `providerDetails` inventory and +estimates when available. Suspend requires idle threads and no open terminals; +empty machines use the provider’s opt-in idle timeout. Resume waits for pending +suspension and leaves an already-active machine active. + +### Local machine lifecycle + +`install-machine.sh --start|--stop|--uninstall --host-id ` operates on that machine's local +installation. Optional `--server-url` and `--data-dir` assert its identity and +installation location; BB_DATA_DIR is also an assertion, never permission to +remove another installation. Uninstall checks ownership before stopping its +service, releasing its port reservation and deleting its private files. + +### Private machine enrollment + +Use `bb machine enroll --bootstrap-file ` or `--bootstrap-env ` on a machine that already has the CLI. Core prepares the versioned bundle; transport it through a private file or environment/stdin, never command arguments, logs, resource JSON, or a transcript. Enrollment refuses a different existing host/server identity and succeeds without another exchange when the same identity is already enrolled. The installer accepts `--bootstrap-env ` and invokes this command after installing bb. Machine state defaults to `~/.bb-machines/`; an explicit `BB_DATA_DIR` must be isolated from the default BB instance. For remote non-login commands, discover `bb` on PATH and fall back to `~/.local/bin/bb`. + +Delivered enrollment bundles from v1 remain valid until their expiry. The CLI accepts both file and environment forms, upgrades the bundle to v2 headers locally, and persists legacy Connect redemption before enrollment so a retry reuses it. The installer upgrades v1 environment bundles before authenticated artifact downloads. + +The core `manual` provider appears as Manual machine setup. `bb machine create --provider manual` prints the enrollment command and follows; `--no-wait` returns the creating host ID. Commands are no longer available after enrollment or removal. Manual machines never suspend or retire automatically. Removal revokes access; run the original installer with `--uninstall --host-id ` on the target using its original data directory. + For paths a provider owns, bb runs `.bb-env-setup.sh` after create and `.bb-env-teardown.sh` before remove on that machine, with separate 15-minute timeouts. Setup failure fails the launch with output in provisioning progress; -teardown script failure is logged and removal continues. Attaching a project -checkout or personal workspace skips both hooks. Providers do not run these +teardown script failure is logged and removal continues. Attaching a user-maintained project checkout or personal workspace skips both +hooks. A fresh core clone on a new machine is owned and runs the hooks. Providers do not run these core hooks themselves. +Thread startup does not validate agent credentials, fingerprint the checkout, or install agent CLIs. + +`bb machine list --json` includes lifecycle phase, progress, and any suspension or resume error. +Maintenance interrupts active turns and closes terminals before saving. Submit a +new continuation turn after restore; interrupted turns are never reported successful. + +Resuming a machine restores its provider state without rerunning environment setup. diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/SKILL.md b/plugins/bb-guide/skills/bb-plugin-authoring/SKILL.md index d6748ed71d..af8cae05ef 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/SKILL.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/SKILL.md @@ -42,8 +42,10 @@ the same change. interactions, provider models, browser sessions, and event history. - Read references/backend-api-index.md to check every public backend, host, AI-service, and test export. -- Read references/backend-events.md for lifecycle events, environment and +- Read references/backend-events.md for lifecycle events, environment providers, HTTP, RPC, realtime, background services, and schedules. +- Read references/backend-machines.md for machine providers, core project source + setup, enrollment/bootstrap helpers, and server access. - Read references/backend-cli-agents.md for CLI commands, input forms, agent tools, agent configuration, and helper AI services. - Read references/providers.md only when the plugin registers an agent provider. diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md index 443dfa2942..4fc4438493 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md @@ -73,6 +73,17 @@ Read the installed declarations for exact current signatures. - `PluginDispatchExecutionSources` - `PluginEnvironments` — `bb.experimental_environments`: `register` + `recheck` (see backend-events.md, environment providers) +- `PluginServerAccess` — `bb.experimental_serverAccess.register` +- `ServerAccessProviderDeclaration` +- `ServerAccessGrant` +- `ServerAccessSelection` +- `PluginMachines` — `bb.experimental_machines.register` and bootstrap helper (see backend-machines.md) +- `MachineExecutorRequest` — argv, timeout, signal, optional private stdin +- `MachineExecutor` — transport exec +- `MachineBootstrapRequest` — durable key, optional executor and access selection, report, signal +- `MachineBootstrapApi` — bootstrap +- `PluginMachineProviderDeclaration` +- `PluginMachineValidateDecision` - `PluginEnvironmentProviderDeclaration` - `PluginEnvironmentProviderRequirements` — `requires`, e.g. `{ gitCheckout: true }`; also `projectCheckout`, `gitRemote` and `projectless`. @@ -279,6 +290,23 @@ Read the installed declarations for exact current signatures. `resource` returned by the launch that made the environment - `PluginEnvironmentProviderRemoveResult` +## `@get-bb/plugin-sdk/machine-provider` + +- `PluginMachineProviderDefinition` — id, display, description, icon, inputs, + availability, validation, create, optional paired suspend/resume, + `ephemeral` automatic retirement policy, and remove +- `PluginMachineProviderInputsSchema` +- `PluginMachineProviderAvailability` +- `PluginMachineProviderValidateContext` +- `PluginMachineProviderCreateContext` — async `checkpoint(resource)` after + preparing enrollment and allocating, before bootstrap; never bundle credentials +- `PluginMachineProviderCreateResult` +- `PluginMachineProviderLifecycleContext` — shared create, suspend and resume + context with a durable `checkpoint` resource callback +- `PluginMachineProviderProgress` +- `PluginMachineProviderResourceResult` +- `PluginMachineProviderRemoveResult` + ## `@get-bb/plugin-sdk/ai-services` - `experimental_aiInferenceCompleteInputSchema` diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-events.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-events.md index cb41ec22b1..6f2f53e936 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-events.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-events.md @@ -3,6 +3,8 @@ ### bb.events.on — lifecycle events ```ts +bb.events.on("experimental_thread.events", ({ thread, sequence }) => { ... }); +bb.events.on("experimental_terminal.input", ({ terminal }) => { ... }); bb.events.on("thread.created", ({ thread }) => { ... }); bb.events.on("thread.active", ({ thread }) => { ... }); bb.events.on("thread.idle", ({ thread, lastAssistantText }) => { ... }); // lastAssistantText: string | null @@ -22,7 +24,7 @@ handler is told, and whatever it returns is IGNORED. The surface that ASKS is `bb.experimental_hooks`, below, where core acts on your answer — the same split git draws between post-commit and pre-commit hooks. -Twelve events. The seven `thread.*` ones are thread lifecycle. `interaction.pending` +Fourteen events. The seven `thread.*` ones are thread lifecycle. `interaction.pending` fires after core commits a pending interaction row. The three `message.*` ones fire when a dispatch is queued behind a wait, when a queued row's waits all clear and it dispatches, or when the queued row is cancelled. Every listener sees every queued row, so a plugin @@ -98,6 +100,17 @@ always in the timeline yet. To react to a thread's content, listen on in a handler — including `bb.sdk.threads.update({ threadId, title })` — cannot delay or interrupt the thread's turn. +`experimental_thread.events` notifies that the thread event sequence advanced. Core +coalesces appends per thread into one notification per second, with the latest sequence +and current thread DTO. Continuous output produces periodic updates and a final pending +update. Reading history does not notify. The payload contains no event contents; use the +existing thread-events SDK if your policy needs them. Modal v1 simply checks whether +the delivered thread is active before extending its idle deadline. + +`experimental_terminal.input` fires after nonempty real user input is forwarded to a +terminal. Its public terminal DTO includes hostId; keystrokes are not included. Output, +keepalives and opening a terminal do not count. + ### bb.experimental_hooks — the dispatch checkpoint **Hooks are questions core asks.** Core stops, hands your handler a context, and diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-foundation.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-foundation.md index 6a0d3a75cd..aec53f7972 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-foundation.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-foundation.md @@ -21,7 +21,7 @@ reports the reload failure in its detail. `bb.pluginId` is the plugin's own id. The complete top-level factory API is `pluginId`, `log`, `settings`, `storage`, `http`, `rpc`, `realtime`, `background`, `cli`, `agents`, `providers`, `ui`, `events`, `experimental_hooks`, `experimental_environments`, -`status`, `server`, `hosts`, +`experimental_machines`, `experimental_serverAccess`, `status`, `server`, `hosts`, `experimental_aiServices`, `sdk`, and `onDispose`. Keyed registrations must be unique within one factory execution: duplicate diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-machines.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-machines.md new file mode 100644 index 0000000000..b43d962f86 --- /dev/null +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-machines.md @@ -0,0 +1,191 @@ +# Machine providers and server access + +### Machine providers: core-owned machines + +Register machine resource operations with `bb.experimental_machines.register`. +An explicit environment composition first +creates the machine, then asks its named environment provider for a workspace +on that machine. After a new machine connects, core sets up the project's Git +remote on that host and registers its source before invoking an environment +provider that requires `projectCheckout`, if no source exists yet. This reuses +Set up on machine; machine plugins do not clone projects. An existing source is +reused. Core shares concurrent setup per project/host and recovers a completed +clone at its stable project-ID target after a crash by verifying the remote and +registering its source. Providers without that requirement, including personal workspace, do not +trigger source setup. The Machines page and `bb.sdk.hosts.experimental_create` can instead create +a standalone machine without project context. Project source setup happens later when an environment needs it. + +`description` and `icon` are required. The icon supplies the normal provider glyph or plugin-relative SVG; a React icon slot can customize its presentation. + +The provider display name and icon are the machine kind shown next to the name +of every machine that provider creates. Manually enrolled machines have no kind. + +Set `ephemeral: true` only when the provider creates disposable +compute. Core then automatically requests machine removal when no live thread or +live thread's creating/ready machine launch still needs it, regardless of any +attached environment's retirement policy. The default is false, so manually enrolled machines +and provider-managed machines intended to persist are never removed automatically. + +```ts +bb.experimental_machines.register({ + id: "custom-machine", + displayName: "Custom machine", + description: "Create a machine with custom compute.", + icon: "Server", + ephemeral: true, + inputs: z.object({ target: z.string() }), + async create({ inputs, key, checkpoint, report, signal }) { + const target = await allocateTarget({ target: inputs.target, key, signal }); + const resource = { target: target.id }; + await checkpoint(resource); + const { hostId } = await bb.experimental_machines.bootstrap({ + key, + executor: target.executor, + report, + signal, + }); + return { + status: "created", + name: `Custom machine ${hostId.slice(-6)}`, + resource, + }; + }, + async remove({ resource }) { + const owned = z.object({ target: z.string() }).parse(resource); + await disconnectTarget(owned.target); + return { status: "removed" }; + }, +}); +``` + +A machine is not scoped to a project: nothing about creation names one, and +projects reach a machine later through project sources. Optional Standard +Schema `inputs` are parsed before create and persisted on the launch. Never put +secrets there. Store credentials in plugin settings +and pass a non-secret reference such as a target name in inputs. + +Create receives parsed inputs, a stable key, monotonic attempt, durable +progress reporter, and abort signal. It must be +idempotent by key: if enrolment completed before the server crashed, the next +call reuses the already-enrolled host instead of creating another resource. +Call `await checkpoint(resource)` after durable allocation and before +bootstrap. Create's checkpoint is asynchronous and makes +partial allocation recoverable even if enrollment never succeeds. Never put the +bootstrap bundle in resource JSON. Return a readable name and private JSON +resource for later lifecycle operations; core uses the host identity reserved +on the launch. Core falls back to the provider display name plus a short +identity suffix for older providers that omit the name. +`allocateTarget` and `disconnectTarget` above +stand for provider-owned allocation, transport, and idempotent cleanup; removal +must handle a checkpointed target whose daemon was never installed or enrolled. +Core owns enrollment, identity files, and daemon installation internals. +Automatic unresolved cleanup retries are bounded to a 30-minute launch window; +unresolved cleanup remains recorded for operator reconciliation. + +Machine registration does not contribute environment-picker entries. Register +an environment composition with `machineProviderId` and `environmentProviderId` +to offer a new machine plus a concrete environment. Modal combines its machine +with `project-checkout`; core prepares the missing checkout. CLI users select +`--environment-provider modal-sandbox` without machine selectors and may pass +`--machine-inputs ` for the composition's machine inputs. Explicit +`--new-machine ` always requires `--environment-provider `. + +Suspend and resume are optional but must be declared together. Providers own idle +timing and request pause through the host SDK. Core interrupts active work before +stopping the host daemon and invoking suspend, and resumes before queued execution. +Suspend receives `checkpoint(resource)`, which synchronously +persists a recoverable private resource before destructive cleanup. Use it +after creating a recovery artifact and before terminating the live machine or +deleting an older artifact. A replay receives the last checkpoint. +Resume receives an awaitable `checkpoint(resource)`. Call it immediately after +restoring or allocating compute and before bootstrap. Core fences the provider +owner, lifecycle phase and persisted operation ID, and restart passes the last +checkpoint back with the same enrollment identity. A stale callback rejects. +Allocation checkpoints are recovery records, not filesystem saves: providers +must create any filesystem snapshot themselves. Daemon-connected is not +agent-ready; checkout setup and provider authentication still need to complete. + +Standalone `bb machine create` and `bb.sdk.hosts.experimental_create` create a +durable host and follow its progress. `create --no-wait` returns the creating +host ID; `machine show` / `hosts.get` poll it. `machine remove` / `hosts.delete` +cancel creation; closing a client or aborting its signal only stops following. + +Persistent-machine removal cascades through the machine's environment providers +before machine remove; failures persist and retry after the core one-minute retry +interval. Ephemeral-machine removal skips environment-provider teardown and never +resumes suspended compute for it. Once compute removal succeeds, core marks every +attached environment destroyed with teardown removed as read-only history. + +## Server access + +`bb.experimental_serverAccess.register` declares id, displayName, description, +availability, acquire({ key, hostId, signal }) returning a ServerAccessGrant or +`{ status: "failed", message }`, and release({ key, hostId, grantId }). Acquire +is idempotent by key. Return `{ id, serverUrl, headers?: Record }`; the grant serves runtime requests as well +as enrolment. Acquire must redeem provider-specific codes server-side and persist +the revocation identity before returning, so release works before enrolment. +Direct grants omit headers. Bootstrap carries the headers. Host metadata stores +the provider id and grant id; pending +bootstrap credentials are encrypted separately by core. +The failed result's message is deliberate user-safe recovery copy; ordinary +thrown errors stay redacted. Release receives a null grantId when acquire was interrupted. Core persists the +provider before acquisition and retries release by key and hostId. Keep intent +and credential-bearing grants in secret storage; only non-secret revocation +metadata belongs in KV. + +`attention()` optionally returns a user-safe diagnostic or null, +synchronously or asynchronously. Machines settings displays it independently of +availability; never include credentials or raw provider payloads. + +Machines settings select the default. Without a saved selection, core uses the +first registered provider, or direct when none are registered. Plugins can pass ServerAccessSelection +to the machine enrolment/bootstrap APIs. The direct provider reads +machineServerUrl, falling back to BB_EXTERNAL_URL. Declaring a URL does not +prove reachability from a sandbox. + +### Machine enrollment and bootstrap + +`bb.experimental_machines` implements `MachineBootstrapApi` alongside register: + +- `bootstrap({ key, executor?, access?, report, signal })` prepares or recovers + enrollment, installs or starts the daemon, waits for its + connection, and returns `{ hostId }`. When `executor` is omitted, it waits for + the user to run the manual command. Reuse the same key and access selection + used before the create checkpoint. Initial installation needs Node, npm, and + curl; the helper does not install OS packages. + +A `MachineExecutor` implements `exec({ command, timeoutMs, signal, stdin? })` +returning `{ exitCode, stdout, stderr }`. Execute argv through the provider's +transport, honor timeout and cancellation, and keep stdin private. +The helper suppresses remote output and reports fixed progress messages. It +restarts enrolled identities, including a restored preinstalled snapshot. +Create's awaited checkpoint precedes bootstrap; suspend's awaited checkpoint +persists a recovery artifact before destructive cleanup. + +### Coordinated suspension + +Own idle timing with plugin storage and background schedules. Subscribe to +`experimental_thread.events` and `experimental_terminal.input` to extend your deadline. +Modal extends its deadline for starting threads before environment attachment by +matching the thread to its machine launch key, and for starting and active threads +after attachment through the environment host. + +Call `bb.sdk.hosts.experimental_suspend({hostId})` for coordinated suspension. Core accepts follow-ups into the host-wait queue +and drains active turns, setup hooks and terminals with a five-minute bound before +calling your suspend callback. Persisted live thread launches, provisioning environments, +and project checkout setup on the host reject the request with `machine_busy`; an idle +scheduler should retry on its next sweep. +Persist opaque state with `checkpoint(resource)` before +terminating compute. The SDK request returns after suspension starts; observe the +host lifecycle when completion matters. Core serializes resource transitions and restores the same host +identity without rerunning checkout setup. + +Your plugin owns vendor observations, expiry scheduling, snapshot identifiers, +cleanup and explicit recovery from loss. Use `bb.background.schedule` plus startup +reconciliation; allow the full core drain bound, snapshot time and scheduler jitter. +Refuse unsafe recovery or preservation after a missed deadline. A dispatch hook can +help communicate status but is bypassable and does not protect terminal/file RPCs. +Expose vendor-specific snapshots and loss information through the plugin’s own RPC and CLI. + +The host DTO returned by `bb.sdk.hosts.get({hostId})` shows generic maintenance +state through lifecycle phase and progress. Core does not provide retention or keep controls. diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md index 08e6496cda..8a45992392 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-sdk.md @@ -183,3 +183,21 @@ path-shaped `baseUrl`. Append individually encoded relative path segments to serve browser assets from that confined host root. This is the preferred transport for plugin images and sandboxed HTML with sibling-relative assets; preview URLs expire and never reveal the host id or absolute root. + +## Standalone machines + +`bb.sdk.hosts.experimental_listProviders({ projectId? })` discovers machine providers and their +input schemas; its optional `projectId` only resolves the environment row shown +for a project. `bb.sdk.hosts.experimental_create({ machineProviderId, inputs, key?, wait?, signal? })` +returns a public Host; a machine belongs to no project, and `inputs: null` is +for a provider that accepts no inputs. Supply a stable key for +idempotent retries. The default waits until active; `wait: false` returns the +creating host for polling with `get`. Creation does not create an environment or a thread. +`bb.sdk.hosts.experimental_suspend({ hostId })` and `resume({ hostId })` require the provider's +paired suspend/resume operations. They return the updated public Host with HTTP +202 once the tracked operation starts; read its lifecycle state for completion. +`retryCleanup({ hostId })` retries failed +provider teardown. `get({ hostId })` additionally returns nullable +`connectMachineId` from trusted gate metadata for legacy access revocation; +Connect now persists its revocation identity during acquire, before enrollment. +Host lists do not expose that detail. diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md index 0a7c663d5c..7dc1cb2ff1 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md @@ -58,6 +58,10 @@ Read the installed SDK declarations for the exact current signatures. - `PluginEnvironmentProviderInputsProps` - `PluginEnvironmentProviderInputsRegistration` — the registration accepted by `app.slots.experimental_environmentProviderInputs` +- `PluginMachineProviderInputsChange` +- `PluginMachineProviderInputsProps` +- `PluginMachineProviderInputsRegistration` — the registration accepted by + `app.slots.experimental_machineProviderInputs` - `PluginSidebarFooterActionProps` - `ExperimentalSidebarFooterDisclosureProps` - `ExperimentalSidebarNavigationShortcut` diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md index f3f6751451..a44cd6ff9a 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md @@ -228,6 +228,21 @@ className?, draftKey? }` — the `default*` props are SEEDS, not controlled provider's control owns the base branch, and the checkout provider's owns the directory and the branch to switch to. + An environment composition declares `machineProviderId` and + `environmentProviderId`; choosing it creates the machine and runs the concrete + environment provider. It appears once, outside existing-host groups. + Machine-only registrations do not contribute environment-picker entries. + When the composition's machine provider declares inputs, its + `experimental_machineProviderInputs` compact chip renders before the + environment provider's inputs chip. It reports a ready default on mount and + opens richer configuration in the shared responsive drawer; a blocked or + crashed control disables submit with its short reason. + The Machines page renders the machine provider's icon and display name as the + kind next to each provider-created machine's name. Manually enrolled machines + have no kind. + Machine inputs are persisted and readable by every plugin, so never put + secrets in them; store credentials in plugin settings and emit only + non-secret configuration or references. Store-then-restore: the request's selection fields map to the `default*` seed props. The host composer creates `input` and `executionInputSources` from its draft and selection provenance. A plugin can re-open a saved diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md index 564c6fcecc..3bb832f7bf 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-renderer-slots.md @@ -135,3 +135,4 @@ providerId }`) and `Original`, the host's declarative base for the body — One registration per provider id per plugin; if two plugins claim one provider id the host keeps the first by plugin id and warns. See the `app.tsx` example in `references/providers.md`. + diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/providers.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/providers.md index 0ab643f2d8..493c5b07fa 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/providers.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/providers.md @@ -155,7 +155,6 @@ bb.providers.experimental_contributeEnv("claude-code", async (context) => [ name: "ANTHROPIC_BASE_URL", value: { serverPath: `/plugins/my-proxy/${context.hostId}` }, reason: "Route Claude through the plugin's authenticated proxy", - secret: true, }, ]); ``` @@ -163,7 +162,7 @@ bb.providers.experimental_contributeEnv("claude-code", async (context) => [ The server calls the resolver for every matching start, resume, fork, and turn command. Its `ExperimentalPluginProviderEnvContext` has `threadId`, `projectId`, and `hostId`; return at most 32 `ExperimentalPluginProviderEnvEntry` values. -Names must match `[A-Z_][A-Z0-9_]*`; `reason` and `secret` are required. A +Names must match `[A-Z_][A-Z0-9_]*`; `reason` is required. A literal `value` is forwarded as-is. `{ serverPath: "/..." }` is expanded by the selected host against its authenticated `BB_SERVER_URL`, which is the right form for a server route that must work from enrolled machines. @@ -171,9 +170,9 @@ right form for a server route that must work from enrolled machines. Contributions override the host shell environment. If multiple plugins return the same name, the earlier registration wins and BB logs the conflict. A resolver that throws, times out after five seconds, or returns invalid entries -contributes nothing for that command without blocking other plugins. Mark -credentials and sensitive URLs with `secret: true`; BB passes the real value -to the provider but masks it in `provider.env-resolved` timeline events. +contributes nothing for that command without blocking other plugins. BB passes +values to the provider and reports them as-is in `provider.env-resolved` +timeline events, provider output, and diagnostics. When the contributed environment supplies credentials that replace a local login, pair the resolver with diff --git a/plugins/bb-official.json b/plugins/bb-official.json index e85b55e5aa..c158ee66d8 100644 --- a/plugins/bb-official.json +++ b/plugins/bb-official.json @@ -137,5 +137,9 @@ "bb-guide": { "category": "memory-and-context", "screenshots": [] + }, + "environment-modal-sandbox": { + "category": "environments", + "screenshots": [] } } diff --git a/plugins/connect/README.md b/plugins/connect/README.md new file mode 100644 index 0000000000..54a238c4e2 --- /dev/null +++ b/plugins/connect/README.md @@ -0,0 +1,20 @@ +# Connect server access + +Connect redeems machine codes on the server and returns the grant's server URL +and authentication headers. Its existing plugin KV stores one record per machine, +under `server-access-grant:`, outside settings descriptors and the UI. + +The record contains either a pending redemption (code and expiry) or a completed +grant (credentials and Cloud device ID). Connect persists the pending record +before redeeming and the completed grant before returning it to core. + +If a redemption response is lost, acquire and release look up the original code +through the authenticated Cloud machine-code endpoint. If consumed, Connect +revokes its device before issuing a replacement. If unconsumed, a valid code can +be reused; an expired code is replaced. An unavailable or ambiguous lookup keeps +the pending record and reports that dashboard revocation may be needed. It does +not silently issue another grant. Acquire reports this recoverable state with a +typed failed result; unexpected thrown errors remain private at the plugin boundary. + +Release revokes the completed grant's device even if enrollment never finished. +The record is deleted only after successful cleanup; failures keep it for retry. diff --git a/plugins/connect/src/machine-code.ts b/plugins/connect/src/machine-code.ts index 9442027546..b0710fbb45 100644 --- a/plugins/connect/src/machine-code.ts +++ b/plugins/connect/src/machine-code.ts @@ -49,3 +49,25 @@ export async function fetchMachineCode( serverUrl: parsed.data.serverUrl, }; } + +export async function lookupMachineCode( + credential: ConnectCredential, + code: string, +) { + const response = await fetch( + `${deriveConnectBaseUrl(credential.serverUrl)}/api/connect/machine-code`, + { + method: "GET", + headers: { + "x-bb-connect-machine": credential.credential, + "x-bb-connect-code": code, + }, + signal: AbortSignal.timeout(10_000), + }, + ); + if (!response.ok) + throw new Error(`Machine code lookup failed (${response.status})`); + return z + .object({ consumed: z.boolean(), machineId: z.string().nullable() }) + .parse(await response.json()); +} diff --git a/plugins/connect/src/redeem.ts b/plugins/connect/src/redeem.ts index 12a390493c..444609168b 100644 --- a/plugins/connect/src/redeem.ts +++ b/plugins/connect/src/redeem.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import { deriveConnectBaseUrl } from "@bb/connect-client"; export const DEFAULT_CONNECT_BASE_URL = "https://getbb.app"; export function resolveDefaultConnectBaseUrl(env: NodeJS.ProcessEnv): string { @@ -95,3 +97,27 @@ export async function redeemConnectCode(args: { const data = (await res.json()) as RedeemedCredential; return { credential: data.credential, handle: data.handle }; } + +export async function redeemMachineCode(args: { + code: string; + serverUrl: string; +}): Promise<{ credential: string; machineId: string; serverUrl: string }> { + const response = await fetch( + `${deriveConnectBaseUrl(args.serverUrl)}/api/connect/redeem-machine`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: args.code }), + signal: AbortSignal.timeout(10_000), + }, + ); + if (!response.ok) + throw new Error(`Machine redeem failed (${response.status})`); + return z + .object({ + credential: z.string().min(1), + machineId: z.string().min(1), + serverUrl: z.string().url(), + }) + .parse(await response.json()); +} diff --git a/plugins/connect/src/server-access.test.ts b/plugins/connect/src/server-access.test.ts new file mode 100644 index 0000000000..8116978b46 --- /dev/null +++ b/plugins/connect/src/server-access.test.ts @@ -0,0 +1,343 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createFakePluginHost, + type FakePluginHost, +} from "@get-bb/plugin-sdk/testing"; +import { + createServerAccessRecheck, + registerServerAccess, +} from "./server-access.js"; + +const credential = { + serverUrl: "https://test.getbb.app", + handle: "test", + credential: "bbcred_private_server", +}; +const tunnel = { + getCredential: () => credential, + status: () => ({ paired: true, url: credential.serverUrl }), +}; +const request = { + key: "launch-key", + hostId: "host-pending", + signal: new AbortController().signal, +}; +const key = "server-access-grant:host-pending"; +const hosts: FakePluginHost[] = []; +async function setup(beforeInit?: (host: FakePluginHost) => Promise) { + const host = createFakePluginHost({ + pluginId: "connect", + sdk: { hosts: { get: async () => ({ connectMachineId: null }) } }, + }); + hosts.push(host); + await beforeInit?.(host); + await registerServerAccess(host.bb, tunnel); + return host; +} +function provider(host: FakePluginHost) { + const p = host.harness.registrations.serverAccessProviders.get("connect"); + if (!p) throw new Error("Missing provider"); + return p; +} +function cloud() { + let active = false; + let failRevoke = false; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/machine-code")) + return Response.json({ + code: "PRIVATE-CODE", + expiresInMs: 600000, + serverUrl: credential.serverUrl, + }); + if (path.endsWith("/redeem-machine")) { + expect(JSON.parse(String(init?.body))).toEqual({ + code: "PRIVATE-CODE", + }); + active = true; + return Response.json({ + credential: "bbcm_private", + machineId: "cloud-id", + serverUrl: credential.serverUrl, + }); + } + expect(path).toBe("https://getbb.app/api/connect/revoke-machine"); + expect(JSON.parse(String(init?.body))).toEqual({ machineId: "cloud-id" }); + if (failRevoke) return new Response(null, { status: 503 }); + active = false; + return Response.json({ ok: true }); + }, + ); + vi.stubGlobal("fetch", fetchMock); + return { + fetchMock, + active: () => active, + failRevoke: (value: boolean) => { + failRevoke = value; + }, + }; +} +afterEach(async () => { + for (const host of hosts.splice(0)) await host.harness.lifecycle.dispose(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); +describe("Connect server-owned machine access", () => { + it("declares picker copy", async () => { + const host = await setup(); + expect(provider(host).description).toBe("Use a private getbb.app address."); + }); + + it("persists redemption before enrollment and revokes after restart", async () => { + const api = cloud(); + const original = await setup(); + const grant = await provider(original).acquire(request); + if ("status" in grant) throw new Error(grant.message); + expect(grant).toEqual({ + id: request.hostId, + serverUrl: credential.serverUrl, + headers: { "x-bb-connect-machine": "bbcm_private" }, + }); + expect(await original.bb.storage.kv.get(key)).toMatchObject({ + result: { connectMachineId: "cloud-id" }, + }); + const restarted = await original.harness.lifecycle.reload((bb) => + registerServerAccess(bb, tunnel), + ); + hosts.push(restarted); + expect(await provider(restarted).acquire(request)).toEqual(grant); + expect(api.fetchMock).toHaveBeenCalledTimes(2); + await provider(restarted).release({ + key: request.key, + hostId: request.hostId, + grantId: grant.id, + }); + expect(api.active()).toBe(false); + expect(await restarted.bb.storage.kv.get(key)).toBeUndefined(); + }); + it("retains the device ID on revoke failure and retries after restart", async () => { + const api = cloud(); + const original = await setup(); + await provider(original).acquire(request); + api.failRevoke(true); + await expect( + provider(original).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }), + ).rejects.toThrow("503"); + const restarted = await original.harness.lifecycle.reload((bb) => + registerServerAccess(bb, tunnel), + ); + hosts.push(restarted); + api.failRevoke(false); + await provider(restarted).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(api.active()).toBe(false); + expect(await restarted.bb.storage.kv.get(key)).toBeUndefined(); + }); + it("retains the device ID when pairing is unavailable", async () => { + cloud(); + const host = await setup(); + await provider(host).acquire(request); + const restarted = await host.harness.lifecycle.reload((bb) => + registerServerAccess(bb, { ...tunnel, getCredential: () => null }), + ); + hosts.push(restarted); + await expect( + provider(restarted).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }), + ).rejects.toThrow("Pair this bb instance"); + expect(await restarted.bb.storage.kv.get(key)).toMatchObject({ + result: { connectMachineId: "cloud-id" }, + }); + }); +}); + +it("does not redeem a code when persisting the pending record fails", async () => { + const host = await setup(); + const api = cloud(); + vi.spyOn(host.bb.storage.kv, "set").mockRejectedValueOnce( + new Error("KV write failed"), + ); + await expect(provider(host).acquire(request)).rejects.toThrow( + "KV write failed", + ); + expect(api.fetchMock).toHaveBeenCalledOnce(); + expect(api.active()).toBe(false); +}); + +it.each([true, false])( + "reconciles lost redemption responses with lookup available=%s without blindly minting", + async (available) => { + const host = await setup(); + const active = new Set(); + let minted = 0; + let redeemed = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/machine-code") && init?.method === "GET") { + return available + ? Response.json({ consumed: true, machineId: "device-1" }) + : new Response("bb", { + headers: { "content-type": "text/html" }, + }); + } + if (url.endsWith("/machine-code")) { + minted++; + return Response.json({ + code: `CODE-${minted}`, + expiresInMs: 600000, + serverUrl: credential.serverUrl, + }); + } + if (url.endsWith("/redeem-machine")) { + const id = `device-${++redeemed}`; + active.add(id); + if (redeemed === 1) + throw new Error("Response lost after Cloud commit"); + return Response.json({ + credential: "private-bearer", + machineId: id, + serverUrl: credential.serverUrl, + }); + } + active.delete(JSON.parse(String(init?.body)).machineId); + return Response.json({ ok: true }); + }), + ); + await expect(provider(host).acquire(request)).resolves.toEqual({ + status: "failed", + message: + "Cloud device may need dashboard revocation: interrupted machine access acquisition", + }); + if (available) { + await provider(host).acquire(request); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(active.size).toBe(0); + } else { + await expect(provider(host).acquire(request)).resolves.toEqual({ + status: "failed", + message: + "Cloud device may need dashboard revocation: interrupted machine access acquisition; retry after Cloud lookup is available", + }); + await expect( + provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: null, + }), + ).rejects.toThrow("Cloud device may need dashboard revocation"); + expect(minted).toBe(1); + expect(redeemed).toBe(1); + expect(await host.bb.storage.kv.get(key)).toMatchObject({ + intent: { code: "CODE-1" }, + }); + } + const storedValues = await Promise.all( + (await host.bb.storage.kv.list()).map((key) => + host.bb.storage.kv.get(key), + ), + ); + expect(JSON.stringify(storedValues)).not.toContain("private-bearer"); + }, +); + +it("serializes concurrent acquisitions so release revokes every created device", async () => { + const host = await setup(); + const api = cloud(); + const grants = await Promise.all([ + provider(host).acquire(request), + provider(host).acquire(request), + ]); + expect(grants[0]).toEqual(grants[1]); + expect(api.fetchMock).toHaveBeenCalledTimes(2); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(api.active()).toBe(false); +}); + +it.each(["expired", "valid"])( + "renews only definitively unconsumed expired intents: %s", + async (age) => { + const host = await setup(async (host) => { + await host.bb.storage.kv.set(key, { + intent: { + key: request.key, + hostId: request.hostId, + code: "OLD-CODE", + serverUrl: credential.serverUrl, + expiresAt: Date.now() + (age === "expired" ? -1000 : 600000), + }, + }); + }); + const issued: string[] = []; + const redeemed: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "GET") + return Response.json({ consumed: false, machineId: null }); + if (String(input).endsWith("/machine-code")) { + issued.push("NEW-CODE"); + return Response.json({ + code: "NEW-CODE", + expiresInMs: 600000, + serverUrl: credential.serverUrl, + }); + } + expect(String(input)).toContain("/redeem-machine"); + redeemed.push(JSON.parse(String(init?.body)).code); + return Response.json({ + credential: "new-private", + machineId: "new-device", + serverUrl: credential.serverUrl, + }); + }), + ); + await expect(provider(host).acquire(request)).resolves.toMatchObject({ + headers: { "x-bb-connect-machine": "new-private" }, + }); + expect(issued).toEqual(age === "valid" ? [] : ["NEW-CODE"]); + expect(redeemed).toEqual([age === "valid" ? "OLD-CODE" : "NEW-CODE"]); + expect(await host.bb.storage.kv.get(key)).toMatchObject({ + result: { connectMachineId: "new-device", grant: { id: request.hostId } }, + }); + }, +); + +describe("server access recheck", () => { + it("tells core only when paired state or public URL changes", async () => { + const host = await setup(); + const recheck = createServerAccessRecheck(host.bb); + recheck({ paired: false, url: null }); + expect(host.harness.recheckCount).toBe(0); + recheck({ paired: false, url: null }); + expect(host.harness.recheckCount).toBe(0); + recheck({ paired: true, url: "https://test.getbb.app" }); + expect(host.harness.recheckCount).toBe(1); + recheck({ paired: true, url: "https://test.getbb.app" }); + expect(host.harness.recheckCount).toBe(1); + recheck({ paired: true, url: "https://renamed.getbb.app" }); + expect(host.harness.recheckCount).toBe(2); + recheck({ paired: false, url: null }); + expect(host.harness.recheckCount).toBe(3); + }); +}); diff --git a/plugins/connect/src/server-access.ts b/plugins/connect/src/server-access.ts new file mode 100644 index 0000000000..631e62d707 --- /dev/null +++ b/plugins/connect/src/server-access.ts @@ -0,0 +1,166 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { z } from "zod"; +import { lookupMachineCode } from "./machine-code.js"; +import type { ConnectTunnel } from "./tunnel.js"; +import { fetchMachineCode } from "./machine-code.js"; +import { revokeMachine } from "./revoke-machine.js"; +import { redeemMachineCode } from "./redeem.js"; + +const grantSchema = z.object({ + connectMachineId: z.string().min(1), + grant: z.object({ + id: z.string().min(1), + serverUrl: z.string().url(), + headers: z.record(z.string(), z.string()), + }), +}); + +export function createServerAccessRecheck( + bb: BbPluginApi, +): (status: { paired: boolean; url: string | null }) => void { + let last: string | null = null; + return (status) => { + const signature = `${status.paired}:${status.url ?? ""}`; + const changed = last !== null && last !== signature; + last = signature; + if (changed) bb.experimental_serverAccess.recheck(); + }; +} + +function acquisitionFailure(message: string) { + return { status: "failed" as const, message }; +} + +function grantKey(hostId: string): string { + return `server-access-grant:${hostId}`; +} + +export async function registerServerAccess( + bb: BbPluginApi, + tunnel: { + getCredential: ConnectTunnel["getCredential"]; + status(): { paired: boolean; url: string | null }; + }, +) { + const intentSchema = z.object({ + key: z.string(), + hostId: z.string(), + code: z.string(), + expiresAt: z.number().finite(), + serverUrl: z.string().url(), + }); + const stateSchema = z.union([ + z.object({ result: grantSchema }), + z.object({ intent: intentSchema }), + ]); + let queue = Promise.resolve(); + function serialized(action: () => Promise): Promise { + const result = queue.then(action); + queue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + async function load(hostId: string) { + const raw = await bb.storage.kv.get(grantKey(hostId)); + return raw === undefined ? undefined : stateSchema.parse(raw); + } + async function reconcile(intent: z.infer) { + const credential = tunnel.getCredential(); + if (!credential) + throw new Error( + "Pair this bb instance with bb connect to revoke machine access", + ); + try { + const status = await lookupMachineCode(credential, intent.code); + if (status.consumed && !status.machineId) + throw new Error("Device identity unavailable"); + if (status.machineId) await revokeMachine(credential, status.machineId); + return status.consumed; + } catch { + const message = + "Cloud device may need dashboard revocation: interrupted machine access acquisition; retry after Cloud lookup is available"; + return acquisitionFailure(message); + } + } + bb.experimental_serverAccess.register({ + id: "connect", + displayName: "bb connect", + description: "Use a private getbb.app address.", + availability: () => { + const status = tunnel.status(); + return status.paired + ? { + status: "available", + ...(status.url ? { serverUrl: status.url } : {}), + } + : { + status: "setup-required", + message: "Pair this bb instance with bb connect", + }; + }, + acquire({ key, hostId, signal }) { + return serialized(async () => { + signal.throwIfAborted(); + const existing = await load(hostId); + if (existing && "result" in existing) return existing.result.grant; + const credential = tunnel.getCredential(); + if (!credential) + throw new Error("Pair this bb instance with bb connect"); + let intent = existing?.intent; + if (intent) { + const reconciliation = await reconcile(intent); + if (typeof reconciliation !== "boolean") return reconciliation; + if (reconciliation || intent.expiresAt <= Date.now()) + intent = undefined; + } + if (!intent) { + const code = await fetchMachineCode(credential); + intent = { + key, + hostId, + code: code.code, + serverUrl: code.serverUrl, + expiresAt: code.expiresAt, + }; + } + await bb.storage.kv.set(grantKey(hostId), { intent }); + const pending = intent; + const redeemed = await redeemMachineCode(pending).catch(() => null); + if (redeemed === null) + return acquisitionFailure( + "Cloud device may need dashboard revocation: interrupted machine access acquisition", + ); + const grant = { + id: hostId, + serverUrl: redeemed.serverUrl, + headers: { "x-bb-connect-machine": redeemed.credential }, + }; + await bb.storage.kv.set(grantKey(hostId), { + result: { connectMachineId: redeemed.machineId, grant }, + }); + return grant; + }); + }, + release({ hostId }) { + return serialized(async () => { + const stored = await load(hostId); + if (!stored) return; + if ("intent" in stored) { + const reconciliation = await reconcile(stored.intent); + if (typeof reconciliation !== "boolean") + throw new Error(reconciliation.message); + } else { + const credential = tunnel.getCredential(); + if (!credential) + throw new Error( + "Pair this bb instance with bb connect to revoke machine access", + ); + await revokeMachine(credential, stored.result.connectMachineId); + } + await bb.storage.kv.delete(grantKey(hostId)); + }); + }, + }); +} diff --git a/plugins/connect/src/server.ts b/plugins/connect/src/server.ts index e91270b588..19a6961d4c 100644 --- a/plugins/connect/src/server.ts +++ b/plugins/connect/src/server.ts @@ -1,3 +1,7 @@ +import { + createServerAccessRecheck, + registerServerAccess, +} from "./server-access.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { registerConnectCli } from "./cli.js"; import { createKvCredentialStore } from "./credential.js"; @@ -51,16 +55,21 @@ export default async function plugin(bb: BbPluginApi) { }, }); + const recheckServerAccess = createServerAccessRecheck(bb); tunnel = new ConnectTunnel({ store, shares, defaultBaseUrl: resolveDefaultConnectBaseUrl(process.env), getLoopbackBaseUrl, log: bb.log, - onStatusChange: (status) => - bb.realtime.publish(CONNECT_REALTIME_CHANNEL, status), + onStatusChange: (status) => { + bb.realtime.publish(CONNECT_REALTIME_CHANNEL, status); + recheckServerAccess(status); + }, }); + await registerServerAccess(bb, tunnel); + const mobilePairing: MobilePairingGate = { enabled: async () => (await bb.sdk.system.config()).experiments.mobileApp, }; diff --git a/plugins/environment-git-worktree/app.test.tsx b/plugins/environment-git-worktree/app.test.tsx index 4a35e24cbd..d7894a19d7 100644 --- a/plugins/environment-git-worktree/app.test.tsx +++ b/plugins/environment-git-worktree/app.test.tsx @@ -33,7 +33,7 @@ describe("worktree inputs control", () => { const onChange = vi.fn(); renderSlot(inputsSlot(), { projectId: "project-1", - hostId: "host-a", + target: { kind: "existing-host", hostId: "host-a" }, value: null, onChange, }); @@ -48,7 +48,7 @@ describe("worktree inputs control", () => { it("binds bb's branch picker to the picked machine and project", () => { const slot = renderSlot(inputsSlot(), { projectId: "project-1", - hostId: "host-a", + target: { kind: "existing-host", hostId: "host-a" }, value: { branch: { kind: "named", name: "release" } }, onChange: vi.fn(), }); @@ -65,7 +65,7 @@ describe("worktree inputs control", () => { const onChange = vi.fn(); const slot = renderSlot(inputsSlot(), { projectId: "project-1", - hostId: "host-a", + target: { kind: "existing-host", hostId: "host-a" }, value: { branch: { kind: "default" } }, onChange, }); @@ -82,7 +82,7 @@ describe("worktree inputs control", () => { const onChange = vi.fn(); const slot = renderSlot(inputsSlot(), { projectId: "project-1", - hostId: "host-a", + target: { kind: "existing-host", hostId: "host-a" }, value: { branch: { kind: "named", name: "release" } }, onChange, }); diff --git a/plugins/environment-git-worktree/app.tsx b/plugins/environment-git-worktree/app.tsx index a20aee0934..ebb4cd4eb4 100644 --- a/plugins/environment-git-worktree/app.tsx +++ b/plugins/environment-git-worktree/app.tsx @@ -26,10 +26,11 @@ export function selectedBranchName(value: JsonValue | null): string | null { function WorktreeInputsControl({ projectId, - hostId, + target, value, onChange, }: PluginEnvironmentProviderInputsProps) { + const hostId = target.kind === "existing-host" ? target.hostId : null; useEffect(() => { if (value === null) { onChange({ status: "ready", value: DEFAULT_INPUTS }); diff --git a/plugins/environment-modal-sandbox/Dockerfile b/plugins/environment-modal-sandbox/Dockerfile new file mode 100644 index 0000000000..2f345280d3 --- /dev/null +++ b/plugins/environment-modal-sandbox/Dockerfile @@ -0,0 +1,29 @@ +# Debian with Node.js and npm, pinned for reproducible machine images. +FROM node:22.19.0-bookworm-slim@sha256:4a4884e8a44826194dff92ba316264f392056cbe243dcc9fd3551e71cea02b90 + +# Git/GitHub access, HTTP and JSON tools, fast search, and native build support. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gh \ + jq \ + ripgrep \ + procps \ + build-essential \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +# Package manager and coding agents. Versions match the supported BB providers. +RUN npm install -g \ + pnpm@9.15.0 \ + @earendil-works/pi-coding-agent@0.84.0 \ + @openai/codex@0.153.4 \ + @anthropic-ai/claude-code@2.1.263 \ + && npm cache clean --force + +# Run agents as an unprivileged user with a writable home directory. +# BB installs its daemon on demand during bootstrap; it is not baked into this image. +USER node +WORKDIR /home/node diff --git a/plugins/environment-modal-sandbox/README.md b/plugins/environment-modal-sandbox/README.md new file mode 100644 index 0000000000..65bfca8430 --- /dev/null +++ b/plugins/environment-modal-sandbox/README.md @@ -0,0 +1,168 @@ +# Modal sandbox + +Run reusable BB machines in Modal. Install the optional official plugin, connect +its token in Settings → Plugins → Modal sandbox, select a project, and create a +machine. The project picker exposes **New sandbox** under **New machine**. + +## Standard image + +Settings lets you edit, save, or reset the Dockerfile used for future machines +across all projects. Agents can use the same saved definition through the CLI: + +```sh +bb modal image show > Dockerfile +bb modal image set --file ./Dockerfile +bb modal image show --json +bb modal image reset +``` + +Only one `FROM` followed by `RUN`, `ENV`, `WORKDIR`, or `USER` is supported. +Comments and line breaks are preserved. There is no build context, `COPY`, `ADD`, +or multi-stage build. Invalid definitions leave the saved version unchanged. +Saving/resetting does not build or allocate compute; existing machines and their +snapshots are unaffected. The last saved definition applies to new launches. +The override is stored by this plugin and survives reloads; reset uses the bundled +Dockerfile from the installed plugin version. + +`--file` resolves relative to the invoking CLI directory. In a BB thread it reads +from that thread's host; without thread context it reads on the server's primary +host. Remote callers without thread context can use the typed RPC with file text. +All commands accept `--json` as the final flag. `image.definition`, `image.set` +(input `{dockerfile}`), and `image.reset` are available through `modalRpcContract` +and `sdk.plugins.callRpc`. They return `{dockerfile, customized}`. Definitions +are limited to 65,536 characters. Editing needs no Modal credentials. + +The plugin ships a [Dockerfile](Dockerfile) with Debian, Node, Git/GitHub CLI, +build tools, Python, ripgrep, jq, pnpm, Pi, Codex and Claude Code. It contains no BB +daemon, project files, enrollment state or credentials. The image is named by the +Dockerfile's SHA-256 and reused within the Modal account. The first launch builds +and publishes it automatically; later launches reuse it. Changing the Dockerfile +creates a new image version for future machines. Modal also caches build layers. +There is no project recipe, uploaded context, smoke-test gate or image promotion. + +Machine creation reports image preparation and allocation progress. Build failures +surface on the machine launch and may be retried. Cancelling a launch prevents +subsequent sandbox allocation; an image build already submitted to Modal can +finish and remain cached. Shared standard images are not removed with a machine. + +Core prepares enrollment before allocation. As soon as the sandbox ID is known, +the plugin awaits a durable resource checkpoint before bootstrap, so cancellation +cleanup does not need to allocate or enroll again. Core installs the matching BB +daemon on demand using Modal exec, enrolls the machine and waits for its connection. +Restore uses the same bootstrap API, reusing an enrolled daemon from the snapshot when available. +Bootstrap credentials travel through stdin and are never persisted in machine +resources. Core owns machine access grants and runtime credential injection. + +Core clones the selected project and runs its `.bb-env-setup.sh`. Use that hook to +install project dependencies and start services; failures remain visible in the +launch logs. The hook must be idempotent because it runs again after filesystem +restore. Environment teardown uses the core `.bb-env-teardown.sh` hook. Attached, +user-maintained checkouts skip owned-environment hooks. `.worktreeinclude` does not +apply to fresh clones; configure runtime files and secrets through core Machine +environment settings. + +## Settings and commands + +| Setting | Meaning | +| ------------------------ | --------------------------------------------------------- | +| `tokenId`, `tokenSecret` | Required Modal token, entered in secret settings. | +| `appName` | Modal app, default `bb-sandboxes`. | +| `idleMinutes` | Pause after idle, default 15; 0 disables idle suspension. | +| Sandbox size presets | Named CPU and memory reservations for new machines. | +| Images | Named Dockerfiles or existing Modal image IDs. | + +Use `bb modal account inspect --json` to validate credentials +without allocating resources. Create with +`bb machine create --provider modal-sandbox --json`, or SDK +`hosts.experimental_create({machineProviderId:"modal-sandbox",key})`. Machine creation +accepts optional configured names in `{"preset":"Large","image":"Node 22"}`. +With one or zero choices, the default applies and the composer shows no extra +chip. Account inspection is also available through the +plugin's typed `modalRpcContract` (`account.inspect`) and `sdk.plugins.callRpc`. +See the [command reference](skills/modal-sandboxes/SKILL.md). + +## Lifecycle + +The idle pause defaults to 15 minutes and applies to existing machines without a +reload. Compute lifetime is fixed at Modal's 24-hour sandbox maximum. Open +terminals prevent idle pause. There is no automatic retention removal. + +Manual and idle pauses snapshot the filesystem before terminating compute. Core +blocks new work, interrupts turns and closes terminals; the plugin stops the +daemon, saves the filesystem and durably records the snapshot before termination. +Core defers pause while persisted state ties a live thread launch or provisioning +environment to the machine, or while project checkout setup is pending. Resume +preserves host identity without rerunning setup. Interrupted turns are not replayed. + +There is no pre-expiry scheduler. A sandbox that stays active for its full +24-hour lifetime stops without a guaranteed final snapshot. Changes +since the last successful pause may be lost. Pause before the timeout to save work. +Failed saves retain compute while it still exists. +`bb modal machine inspect HOST_ID --json` exposes vendor state, expiry and the saved image. Missing +compute never silently becomes an empty checkout or an older snapshot. A checkpoint +from an interrupted planned suspension remains recoverable. + +Use `bb machine list --json` for core state and `bb modal machine inspect HOST_ID --json` for Modal diagnostics. The plugin RPC `machine.inspect` returns the same result. Remove explicitly with `bb machine remove MACHINE --yes`. +Account identity remains pinned; restore the original account before lifecycle +operations. Removal deletes private snapshots and compute, retaining the shared +standard image. Removing lost compute can remain blocked on core checkout cleanup. + +## Prerequisites + +Modal credentials, a project Git remote and access to it, and a configured core +server-access route reachable from the sandbox are required. Agent authentication +is needed to run agent turns. Image builds and running machines incur Modal usage; +this plugin does not provision anything merely by being installed or connected. + +## Logo and trademark + +The bundled `modal-logo.svg` is an unmodified copy of +[`Modal-IconMark-Dark-OneColor.svg`](https://drive.google.com/file/d/1JvQGLrZsQvnpZu5DmUafxXPGHXDk6TsI/view), +the web one-color icon mark in [Modal's current official brand +assets](https://modal.com/brand). The light one-color file published beside it +uses the same geometry; bb supplies the visible color through its icon mask. + +Modal's brand-asset folder publishes no separate license or attribution file. +Modal and its logo are trademarks of Modal Labs, Inc., and Modal's +[terms](https://modal.com/legal/terms) reserve its intellectual-property +rights. The mark remains Modal's property and is bundled only to identify the +service this plugin integrates with; no license to reuse it separately is +granted or implied. + +## Debug an image + +```sh +bb modal image build --json +bb modal sandbox run --json +bb modal sandbox exec SANDBOX -- bash -lc 'node --version && which git' +bb modal sandbox exec SANDBOX --json -- bash -lc 'exit 7' +bb modal sandbox stop SANDBOX --json +``` + +Build uses the saved Dockerfile and the same account-wide image cache as machine +creation. It returns the image ID and the final 65,536 characters of build logs +when finished; failures include captured logs and the vendor error. Build logs +are collected through Modal 0.10's gRPC middleware because its image builder does +not forward them. This adapter is tied to the pinned vendor SDK. Output is not +streamed to the CLI. An already submitted build can finish after CLI cancellation. + +Run builds or reuses that image and returns `sandboxId`, `imageId`, `expiresAt` +and build `logs`. Debug sandboxes expire after 30 minutes, use Modal's default CPU +and memory, and contain no injected BB credentials, daemon, project clone or setup +hook. They are separate from BB Machines and do not snapshot. Files and running +processes remain between exec calls until stop or expiry. Copy successful fixes +into the Dockerfile, save it, and run a new sandbox to verify them. + +Exec passes arguments after `--` literally. Use `bash -lc` for shell expressions. +Place BB's `--json` before `--`; command flags after it belong to the command. +Commands have a 60-second timeout and output is capped at 128 KiB per stream with +a truncation marker. Plain output preserves stdout/stderr and the command exit +code; JSON returns `{exitCode,stdout,stderr}` with the same CLI exit status. +Stopping is idempotent for known debug sandboxes. Exec/stop only accept sandboxes +created by this plugin's debug workflow in the original Modal account; they +cannot target arbitrary sandboxes or provider-managed machines. Stop removes +compute without deleting the shared cached image. Expired IDs remain recognizable. + +SDK clients use `sdk.plugins.callRpc` with `modalRpcContract`: `image.build({})`, +`sandbox.run({})`, `sandbox.exec({sandboxId,command})`, and +`sandbox.stop({sandboxId})`. Build/run incur Modal usage. diff --git a/plugins/environment-modal-sandbox/account.ts b/plugins/environment-modal-sandbox/account.ts new file mode 100644 index 0000000000..148c4300e1 --- /dev/null +++ b/plugins/environment-modal-sandbox/account.ts @@ -0,0 +1,324 @@ +import { + buildOutput, + runOutput, + execInput, + execOutput, + sandboxInput, + type DebugSandbox, +} from "./debug-sandbox.js"; +import { + defineRpcContract, + type BbPluginApi, + type PluginCliContext, +} from "@get-bb/plugin-sdk"; +import path from "node:path"; +import { z } from "zod"; +import { dockerfileSchema, type ImageDefinition } from "./image-definition.js"; +import { + modalLaunchOptionsSchema, + type ModalLaunchOptionsStore, +} from "./launch-options.js"; + +const machineInput = z.object({ hostId: z.string().min(1) }).strict(); +const machineOutput = z.object({ + summary: z.string(), + values: z.object({ + state: z.enum(["running", "suspended", "missing"]), + expiresAt: z.number().nullable(), + snapshotImageId: z.string().nullable(), + }), +}); + +const definitionSchema = z.object({ + dockerfile: z.string(), + customized: z.boolean(), +}); +export const modalRpcContract = defineRpcContract({ + "machine.inspect": { input: machineInput, output: machineOutput }, + "image.build": { input: z.object({}).strict(), output: buildOutput }, + "sandbox.run": { input: z.object({}).strict(), output: runOutput }, + "sandbox.exec": { input: execInput, output: execOutput }, + "sandbox.stop": { input: sandboxInput, output: sandboxInput }, + "image.definition": { + input: z.object({}).strict(), + output: definitionSchema, + }, + "image.set": { + input: z.object({ dockerfile: dockerfileSchema }).strict(), + output: definitionSchema, + }, + "image.reset": { input: z.object({}).strict(), output: definitionSchema }, + "launch.options": { + input: z.object({}).strict(), + output: modalLaunchOptionsSchema, + }, + "launch.options.set": { + input: modalLaunchOptionsSchema, + output: modalLaunchOptionsSchema, + }, + "account.inspect": { + input: z.object({}).strict(), + output: z.object({ available: z.boolean(), message: z.string() }), + }, +}); + +export function registerRpcAndCli( + bb: BbPluginApi, + image: ImageDefinition, + launchOptions: ModalLaunchOptionsStore, + inspect: () => Promise<{ available: boolean; message: string }>, + debug: DebugSandbox, + inspectMachine: ( + input: z.infer, + ) => Promise>, +) { + bb.rpc.register(modalRpcContract, { + "machine.inspect": inspectMachine, + "image.build": () => debug.build(), + "sandbox.run": () => debug.run(), + "sandbox.exec": (input) => debug.exec(input), + "sandbox.stop": debug.stop, + "account.inspect": inspect, + "image.definition": image.get, + "image.set": ({ dockerfile }) => image.set(dockerfile), + "image.reset": image.reset, + "launch.options": launchOptions.get, + "launch.options.set": launchOptions.set, + }); + async function readDockerfile(file: string, context: PluginCliContext) { + let hostId: string | undefined; + if (context.threadId) { + const thread = await bb.sdk.threads.get({ threadId: context.threadId }); + if (!thread.environmentId) + throw new Error("The current thread has no machine workspace"); + const environment = await bb.sdk.environments.get({ + environmentId: thread.environmentId, + }); + if (!environment.hostId) + throw new Error("The current thread has no machine workspace"); + hostId = environment.hostId; + } + if (!path.isAbsolute(file) && !context.cwd) + throw new Error("A relative --file requires the CLI working directory"); + const result = await bb.sdk.files.read({ + hostId, + path: path.resolve(context.cwd ?? "/", file), + signal: context.signal, + }); + if (result.contentEncoding !== "utf8") + throw new Error("Dockerfile must be UTF-8 text"); + return dockerfileSchema.parse(result.content); + } + const usage = + "Usage: bb modal machine inspect HOST_ID [--json] | bb modal account inspect [--json] | bb modal image show [--json] | bb modal image set --file PATH [--json] | bb modal image reset [--json] | bb modal image build [--json] | bb modal sandbox run [--json] | bb modal sandbox exec ID [--json] -- COMMAND... | bb modal sandbox stop ID [--json]"; + type CliResult = { + exitCode: number; + stdout?: string; + stderr?: string; + }; + type CliRoute = { + path: readonly string[]; + arity: number; + separator?: boolean; + run: ( + args: string[], + command: string[], + context: PluginCliContext, + json: boolean, + ) => Promise; + }; + const routes = [ + { + path: ["machine", "inspect"], + arity: 1, + async run(args, _command, _context, json) { + const result = await inspectMachine( + machineInput.parse({ hostId: args[0] }), + ); + return { + exitCode: 0, + stdout: json ? JSON.stringify(result) : result.summary, + }; + }, + }, + { + path: ["account", "inspect"], + arity: 0, + async run(_args, _command, _context, json) { + const result = await inspect(); + return { + exitCode: result.available ? 0 : 1, + stdout: json ? JSON.stringify(result) : result.message, + }; + }, + }, + { + path: ["image", "show"], + arity: 0, + async run(_args, _command, _context, json) { + const result = await image.get(); + return { + exitCode: 0, + stdout: json ? JSON.stringify(result) : result.dockerfile, + }; + }, + }, + { + path: ["image", "set", "--file"], + arity: 1, + async run(args, _command, context, json) { + const result = await image.set(await readDockerfile(args[0]!, context)); + return { + exitCode: 0, + stdout: json + ? JSON.stringify(result) + : "Saved the Dockerfile for future machines.", + }; + }, + }, + { + path: ["image", "reset"], + arity: 0, + async run(_args, _command, _context, json) { + const result = await image.reset(); + return { + exitCode: 0, + stdout: json + ? JSON.stringify(result) + : "Restored the bundled Dockerfile for future machines.", + }; + }, + }, + { + path: ["image", "build"], + arity: 0, + async run(_args, _command, context, json) { + const result = await debug.build(context.signal); + return { + exitCode: 0, + stdout: json + ? JSON.stringify(result) + : `${result.logs}${result.imageId}`, + }; + }, + }, + { + path: ["sandbox", "run"], + arity: 0, + async run(_args, _command, context, json) { + const result = await debug.run(context.signal); + return { + exitCode: 0, + stdout: json ? JSON.stringify(result) : result.sandboxId, + stderr: json ? "" : result.logs, + }; + }, + }, + { + path: ["sandbox", "exec"], + arity: 1, + separator: true, + async run(args, command, context, json) { + const result = await debug.exec( + execInput.parse({ sandboxId: args[0], command }), + context.signal, + ); + return { + exitCode: result.exitCode, + stdout: json ? JSON.stringify(result) : result.stdout, + stderr: json ? "" : result.stderr, + }; + }, + }, + { + path: ["sandbox", "stop"], + arity: 1, + async run(args, _command, _context, json) { + const result = await debug.stop( + sandboxInput.parse({ sandboxId: args[0] }), + ); + return { + exitCode: 0, + stdout: json ? JSON.stringify(result) : `Stopped ${result.sandboxId}`, + }; + }, + }, + ] satisfies readonly CliRoute[]; + bb.cli.register({ + name: "modal", + summary: "Configure, build and debug Modal images", + commands: [ + { + name: "machine-inspect", + summary: "Inspect Modal compute and the last saved snapshot", + usage: "bb modal machine inspect HOST_ID [--json]", + }, + { + name: "image-build", + summary: "Build or reuse the saved image", + usage: "bb modal image build [--json]", + }, + { + name: "sandbox-run", + summary: "Run the saved image in a 30-minute debug sandbox", + usage: "bb modal sandbox run [--json]", + }, + { + name: "sandbox-exec", + summary: "Execute a command in a debug sandbox", + usage: "bb modal sandbox exec ID [--json] -- COMMAND...", + }, + { + name: "sandbox-stop", + summary: "Stop a debug sandbox", + usage: "bb modal sandbox stop ID [--json]", + }, + { + name: "image-show", + summary: "Show the Dockerfile used for new machines", + usage: "bb modal image show [--json]", + }, + { + name: "image-set", + summary: "Save a Dockerfile for future machines", + usage: "bb modal image set --file PATH [--json]", + }, + { + name: "image-reset", + summary: "Restore the bundled Dockerfile", + usage: "bb modal image reset [--json]", + }, + { + name: "account-inspect", + summary: "Test the configured Modal account", + usage: "bb modal account inspect [--json]", + }, + ], + async run(argv, context) { + try { + const separator = argv.indexOf("--"); + const flags = separator < 0 ? argv : argv.slice(0, separator); + const json = flags.at(-1) === "--json"; + const args = json ? flags.slice(0, -1) : flags; + const route = routes.find( + (candidate) => + candidate.path.every((part, index) => args[index] === part) && + args.length === candidate.path.length + candidate.arity && + (candidate.separator === true) === separator >= 0, + ); + if (route === undefined) throw new Error(usage); + return route.run( + args.slice(route.path.length), + separator < 0 ? [] : argv.slice(separator + 1), + context, + json, + ); + } catch (error) { + return { + exitCode: 1, + stderr: error instanceof Error ? error.message : String(error), + }; + } + }, + }); +} diff --git a/plugins/environment-modal-sandbox/app.tsx b/plugins/environment-modal-sandbox/app.tsx new file mode 100644 index 0000000000..8b4349c24f --- /dev/null +++ b/plugins/environment-modal-sandbox/app.tsx @@ -0,0 +1,625 @@ +import { useEffect, useState } from "react"; +import { + definePluginApp, + useRpc, + type JsonValue, + type PluginMachineProviderInputsProps, +} from "@get-bb/plugin-sdk/app"; +import { Button } from "@bb/shared-ui/button"; +import { Input } from "@bb/shared-ui/input"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + COARSE_POINTER_COMPACT_ICON_SIZE_CLASS, + COARSE_POINTER_ICON_SIZE_CLASS, +} from "@bb/shared-ui/coarse-pointer-sizing"; +import { + OPTION_BASE_CLASS_NAME, + OPTION_INTERACTIVE_CLASS_NAME, + OPTION_MENU_CONTENT_CLASS_NAME, + OPTION_MUTED_CLASS_NAME, +} from "@bb/shared-ui/option-display"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@bb/shared-ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import type { modalRpcContract } from "./account.js"; +import type { + ModalImage, + ModalLaunchOptions, + SandboxPreset, +} from "./launch-options.js"; +import { PROVIDER_ID } from "./provider-id.js"; + +function selectedName( + value: JsonValue | null, + key: "preset" | "image", +): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const selected = value[key]; + return typeof selected === "string" ? selected : undefined; +} + +function machineInputs( + preset: string | undefined, + image: string | undefined, +): JsonValue { + return { + ...(preset === undefined ? {} : { preset }), + ...(image === undefined ? {} : { image }), + }; +} + +function ModalOptionItem({ + label, + selected, + onSelect, +}: { + label: string; + selected: boolean; + onSelect: () => void; +}) { + return ( + + + {label} + + + + ); +} + +function ModalMachineInputsControl({ + value, + onChange, +}: PluginMachineProviderInputsProps) { + const rpc = useRpc(); + const selectedPresetName = selectedName(value, "preset"); + const selectedImageName = selectedName(value, "image"); + const [options, setOptions] = useState(null); + useEffect(() => { + onChange({ + status: "ready", + value: machineInputs(selectedPresetName, selectedImageName), + }); + }, [onChange, selectedImageName, selectedPresetName]); + useEffect(() => { + let active = true; + void rpc.call("launch.options", {}).then( + (result) => { + if (active) setOptions(result); + }, + () => { + if (active) setOptions(null); + }, + ); + return () => { + active = false; + }; + }, [rpc]); + if ( + options === null || + (options.presets.length <= 1 && options.images.length <= 1) + ) { + return null; + } + const presetName = + options.presets.find((entry) => entry.name === selectedPresetName)?.name ?? + options.presets[0]?.name; + const imageName = + options.images.find((entry) => entry.name === selectedImageName)?.name ?? + options.images[0]?.name; + const labels = [ + ...(options.presets.length > 1 && presetName !== undefined + ? [presetName] + : []), + ...(options.images.length > 1 && imageName !== undefined + ? [imageName] + : []), + ]; + const choose = (preset: string | undefined, image: string | undefined) => { + onChange({ status: "ready", value: machineInputs(preset, image) }); + }; + return ( + + + + + + {options.presets.length > 1 ? ( + <> + Size preset + {options.presets.map((preset) => ( + choose(preset.name, imageName)} + /> + ))} + + ) : null} + {options.presets.length > 1 && options.images.length > 1 ? ( + + ) : null} + {options.images.length > 1 ? ( + <> + Image + {options.images.map((image) => ( + choose(presetName, image.name)} + /> + ))} + + ) : null} + + + ); +} + +function nextName( + prefix: string, + existing: readonly { name: string }[], +): string { + const taken = new Set( + existing.map((entry) => entry.name.trim().toLocaleLowerCase()), + ); + for (let candidate = existing.length + 1; ; candidate += 1) { + const name = `${prefix} ${candidate}`; + if (!taken.has(name.toLocaleLowerCase())) return name; + } +} + +function replaceAt(items: readonly T[], index: number, value: T): T[] { + return items.map((item, candidate) => (candidate === index ? value : item)); +} + +function LaunchOptionsSettings() { + const rpc = useRpc(); + const [saved, setSaved] = useState(null); + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [selectedImage, setSelectedImage] = useState(0); + useEffect(() => { + let active = true; + void rpc.call("launch.options", {}).then( + (result) => { + if (!active) return; + setSaved(result); + setDraft(result); + }, + (failure) => { + if (active) { + setError( + failure instanceof Error ? failure.message : String(failure), + ); + } + }, + ); + return () => { + active = false; + }; + }, [rpc]); + const save = async () => { + if (draft === null) return; + setSaving(true); + setError(null); + try { + const result = await rpc.call("launch.options.set", draft); + setSaved(result); + setDraft(result); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setSaving(false); + } + }; + if (draft === null) { + return error === null ? ( +

+ Loading launch options… +

+ ) : ( +

+ {error} +

+ ); + } + const updatePreset = (index: number, preset: SandboxPreset) => { + setDraft({ ...draft, presets: replaceAt(draft.presets, index, preset) }); + }; + const updateImage = (index: number, image: ModalImage) => { + setDraft({ ...draft, images: replaceAt(draft.images, index, image) }); + }; + return ( +
+
+
+
+

+ Sandbox sizes +

+

+ One preset becomes the default; several add a picker to the + composer. With none, Modal uses 0.125 CPU and 128 MiB. +

+
+ +
+ {draft.presets.length > 0 ? ( +
+
+ Name + CPU + Memory (MiB) + +
+ {draft.presets.map((preset, index) => ( +
+ + updatePreset(index, { ...preset, name: event.target.value }) + } + placeholder="Large" + /> + + updatePreset(index, { + ...preset, + cpu: Number(event.target.value), + }) + } + /> + + updatePreset(index, { + ...preset, + memoryMiB: Number(event.target.value), + }) + } + /> + +
+ ))} +
+ ) : null} +
+ +
+
+
+

Images

+

+ Default is bundled and always first; several add a picker to the + composer. +

+
+
+ {(() => { + const index = Math.min(selectedImage, draft.images.length - 1); + const image = draft.images[index]; + if (image === undefined) return null; + const label = image.name || `Image ${index + 1}`; + const bundled = index === 0; + return ( +
+
+ + + + + + {draft.images.map((entry, entryIndex) => ( + setSelectedImage(entryIndex)} + /> + ))} + + { + setDraft({ + ...draft, + images: [ + ...draft.images, + { + name: nextName("Image", draft.images), + source: "dockerfile", + dockerfile: "FROM debian:bookworm-slim\n", + }, + ], + }); + setSelectedImage(draft.images.length); + }} + > + + Add image + + + + {bundled ? ( + + Dockerfile + + ) : ( + <> +
+ + + + + + + updateImage(index, { + name: image.name, + source: "dockerfile", + dockerfile: "FROM debian:bookworm-slim\n", + }) + } + /> + + updateImage(index, { + name: image.name, + source: "image-id", + imageId: "", + }) + } + /> + + + +
+ + )} +
+ {bundled ? null : ( + + )} + {image.source === "dockerfile" ? ( +