diff --git a/.bb/skills/verify-bb/features/README.md b/.bb/skills/verify-bb/features/README.md index 72d65f28c0..f497e68e82 100644 --- a/.bb/skills/verify-bb/features/README.md +++ b/.bb/skills/verify-bb/features/README.md @@ -46,7 +46,7 @@ were excluded. See the [audit](../MAINTENANCE.md) and | [Panels, files, terminals, splits, and embedded browser](workspace-panels.md) | 15 | 12 passed, 3 partial/blocked | | [Settings, keyboard, appearance controls, and usage](settings.md) | 13 | 6 passed, 7 partial/blocked | | [Skills, plugins, marketplaces, and plugin development](extensions.md) | 13 | 11 passed, 2 partial/blocked | -| [Machines, daemon lifecycle, and updates](hosts-updates.md) | 8 | 2 passed, 6 partial/blocked | +| [Machines, daemon lifecycle, and updates](hosts-updates.md) | 9 | 2 passed, 7 partial/blocked | | [Agent interfaces, route compatibility, and error contracts](compatibility-api.md) | 8 | 3 passed, 5 partial/blocked | | [Responsive layouts, accessibility, and performance](responsive-accessibility.md) | 8 | 8 partial/blocked | diff --git a/.bb/skills/verify-bb/features/hosts-updates.md b/.bb/skills/verify-bb/features/hosts-updates.md index cefd25c38b..6193f7ecb2 100644 --- a/.bb/skills/verify-bb/features/hosts-updates.md +++ b/.bb/skills/verify-bb/features/hosts-updates.md @@ -14,9 +14,11 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. ## Source - `apps/app/src/views/MachineSettingsView.tsx` +- `apps/app/src/components/promptbox/banner/ThreadMachineStatus.tsx` - `apps/cli/src/commands/machine.ts` - `apps/cli/src/commands/updates.ts` - `apps/host-daemon/src/server-connection.ts` +- `apps/server/src/services/machines/provider-orchestration.ts` ## Feature recipes @@ -26,6 +28,7 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. | Pair and enroll | Create machine join-code and redeem on the disposable host; attempt expired/reused code. | Exactly one intended host enrolls; invalid or consumed codes do not enroll another. | | Permission ceiling | Change the disposable machine ceiling and request a more permissive thread. | Host ceiling is enforced across UI, CLI, and runtime rather than merely hidden in the picker. | | Disconnect and reconnect | Stop only the disposable daemon, observe unavailable host, restart it, and retry a targeted read. | Status and routing recover to the same host; offline operations do not route to a different machine. | +| Suspend and resume | Suspend a disposable provider-managed machine, send a thread follow-up to wake it, then repeat with the prompt banner's Resume action. | Every wake exposes a durable `resuming` lifecycle phase; the prompt banner says “Machine is resuming…” until the machine becomes active, and queued work dispatches once. | | Protocol mismatch and automatic update | Use the documented QA setup with a deliberately older disposable daemon; inspect rejected protocol and retry-update. | Mismatch initiates the expected update or actionable failure; incompatible payloads are not accepted in a reconnect loop. | | Provider CLI installation | Inspect machine provider-cli status; install/update a chosen provider on the disposable host. | Version and health refresh on that host; failure does not claim installation. | | Updates status and apply | Compare updates status and Settings → Updates; apply only available fixture-host updates. | Per-host/per-provider outcomes are reported; absent updates produce a truthful no-op. | diff --git a/apps/app/.ladle/machine-story-fixtures.ts b/apps/app/.ladle/machine-story-fixtures.ts new file mode 100644 index 0000000000..1cff11f119 --- /dev/null +++ b/apps/app/.ladle/machine-story-fixtures.ts @@ -0,0 +1,170 @@ +import type { + ServerAccessStatus, + SystemMachineProvider, +} from "@bb/server-contract"; +import { machineServerAccessBlockedReason } from "../src/components/machines/machine-server-access"; +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", + availability: { + status: "setup-required", + message: "Pair with bb connect", + }, + }, + { + id: "direct", + displayName: "Manual", + description: "Use your own domain or network address.", + pluginId: null, + availability: null, + }, + ], + defaultProviderId: "connect", + effectiveUrl: null, + urlSource: null, +}; + +export const CONNECT_PAIRED: ServerAccessStatus = { + ...CONNECT_UNPAIRED, + providers: CONNECT_UNPAIRED.providers.map((provider) => + provider.id === "connect" + ? { + ...provider, + availability: { + status: "available", + serverUrl: "https://bb.example.com", + }, + } + : provider, + ), +}; + +export const CONNECT_PAIRED_WITHOUT_URL: ServerAccessStatus = { + ...CONNECT_PAIRED, + providers: CONNECT_PAIRED.providers.map((provider) => + provider.id === "connect" + ? { ...provider, availability: { status: "available" } } + : provider, + ), +}; + +export const CONNECT_UNAVAILABLE: ServerAccessStatus = { + ...CONNECT_UNPAIRED, + providers: CONNECT_UNPAIRED.providers.map((provider) => + provider.id === "connect" + ? { + ...provider, + availability: { + status: "unavailable", + message: "Credential revoked", + }, + } + : provider, + ), +}; + +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: machineServerAccessBlockedReason(access, selected), + 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..d3151e2e89 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 { @@ -103,8 +104,10 @@ export function makeAttachmentsConfig( } function storyProviderIcon(providerId: string, glyph: string) { - return getProviderIconInfo(providerId, { logoUrl: null, icon: { glyph } }) - ?.icon; + return getProviderIconInfo("agent", providerId, { + logoUrl: null, + icon: { glyph }, + })?.icon; } function makeStoryProvider( @@ -323,8 +326,10 @@ export const STORY_WORKTREE_OPTIONS: readonly ReuseThreadOption[] = [ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = [ { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", + description: "Work in this project checkout.", icon: "Laptop", logoUrl: null, pluginId: "environment-project-checkout", @@ -340,8 +345,10 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = inputs: null, }, { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", + description: "Create an isolated Git worktree.", icon: "GitBranch", logoUrl: null, pluginId: "environment-git-worktree", @@ -357,8 +364,10 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = inputs: null, }, { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", + description: "Create a personal directory without a project.", icon: "Folder", logoUrl: null, pluginId: "environment-personal-workspace", @@ -375,6 +384,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..54e22d3132 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx @@ -1,335 +1,139 @@ // @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 { createDeferredPromise } from "@bb/test-helpers"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; 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 { 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" }), - ]); - }); +const reservedHost: Awaited> = + { + 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, + }; - 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(); +function setup(configure?: () => void) { + vi.mocked(sdk.hosts.experimental_create).mockResolvedValue(reservedHost); + vi.mocked(sdk.hosts.experimental_getEnrollmentCommand).mockResolvedValue({ + command: "bb machine enroll test", + expiresAt: Date.now() + 60_000, }); + vi.mocked(sdk.hosts.get).mockImplementation(() => new Promise(() => {})); + vi.mocked(sdk.hosts.delete).mockResolvedValue({ ok: true }); + configure?.(); + const { wrapper } = createQueryClientTestHarness(); + const rendered = render( + + + + {}} /> + + + , + { wrapper }, + ); + + return rendered; +} - 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); - }); +it("cancels a creating manual launch when the dialog content closes", async () => { + const rendered = setup(); + await screen.findByText("bb machine enroll test"); + rendered.unmount(); - act(() => { - queryClient.setQueryData(hostsQueryKey(), [ - existingHost, - host({ id: "host_offline", name: "dev-vm" }), - ]); + await waitFor(() => { + expect(sdk.hosts.delete).toHaveBeenCalledWith({ + hostId: "host-reserved", }); - - 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, +it("retrieves the enrollment command after asynchronous access preparation", async () => { + const rendered = setup(() => { + vi.mocked(sdk.hosts.experimental_getEnrollmentCommand) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + command: "delayed enrollment command", + expiresAt: Date.now() + 60_000, + }); + vi.mocked(sdk.hosts.get).mockResolvedValue({ + ...reservedHost, + connectMachineId: null, }); - 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(); }); + await screen.findByText("delayed enrollment command", {}, { timeout: 3_000 }); + expect(sdk.hosts.experimental_getEnrollmentCommand).toHaveBeenCalledTimes(2); + rendered.unmount(); +}); - 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"), +it("accepts a connection before an enrollment command is returned", async () => { + const rendered = setup(() => { + vi.mocked(sdk.hosts.experimental_getEnrollmentCommand).mockResolvedValue( + null, ); - vi.mocked(sdk.plugins.list).mockResolvedValue({ - plugins: [connectPlugin({ enabled: true, status: "degraded" })], + vi.mocked(sdk.hosts.get).mockResolvedValue({ + ...reservedHost, + connectMachineId: null, + status: "connected", + lifecycle: { ...reservedHost.lifecycle, phase: "active" }, }); - 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(); }); + await screen.findByText("Manual machine connected", {}, { timeout: 3_000 }); + rendered.unmount(); + expect(sdk.hosts.delete).not.toHaveBeenCalled(); +}); - 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", - }); - expect(link.getAttribute("href")).toBe( - "/settings/plugins/connect?view=installed", +it("cancels the reserved host while enrollment command preparation is pending", async () => { + const pending = createDeferredPromise(); + const rendered = setup(() => { + vi.mocked(sdk.hosts.experimental_getEnrollmentCommand).mockReturnValue( + pending.promise, ); - 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(); }); + await waitFor(() => + expect(sdk.hosts.experimental_getEnrollmentCommand).toHaveBeenCalledOnce(), + ); + const request = vi.mocked(sdk.hosts.experimental_getEnrollmentCommand).mock + .calls[0]![0]; + rendered.unmount(); + expect(request.signal?.aborted).toBe(true); + await waitFor(() => + expect(sdk.hosts.delete).toHaveBeenCalledWith({ hostId: reservedHost.id }), + ); + pending.resolve(null); + expect(sdk.hosts.experimental_create).toHaveBeenCalledOnce(); }); diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx index 585f44b8dd..62f5d8b412 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx @@ -1,397 +1,405 @@ -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"); + } + let enrollment: Awaited< + ReturnType + > = null; + while (host.lifecycle.phase === "creating") { + controller.signal.throwIfAborted(); + if (enrollment === null) { + enrollment = await sdk.hosts.experimental_getEnrollmentCommand({ + hostId: host.id, + signal: controller.signal, + }); + setCommand( + enrollment === null + ? null + : { + value: enrollment.command, + expiresAt: enrollment.expiresAt, + }, + ); + } + 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..f448a2872d 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, "all") : 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..b87ea27aac --- /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..d7e2cbca02 --- /dev/null +++ b/apps/app/src/components/machines/machine-server-access.test.ts @@ -0,0 +1,85 @@ +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", + availability: { status: "available" }, + }, + ], + 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("blocks an installed provider until access is available and follows its recovery", () => { + const access = status(); + access.providers[0]!.availability = { + status: "setup-required", + message: "Pair the relay", + }; + expect(machineServerAccessReady(access)).toBe(false); + expect(machineServerAccessBlockedReason(access)).toBe("Pair the relay"); + access.providers[0]!.availability = { status: "available" }; + expect(machineServerAccessReady(access)).toBe(true); + access.providers[0]!.availability = { + status: "unavailable", + message: "Credential revoked", + }; + expect(machineServerAccessBlockedReason(access)).toBe("Credential revoked"); + access.providers[0]!.availability = null; + expect(machineServerAccessReady(access)).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, + availability: 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..9dd3db5f4d --- /dev/null +++ b/apps/app/src/components/machines/machine-server-access.ts @@ -0,0 +1,38 @@ +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 provider.availability?.status === "available"; + 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, + providerId = access?.defaultProviderId, +): string | null { + if (access === undefined) return MACHINE_SERVER_ACCESS_UNSET_REASON; + const selected = { + ...access, + defaultProviderId: providerId ?? access.defaultProviderId, + }; + if (machineServerAccessReady(selected)) return null; + const availability = access.providers.find( + (provider) => provider.id === providerId, + )?.availability; + return availability && availability.status !== "available" + ? availability.message + : MACHINE_SERVER_ACCESS_UNSET_REASON; +} diff --git a/apps/app/src/components/machines/machine-status.test.ts b/apps/app/src/components/machines/machine-status.test.ts new file mode 100644 index 0000000000..79833f1241 --- /dev/null +++ b/apps/app/src/components/machines/machine-status.test.ts @@ -0,0 +1,28 @@ +import { makeHost } from "@bb/test-helpers/domain-fixtures"; +import { describe, expect, it } from "vitest"; +import { + machinePhaseLabel, + machineStatusLabel, + machineStatusTone, +} from "./machine-status"; + +describe("resuming machine status", () => { + const host = makeHost({ + status: "connected", + lifecycle: { + phase: "resuming", + suspendedAt: 1, + message: "Restoring compute", + pendingLog: "", + teardown: null, + }, + }); + + it("keeps lifecycle status ahead of the transport connection", () => { + expect(machinePhaseLabel(host.lifecycle)).toBe("Resuming"); + expect(machineStatusLabel({ host, now: 2 })).toBe( + "Resuming · Restoring compute", + ); + expect(machineStatusTone(host)).toBe("attention"); + }); +}); 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..a40388369e --- /dev/null +++ b/apps/app/src/components/machines/machine-status.ts @@ -0,0 +1,50 @@ +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" | "Resuming" | "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 === "resuming") return "Resuming"; + 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" || + host.lifecycle.phase === "resuming" + ) + 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..73c87bebd3 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,8 +14,10 @@ import { } from "./EnvironmentPicker"; const checkoutProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", + description: "Work in this project checkout.", icon: "Laptop", logoUrl: null, pluginId: "environment-project-checkout", @@ -30,8 +34,10 @@ const checkoutProvider: SystemEnvironmentProvider = { }; const branchProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "branchy", displayName: "New branch workspace", + description: "Prepare a workspace for this thread.", icon: "GitBranch", logoUrl: null, pluginId: "branchy", @@ -48,8 +54,10 @@ const branchProvider: SystemEnvironmentProvider = { }; const sandboxProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "container", displayName: "Docker container", + description: "Prepare a workspace for this thread.", icon: "Container", logoUrl: null, pluginId: "docker-sandbox", @@ -70,8 +78,10 @@ const sandboxProvider: SystemEnvironmentProvider = { }; const optionalInputsProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "optional-sandbox", displayName: "Optional sandbox", + description: "Prepare a workspace for this thread.", icon: "Container", logoUrl: null, pluginId: "optional-sandbox", @@ -113,7 +123,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 +224,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( { ...optionalInputsProvider, id: "personal-workspace", displayName: "Personal workspace", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { projectCheckout: false, gitCheckout: false, @@ -384,10 +430,11 @@ describe("EnvironmentPickerUI", () => { 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 +443,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 +517,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 +647,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.each(["removing", "resuming"] as const)( + "disables options on a machine that is %s", + (phase) => { + const unavailableStudio: Host = { + ...studio, + lifecycle: { ...studio.lifecycle, phase }, + }; + 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( { ...checkoutProvider, id: "host-sandbox", displayName: "Host sandbox", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { ...checkoutProvider.requires, 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, "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 +222,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 +253,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 +272,7 @@ export function EnvironmentPickerUI({ }, [ parsed, hostUnavailableReason, - host, + availableHost, selectedMachineName, selectedProvider, ]); @@ -353,7 +369,7 @@ export function EnvironmentPickerUI({ sources={sources} value={value} onRequestMachineSetup={onRequestMachineSetup} - machineProviders={environmentProviders} + environmentProviders={environmentProviders} providersByHostId={providersByHostId} selectedProviderHostId={selectedProviderHostId} inputsControlProviderIds={inputsControlProviderIds} @@ -362,10 +378,10 @@ export function EnvironmentPickerUI({ ) : ( )} + {!isLoading && + onSelectProvider && + hostlessProviders.length > 0 && + environmentProviders.length > 0 ? ( + + ) : null} + {!isLoading && onSelectProvider + ? hostlessProviders.map((provider) => ( + onSelectProvider(provider, null)} + /> + )) + : null} ); @@ -418,7 +460,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 +473,7 @@ function EnvironmentOptionsSection({ hostName, hostUnavailableReason, value, - machineProviders, + environmentProviders, selectedProviderHostId, inputsControlProviderIds, onSelectProvider, @@ -451,7 +493,7 @@ function EnvironmentOptionsSection({ {hostUnavailableReason} ) : onSelectProvider !== undefined && hostId !== null ? ( - machineProviders.map((provider) => { + environmentProviders.map((provider) => { const disabledReason = providerDisabledReason( provider, inputsControlProviderIds, @@ -488,8 +530,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 +544,7 @@ function MachineGroupedEnvironmentOptions({ sources, value, onRequestMachineSetup, - machineProviders, + environmentProviders, providersByHostId, selectedProviderHostId, inputsControlProviderIds, @@ -527,8 +569,8 @@ function MachineGroupedEnvironmentOptions({ now={now} value={value} onRequestMachineSetup={onRequestMachineSetup} - machineProviders={scopedProviders( - machineProviders, + environmentProviders={scopedProviders( + environmentProviders, providersByHostId, machineHost.id, { value, selectedProviderHostId }, @@ -549,7 +591,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 +606,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 === "active"; return ( - {host.name} + {isThisMachine ? ( this machine ) : null} @@ -613,7 +656,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..c1b516bcf8 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, "all"), [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..e12197b5ba 100644 --- a/apps/app/src/components/pickers/ReuseEnvironmentPicker.test.ts +++ b/apps/app/src/components/pickers/ReuseEnvironmentPicker.test.ts @@ -6,8 +6,10 @@ import { } from "./ReuseEnvironmentPicker"; const provider: SystemEnvironmentProvider = { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", + description: "Prepare a workspace for this thread.", icon: "Laptop", logoUrl: null, pluginId: "environment-project-checkout", 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..06d9de393e 100644 --- a/apps/app/src/components/plugin/EnvironmentProviderIcon.test.tsx +++ b/apps/app/src/components/plugin/EnvironmentProviderIcon.test.tsx @@ -11,12 +11,14 @@ import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; import { EnvironmentProviderIcon } from "./EnvironmentProviderIcon"; const provider: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", pluginId: "environment-git-worktree", acceptsEmptyInputs: true, machineAvailability: {}, availability: null, displayName: "Worktree", + description: "Prepare a workspace for this thread.", icon: "Folder", logoUrl: "/api/v1/system/providers/environment%3Aworktree/logo?h=hash", requires: { @@ -45,6 +47,7 @@ it("renders an environment logo and reacts to React icon registration and remova ...makePluginRegistrationSet(), providerIcons: [ { + providerKind: "environment", providerId: "git-worktree", icon: () => , }, diff --git a/apps/app/src/components/plugin/EnvironmentProviderIcon.tsx b/apps/app/src/components/plugin/EnvironmentProviderIcon.tsx index 4074b31f6d..fe523aa95d 100644 --- a/apps/app/src/components/plugin/EnvironmentProviderIcon.tsx +++ b/apps/app/src/components/plugin/EnvironmentProviderIcon.tsx @@ -10,7 +10,7 @@ export function EnvironmentProviderIcon({ provider: SystemEnvironmentProvider; className?: string; }) { - const info = getProviderIconInfo(provider.id, { + const info = getProviderIconInfo("environment", provider.id, { logoUrl: provider.logoUrl, displayName: provider.displayName, ...(provider.icon === null ? {} : { icon: { glyph: provider.icon } }), diff --git a/apps/app/src/components/plugin/MachineProviderIcon.tsx b/apps/app/src/components/plugin/MachineProviderIcon.tsx new file mode 100644 index 0000000000..fa8176ad1b --- /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("machine", 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..b5bc5a73d2 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -27,7 +27,10 @@ import type { NewThreadRequest, PluginEnvironmentProviderInputsProps, } from "@get-bb/plugin-sdk"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { NewThreadComposer, type NewThreadComposerState, @@ -66,6 +69,9 @@ const mocks = vi.hoisted(() => ({ promptHistoryQueryOptions: [] as Array<{ enabled?: boolean } | undefined>, environmentProviders: [] as unknown[], closeTerminal: vi.fn(), + plugins: [] as unknown[], + serverAccessReady: true, + machineProviders: [] as SystemMachineProvider[], })); vi.mock("@/views/RootComposePanelCommandHandlers", () => ({ @@ -109,6 +115,7 @@ vi.mock("@/components/promptbox/NewThreadPromptBox", () => ({ mocks.promptBoxProps.push(props); return (
+ {props.modeConfig?.banner ?? null} {props.modeConfig?.environmentProviderInputsSlot ?? null}
); @@ -121,6 +128,14 @@ vi.mock("@/hooks/queries/environment-provider-queries", () => ({ }), })); +vi.mock("@/hooks/queries/machine-provider-queries", () => ({ + useSystemMachineProviders: () => ({ providers: mocks.machineProviders }), +})); + +vi.mock("@/hooks/queries/plugin-settings-queries", () => ({ + usePluginList: () => ({ data: { plugins: mocks.plugins } }), +})); + vi.mock("@/lib/sdk", () => ({ sdk: { projects: { attachments: { copy: mocks.copyAttachments } } }, })); @@ -205,7 +220,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 +233,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,8 +483,10 @@ const BRANCH_INPUTS_SCHEMA = { }; const CHECKOUT_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "project-checkout", displayName: "Project checkout", + description: "Prepare a workspace for this thread.", icon: "Laptop", logoUrl: null, pluginId: "environment-project-checkout", @@ -470,8 +506,10 @@ const CHECKOUT_PROVIDER: SystemEnvironmentProvider = { }; const PERSONAL_WORKSPACE_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", + description: "Prepare a workspace for this thread.", icon: "Folder", logoUrl: null, pluginId: "environment-personal-workspace", @@ -488,8 +526,10 @@ const PERSONAL_WORKSPACE_PROVIDER: SystemEnvironmentProvider = { }; const MANAGED_WORKTREE_SUGAR_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", + description: "Prepare a workspace for this thread.", icon: "GitBranch", logoUrl: null, pluginId: "environment-git-worktree", @@ -612,6 +652,9 @@ describe("PluginNewThreadComposer seeding", () => { mocks.sidebarNavigationSettled = true; mocks.sidebarNavigationReplayed = false; mocks.extraProjects = []; + mocks.plugins = []; + mocks.serverAccessReady = true; + mocks.machineProviders = []; mocks.environmentProviders = [ CHECKOUT_PROVIDER, MANAGED_WORKTREE_SUGAR_PROVIDER, @@ -1638,8 +1681,10 @@ describe("PluginNewThreadComposer seeding", () => { }); const SANDBOX_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "container", displayName: "Docker container", + description: "Prepare a workspace for this thread.", icon: "Container", logoUrl: null, pluginId: "docker-sandbox", @@ -1660,8 +1705,10 @@ const SANDBOX_PROVIDER: SystemEnvironmentProvider = { }; const OPTIONAL_INPUTS_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "optional-sandbox", displayName: "Optional sandbox", + description: "Prepare a workspace for this thread.", icon: "Container", logoUrl: null, pluginId: "optional-sandbox", @@ -1681,8 +1728,10 @@ const OPTIONAL_INPUTS_PROVIDER: SystemEnvironmentProvider = { }; const BRANCH_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "branchy", displayName: "New branch workspace", + description: "Prepare a workspace for this thread.", icon: "GitBranch", logoUrl: null, pluginId: "branchy", @@ -1699,8 +1748,10 @@ const BRANCH_PROVIDER: SystemEnvironmentProvider = { }; const HOST_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "hosted", displayName: "Machine sandbox", + description: "Prepare a workspace for this thread.", icon: "Server", logoUrl: null, pluginId: "hosted", @@ -1730,6 +1781,9 @@ describe("NewThreadComposer environment providers", () => { mocks.sidebarNavigationSettled = true; mocks.sidebarNavigationReplayed = false; mocks.extraProjects = []; + mocks.plugins = []; + mocks.serverAccessReady = true; + mocks.machineProviders = []; mocks.environmentProviders = [CHECKOUT_PROVIDER]; resetPluginSlotStoreForTest(); registerCheckoutInputsControl(); @@ -1764,6 +1818,70 @@ describe("NewThreadComposer environment providers", () => { ); } + it("updates the access banner for a composed machine without relying on plugin status", async () => { + const composition: SystemEnvironmentProvider = { + ...OPTIONAL_INPUTS_PROVIDER, + machineProviderId: "qa-machine", + machineInputs: null, + machineAcceptsEmptyInputs: true, + }; + mocks.environmentProviders = [CHECKOUT_PROVIDER, composition]; + mocks.machineProviders = [ + { + id: "qa-machine", + displayName: "QA machine", + description: "Test machine", + icon: "Server", + logoUrl: null, + pluginId: "qa", + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: false, + }, + ]; + mocks.serverAccessReady = false; + const onSubmit = vi.fn(); + const rendered = renderUnseeded(onSubmit, "live-access"); + await act(async () => { + latestPromptBoxProps().modeConfig.environment.onSelectProvider( + composition, + null, + ); + }); + await screen.findByText("Pair with bb connect"); + expect(latestPromptBoxProps().disabled).toBe(true); + mocks.serverAccessReady = true; + rendered.rerender( + + + + , + ); + await waitFor(() => + expect(screen.queryByText("Pair with bb connect")).toBeNull(), + ); + expect(latestPromptBoxProps().disabled).toBe(false); + mocks.serverAccessReady = false; + rendered.rerender( + + + + , + ); + await screen.findByText("Pair with bb connect"); + expect(latestPromptBoxProps().disabled).toBe(true); + }); + it("submits the value the provider's configuration slot produced", async () => { mocks.environmentProviders = [CHECKOUT_PROVIDER, SANDBOX_PROVIDER]; setPluginSlotRegistrations("docker-sandbox", { @@ -1771,10 +1889,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 +1701,7 @@ export function NewThreadComposer({ projectOptions, projectSources, promptActions, + promptBoxFocusRequest, promptDraft, promptHistoryDrafts, promptMentions, @@ -1563,8 +1719,12 @@ export function NewThreadComposer({ supportsPermissionModeSelection, supportsServiceTier, submitDisabledReason, + machineServerAccessReason, + setupRequiredProvider, + environmentSetupRequiredReason, + navigate, environmentProviderInputsSlot, - environmentProvidersByHostId, + machineProviderInputs.control, inputsControlProviderIds, providerHostId, textEffects, @@ -1572,30 +1732,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..8763db3e6b 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,8 +29,10 @@ describe("ProjectlessMachineSlot", () => { }; const personalWorkspaceProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", + description: "Prepare a workspace for this thread.", icon: "Folder", logoUrl: null, pluginId: "environment-personal-workspace", @@ -54,6 +59,7 @@ describe("ProjectlessMachineSlot", () => { localDaemonHostId: string | null; primaryHostId: string | null; } | null; + machineProviders?: readonly SystemMachineProvider[]; }) { return { value: "provider:personal-workspace", @@ -70,6 +76,7 @@ describe("ProjectlessMachineSlot", () => { primaryHostId: host.id, }, providers: [personalWorkspaceProvider], + machineProviders: overrides?.machineProviders, selectedProviderHostId: overrides?.selectedProviderHostId ?? host.id, onSelectProvider: overrides?.onSelectProvider ?? vi.fn(), }; @@ -102,25 +109,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,8 +193,10 @@ describe("EnvironmentSlot", () => { }; const personalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", + description: "Prepare a workspace for this thread.", icon: "Folder", logoUrl: null, pluginId: "environment-personal-workspace", @@ -186,8 +213,10 @@ describe("EnvironmentSlot", () => { }; const sandboxProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "modal-sandbox", displayName: "Modal sandbox", + description: "Prepare a workspace for this thread.", icon: "Cloud", logoUrl: null, pluginId: "environment-modal-sandbox", @@ -203,10 +232,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 +269,7 @@ describe("EnvironmentSlot", () => { providers: overrides.providers ?? [personalProvider], selectedProviderHostId: host.id, onSelectProvider: overrides.onSelectProvider ?? vi.fn(), + machineProviders: overrides.machineProviders, }; } @@ -302,6 +345,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..07d0d7b157 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} @@ -480,10 +499,17 @@ export function ProjectlessMachineSlot({ environment, }: ProjectlessMachineSlotProps) { const machines = environment.machines ?? null; - const availableHosts = useMemo( - () => selectPersistentHosts(machines?.hosts), - [machines?.hosts], - ); + const selectedProviderHostId = environment.selectedProviderHostId ?? null; + const availableHosts = useMemo(() => { + const selectable = selectHosts(machines?.hosts, "persistent"); + const selected = machines?.hosts.find( + (candidate) => candidate.id === selectedProviderHostId, + ); + return selected === undefined || + selectable.some((candidate) => candidate.id === selected.id) + ? selectable + : [...selectable, selected]; + }, [machines?.hosts, selectedProviderHostId]); const parsedEnvironment = useMemo( () => parseEnvironmentValue(environment.value), [environment.value], @@ -506,7 +532,11 @@ export function ProjectlessMachineSlot({ }, [handleSelectProvider, selectedProvider], ); - if (!machines || availableHosts.length <= 1) { + if ( + selectedProvider?.machineProviderId || + !machines || + availableHosts.length <= 1 + ) { return null; } return ( @@ -523,6 +553,7 @@ export function ProjectlessMachineSlot({ disabled={environment.disabled} className="shrink-0" muted + machineProviders={environment.machineProviders} /> ); } @@ -537,6 +568,7 @@ interface NewThreadConnectedModeConfig { worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -554,8 +586,12 @@ 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, "persistent"), + [hosts], + ); const primaryHost = useMemo( () => selectPrimaryHost(availableHosts, primaryHostId), [availableHosts, primaryHostId], @@ -586,11 +622,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..da87da07e0 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.stories.tsx @@ -0,0 +1,117 @@ +import type { SystemMachineProvider } from "@bb/server-contract"; +import modalLogoUrl from "../../../../../../plugins/environment-modal-sandbox/modal-logo.svg?url"; +import 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 }) { + return ( + + ); +} + +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..cefc7330f5 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, 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, +}; + +afterEach(cleanup); + +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(); + expect( + (screen.getByRole("button", { name: "Resume" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + }); + + it.each([ + ["suspending", "Machine is pausing…"], + ["resuming", "Machine is resuming…"], + ] as const)("renders the durable %s phase", (phase, status) => { + render( + , + ); + + expect(screen.getByRole("status").textContent).toBe(status); + expect(screen.queryByRole("button", { name: "Resume" })).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..a193f3de89 --- /dev/null +++ b/apps/app/src/components/promptbox/banner/ThreadMachineStatus.tsx @@ -0,0 +1,155 @@ +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 phase = host.lifecycle.phase; + if (phase !== "suspending" && phase !== "suspended" && phase !== "resuming") + return null; + return ( + provider.id === host.machineProviderId, + )} + phase={phase} + error={ + resume.error + ? getMutationErrorMessage({ + error: resume.error, + fallbackMessage: "Could not resume the machine.", + }) + : null + } + onResume={() => resume.mutate(hostId)} + /> + ); +} + +export function ThreadMachineStatusBanner({ + host, + provider, + phase, + error, + onResume, +}: { + host: MachineLabelHost; + provider: SystemMachineProvider | undefined; + phase: "suspending" | "suspended" | "resuming"; + error: string | null; + onResume: () => void; +}) { + const phaseWord = + phase === "resuming" + ? "resuming…" + : phase === "suspending" + ? "pausing…" + : "paused"; + const status = `Machine is ${phaseWord}`; + const detail = error && phase !== "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..4a0e0bf53f 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,8 +69,10 @@ function withQueryClient( } const worktreeProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", + description: "Prepare a workspace for this thread.", icon: "GitBranch", logoUrl: null, pluginId: "environment-git-worktree", @@ -70,8 +89,10 @@ const worktreeProvider: SystemEnvironmentProvider = { }; const modalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "modal-sandbox", displayName: "Modal sandbox", + description: "Prepare a workspace for this thread.", icon: "Cloud", logoUrl: null, pluginId: "environment-modal-sandbox", @@ -88,8 +109,10 @@ const modalProvider: SystemEnvironmentProvider = { }; const personalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", + description: "Prepare a workspace for this thread.", icon: "Folder", logoUrl: null, pluginId: "environment-personal-workspace", @@ -120,6 +143,10 @@ function renderEnvironmentRow( environment: Environment, registeredProviders?: readonly SystemEnvironmentProvider[], environmentDisplayHost: EnvironmentDisplayHostContext = localHost, + machines?: { + hosts: readonly Host[]; + providers: readonly SystemMachineProvider[]; + }, ): string { return renderToStaticMarkup( withQueryClient( @@ -133,6 +160,7 @@ function renderEnvironmentRow( , registeredProviders, + machines, ), ); } @@ -180,6 +208,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 +329,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 +341,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/CliSkillsSettingsSection.tsx b/apps/app/src/components/settings/CliSkillsSettingsSection.tsx index 8c3dc0cd85..40f7a8a761 100644 --- a/apps/app/src/components/settings/CliSkillsSettingsSection.tsx +++ b/apps/app/src/components/settings/CliSkillsSettingsSection.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { Host } from "@bb/domain"; import type { CliSkillMachineStatus, @@ -13,7 +13,7 @@ import { import { appToast } from "@/components/ui/app-toast"; import { InstallCliSkillsDialog } from "@/components/settings/InstallCliSkillsDialog"; import { useInstallCliSkills } from "@/hooks/mutations/settings-mutations"; -import { useHosts } from "@/hooks/queries/host-queries"; +import { selectHosts, useHosts } from "@/hooks/queries/host-queries"; import { useCliSkillsStatus } from "@/hooks/queries/system-queries"; const CLI_SKILLS_SETTING_LABEL = "bb CLI skills"; @@ -106,7 +106,10 @@ export function CliSkillsSettingsSection() { const statusQuery = useCliSkillsStatus(); const installCliSkills = useInstallCliSkills(); const [pickerOpen, setPickerOpen] = useState(false); - const hosts: readonly Host[] = hostsQuery.data ?? []; + const hosts: readonly Host[] = useMemo( + () => selectHosts(hostsQuery.data, "persistent"), + [hostsQuery.data], + ); const statuses = statusByHostId(statusQuery.data); return ( 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..ea9f4642a2 --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.stories.tsx @@ -0,0 +1,175 @@ +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, + availability: null, +}; + +function access(): ServerAccessStatus { + return { + providers: [ + { + id: "relay", + displayName: "Managed relay", + description: "Use a managed relay address.", + pluginId: "relay-plugin", + availability: { status: "available" }, + }, + 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(); +PROVIDER_SETUP_REQUIRED.providers[0]!.availability = { + status: "setup-required", + message: "Set up the relay", +}; +const PROVIDER_READY = access(); +PROVIDER_READY.providers[0]!.availability = { + status: "available", + serverUrl: "https://bb.example.com", +}; +const PROVIDER_READY_WITHOUT_URL = access(); +const PROVIDER_UNAVAILABLE = access(); +PROVIDER_UNAVAILABLE.providers[0]!.availability = { + status: "unavailable", + message: "The relay rejected this server’s credential", +}; +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.test.tsx b/apps/app/src/components/settings/MachineAccessSettings.test.tsx new file mode 100644 index 0000000000..9be890b246 --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { makeSystemConfig } from "@/test/fixtures/system-config"; +import { MachineAccessSettings } from "./MachineAccessSettings"; + +const state = vi.hoisted(() => ({ + config: undefined as ReturnType | undefined, +})); +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ data: state.config }), +})); +vi.mock("@/hooks/mutations/settings-mutations", () => ({ + useUpdateGeneralSettings: () => ({ isPending: false }), +})); + +afterEach(cleanup); + +it("follows access availability through setup, readiness, revocation, and recovery", () => { + state.config = makeSystemConfig({ + serverAccess: { + defaultProviderId: "relay", + effectiveUrl: null, + urlSource: null, + providers: [ + { + id: "relay", + displayName: "Relay", + description: "A relay", + pluginId: "relay-plugin", + availability: { status: "setup-required", message: "Pair the relay" }, + }, + ], + }, + }); + const view = () => ( + + + + ); + const rendered = render(view()); + expect(screen.getByText("Pair the relay")).toBeTruthy(); + expect(screen.queryByText("Ready")).toBeNull(); + const provider = state.config.serverAccess.providers[0]!; + provider.availability = { + status: "available", + serverUrl: "https://relay.example.com", + }; + rendered.rerender(view()); + expect(screen.getByText("Ready")).toBeTruthy(); + expect(screen.getByText("https://relay.example.com")).toBeTruthy(); + provider.availability = { + status: "unavailable", + message: "Credential revoked", + }; + rendered.rerender(view()); + expect(screen.getByText("Unavailable")).toBeTruthy(); + expect(screen.getByText("Credential revoked")).toBeTruthy(); + expect(screen.queryByText("Ready")).toBeNull(); + provider.availability = { status: "available" }; + rendered.rerender(view()); + expect(screen.getByText("Ready")).toBeTruthy(); + expect(screen.getByText("Ready to add machines.")).toBeTruthy(); + state.config.serverAccess.providers = []; + rendered.rerender(view()); + expect( + screen.getByText("This connection method is not installed."), + ).toBeTruthy(); + expect(screen.queryByText("Ready")).toBeNull(); +}); diff --git a/apps/app/src/components/settings/MachineAccessSettings.tsx b/apps/app/src/components/settings/MachineAccessSettings.tsx new file mode 100644 index 0000000000..a0c0aeaee6 --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.tsx @@ -0,0 +1,321 @@ +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 { machineServerAccessBlockedReason } from "@/components/machines/machine-server-access"; + +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 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, + ); + return { + access, + disabled, + draft, + error, + effective, + configurationMessage: machineServerAccessBlockedReason(access, selected), + 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 ?? + (effective.availability?.status === "available" + ? effective.availability.serverUrl + : null) ?? + "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.test.tsx b/apps/app/src/components/settings/ProjectsSettingsSection.test.tsx index c1175c0d1a..e30e49d155 100644 --- a/apps/app/src/components/settings/ProjectsSettingsSection.test.tsx +++ b/apps/app/src/components/settings/ProjectsSettingsSection.test.tsx @@ -177,6 +177,45 @@ afterEach(() => { vi.clearAllMocks(); }); +it("counts only persistent checkouts and their connection status in project summaries", async () => { + const sandbox = host({ + id: "host_sandbox", + name: "Sandbox", + type: "ephemeral", + }); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + primaryHost, + remoteHost, + sandbox, + ]); + stubSidebarBootstrapFetch([ + { + id: "proj_all", + name: "All checkouts", + gitRemoteUrl: null, + hostIds: [primaryHost.id, remoteHost.id, sandbox.id], + threadCount: 1, + }, + { + id: "proj_offline", + name: "Offline checkout", + gitRemoteUrl: null, + hostIds: [remoteHost.id, sandbox.id], + threadCount: 1, + }, + ]); + renderSection(); + const all = await screen.findByRole("link", { + name: "Open All checkouts settings", + }); + const offline = screen.getByRole("link", { + name: "Open Offline checkout settings", + }); + expect(all.textContent).toContain("2 of 2 machines"); + expect(offline.textContent).toContain("1 of 2 machines"); + expect(offline.textContent).toContain("offline"); +}); + describe("buildProjectReorderRequest", () => { const ids = ["a", "b", "c", "d"]; diff --git a/apps/app/src/components/settings/ProjectsSettingsSection.tsx b/apps/app/src/components/settings/ProjectsSettingsSection.tsx index e274e04b60..4f047599c5 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, "persistent"), [hostsQuery.data], ); const hostById = useMemo( diff --git a/apps/app/src/components/settings/ProvidersSettingsSection.tsx b/apps/app/src/components/settings/ProvidersSettingsSection.tsx index be443c0b03..9ea59d5d07 100644 --- a/apps/app/src/components/settings/ProvidersSettingsSection.tsx +++ b/apps/app/src/components/settings/ProvidersSettingsSection.tsx @@ -109,7 +109,11 @@ function SortableProviderRow({ }), [transform, transition], ); - const ProviderIcon = getProviderIconInfo(provider.id, provider)?.icon; + const ProviderIcon = getProviderIconInfo( + "agent", + provider.id, + provider, + )?.icon; const isDefault = generalSettings.defaultProviderId === provider.id || (generalSettings.defaultProviderId === null && index === 0); diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.tsx index c068a63ec3..b0ebd7f716 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.tsx @@ -1063,6 +1063,7 @@ export function MachineUpdatesRows({ (candidate) => candidate.id === providerId, ); const ProviderIcon = getProviderIconInfo( + "agent", providerId, providerInfo ?? null, )?.icon; diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index 149fc0c1a2..5b0c263010 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { Host, ProviderInfo } from "@bb/domain"; import type { ProviderUsage, @@ -26,7 +26,11 @@ import { useSystemProviders, type ProviderUsageQueryState, } from "@/hooks/queries/system-queries"; -import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +import { + selectHosts, + selectPrimaryHost, + useHosts, +} from "@/hooks/queries/host-queries"; import { getProviderIconInfo } from "@/lib/provider-icon"; import { ProviderIconMark } from "./ProviderIconMark"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -229,6 +233,7 @@ function ProviderUsageBlock({ const planLabel = usage?.status === "ok" ? usage.planLabel : null; const accountEmail = usage?.status === "ok" ? usage.accountEmail : null; const iconInfo = getProviderIconInfo( + "agent", config.providerId, config.provider ?? null, ); @@ -450,7 +455,10 @@ export function UsageLimitsSettingsSectionContent({ export function UsageLimitsSettingsSection() { const systemConfigQuery = useSystemConfig(); const hostsQuery = useHosts(); - const hosts = hostsQuery.data ?? []; + const hosts = useMemo( + () => selectHosts(hostsQuery.data, "persistent"), + [hostsQuery.data], + ); const [selectedHostId, setSelectedHostId] = useState(null); const primaryHost = selectPrimaryHost( hosts, diff --git a/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx b/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx index 3c05850a14..371e5fa889 100644 --- a/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx +++ b/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx @@ -123,6 +123,7 @@ export function SidebarUpdatesBadge({ onNavigate }: SidebarUpdatesBadgeProps) { (candidate) => candidate.id === providerId, ); const iconInfo = getProviderIconInfo( + "agent", providerId, provider ?? null, ); diff --git a/apps/app/src/components/tools/SkillsCollection.tsx b/apps/app/src/components/tools/SkillsCollection.tsx index 40bd79010c..ed15cbb8b1 100644 --- a/apps/app/src/components/tools/SkillsCollection.tsx +++ b/apps/app/src/components/tools/SkillsCollection.tsx @@ -107,7 +107,7 @@ export function ProviderLogo({ provider?: ProviderInfo | undefined; className?: string; }) { - const info = getProviderIconInfo(providerId, provider ?? null); + const info = getProviderIconInfo("agent", providerId, provider ?? null); if (!info) { return null; } 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/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 4909bdcec7..ddd7b3378a 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -525,6 +525,7 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = { }, "plugins-changed": { dirty: [ + dirtySystemConfigQueries, dirtyPluginContributionQueries, dirtyProjectCommandCatalogQueries, dirtyPluginManagementQueries, 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..2a8a8f8514 100644 --- a/apps/app/src/hooks/queries/environment-provider-queries.test.tsx +++ b/apps/app/src/hooks/queries/environment-provider-queries.test.tsx @@ -17,8 +17,10 @@ vi.mock("@/lib/sdk", () => ({ })); const WORKTREE_PROVIDER: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", + description: "Prepare a workspace for this thread.", icon: "GitBranch", logoUrl: null, pluginId: "environment-git-worktree", diff --git a/apps/app/src/hooks/queries/host-queries.test.ts b/apps/app/src/hooks/queries/host-queries.test.ts index 2398b21419..31fbc3f541 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,43 @@ 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", () => { + const hosts = [ + host({ id: "host_local", type: "persistent" }), + host({ + id: "host_modal", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }), + ]; + + it("drops disposable sandboxes from machine choices", () => { + expect( + selectHosts(hosts, "persistent").map((candidate) => candidate.id), + ).toEqual(["host_local"]); + }); + + it("keeps every machine when a caller asks for all of them", () => { + expect(selectHosts(hosts, "all").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..9f483baaba 100644 --- a/apps/app/src/hooks/queries/host-queries.ts +++ b/apps/app/src/hooks/queries/host-queries.ts @@ -12,33 +12,47 @@ 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( +export type HostScope = "persistent" | "all"; + +export function selectHosts( hosts: readonly Host[] | undefined, + scope: HostScope, ): Host[] { - return hosts ? [...hosts] : []; + const everyHost = hosts ? [...hosts] : []; + return scope === "all" + ? everyHost + : everyHost.filter((host) => host.type !== "ephemeral"); } export function selectPrimaryHost( hosts: readonly Host[] | undefined, primaryHostId: string | null, ): Host | null { - if (!hosts || hosts.length === 0) return null; + const availableHosts = selectHosts(hosts, "persistent"); + 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/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index 5594603cf1..0983b3c488 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -266,6 +266,37 @@ describe("createRealtimeCacheEffects", () => { ); }); + it("refreshes cached access configuration when an installed plugin is disabled", async () => { + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const key = systemConfigQueryKey(); + const initial = { + serverAccess: { + providers: [{ id: "relay", availability: { status: "available" } }], + }, + }; + const removed = { serverAccess: { providers: [] } }; + queryClient.setQueryData(key, initial); + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: async () => removed, + staleTime: Infinity, + }); + const unsubscribe = observer.subscribe(() => {}); + try { + effects.handleChanged({ + type: "changed", + entity: "system", + changes: ["plugins-changed"], + }); + await vi.waitFor(() => + expect(queryClient.getQueryData(key)).toEqual(removed), + ); + } finally { + unsubscribe(); + effects.dispose(); + } + }); + it("invalidates provider pickers on provider registration changes", () => { const { effects, queryClient } = createRealtimeEffectsTestContext(); const providersKey = systemProvidersQueryKey({ hostId: "host-1" }); diff --git a/apps/app/src/hooks/useLocalPathPicker.tsx b/apps/app/src/hooks/useLocalPathPicker.tsx index aba734cdd4..5f7f95a8e7 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, "all").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..43d79bbf9b 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, @@ -49,7 +49,7 @@ export function useQuickCreateProject(): QuickCreateProjectController { const { mutate, isPending } = useCreateProject(); const hostsQuery = useHosts(); const hosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), + () => selectHosts(hostsQuery.data, "all"), [hostsQuery.data], ); const navigate = useNavigate(); diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index 01dd33e1b9..fdf213a4d2 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -451,7 +451,7 @@ export function useThreadCreationOptions( providers.map((p) => ({ value: p.id, label: p.displayName, - icon: getProviderIconInfo(p.id, p)?.icon, + icon: getProviderIconInfo("agent", p.id, p)?.icon, ...(p.strings?.brandPrefix === undefined ? {} : { brandPrefix: p.strings.brandPrefix }), 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..a08449fecb 100644 --- a/apps/app/src/lib/environment-workspace-display.test.ts +++ b/apps/app/src/lib/environment-workspace-display.test.ts @@ -1,3 +1,4 @@ +import type { Host } from "@bb/domain"; import { describe, expect, it } from "vitest"; import type { EnvironmentDisplayInfo } from "@bb/core-ui"; import type { SystemEnvironmentProvider } from "@bb/server-contract"; @@ -11,14 +12,20 @@ import { describe("shouldShowEnvironmentHostIdentity", () => { it("keeps the machine identity for a projectless thread with one machine", () => { - expect(shouldShowEnvironmentHostIdentity(false, true)).toBe(true); - expect(shouldShowEnvironmentHostIdentity(false, false)).toBe(false); + expect(shouldShowEnvironmentHostIdentity(false, true, "persistent")).toBe( + true, + ); + expect(shouldShowEnvironmentHostIdentity(false, false, "persistent")).toBe( + false, + ); }); }); const worktreeProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "git-worktree", displayName: "Worktree", + description: "Prepare a workspace for this thread.", icon: "FolderGit", logoUrl: null, pluginId: "environment-git-worktree", @@ -35,8 +42,10 @@ const worktreeProvider: SystemEnvironmentProvider = { }; const personalProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "personal-workspace", displayName: "Personal workspace", + description: "Prepare a workspace for this thread.", icon: "Folder", logoUrl: null, pluginId: "environment-personal-workspace", @@ -53,8 +62,10 @@ const personalProvider: SystemEnvironmentProvider = { }; const machineContainerProvider: SystemEnvironmentProvider = { + machineProviderId: null, id: "container", displayName: "Container", + description: "Prepare a workspace for this thread.", icon: "Box", logoUrl: null, pluginId: "containers", @@ -108,6 +119,7 @@ interface SummaryDisplayOverrides { environmentName?: string | null; hasMultipleMachines?: boolean; hostName?: string | null; + hostType?: Host["type"] | null; isProjectless?: boolean; } @@ -117,6 +129,7 @@ function getSummaryDisplay({ environmentName = null, hasMultipleMachines = false, hostName = "Michael-M4", + hostType = "persistent", isProjectless = false, }: SummaryDisplayOverrides = {}) { return getEnvironmentWorkspaceSummaryDisplay({ @@ -125,6 +138,7 @@ function getSummaryDisplay({ environmentName, hasMultipleMachines, hostName, + hostType, isProjectless, }); } @@ -178,6 +192,24 @@ describe("getEnvironmentDisplayIconName", () => { }); describe("getEnvironmentWorkspaceSummaryDisplay", () => { + it("retains the current sandbox identity when persistent machine choices are singular", () => { + expect(shouldShowEnvironmentHostIdentity(false, false, "ephemeral")).toBe( + true, + ); + expect( + getSummaryDisplay({ + providerLookup: worktreeProviderLookup, + hostName: "Modal sandbox", + hostType: "ephemeral", + hasMultipleMachines: false, + }), + ).toMatchObject({ + label: "Modal sandbox", + compactLabel: "Modal sandbox", + icon: "FolderGit", + }); + }); + it("keeps provisioning ahead of the provider icon and label", () => { expect( getSummaryDisplay({ diff --git a/apps/app/src/lib/environment-workspace-display.ts b/apps/app/src/lib/environment-workspace-display.ts index 473e0a8432..2a3403a1b7 100644 --- a/apps/app/src/lib/environment-workspace-display.ts +++ b/apps/app/src/lib/environment-workspace-display.ts @@ -1,3 +1,4 @@ +import type { Host } from "@bb/domain"; import type { EnvironmentDisplayInfo, EnvironmentDisplayProviderLookup, @@ -22,8 +23,9 @@ export const REUSE_ENVIRONMENT_ICON_NAME: IconName = "Folder02"; export function shouldShowEnvironmentHostIdentity( hasMultipleMachines: boolean, isProjectless: boolean, + hostType: Host["type"] | null, ): boolean { - return hasMultipleMachines || isProjectless; + return hasMultipleMachines || isProjectless || hostType === "ephemeral"; } interface EnvironmentWorkspaceLabelArgs { @@ -33,6 +35,7 @@ interface EnvironmentWorkspaceLabelArgs { } interface EnvironmentWorkspaceSummaryDisplayArgs extends EnvironmentWorkspaceLabelArgs { + hostType: Host["type"] | null; hasMultipleMachines: boolean; hostName: string | null; isProjectless: boolean; @@ -112,6 +115,7 @@ export function getEnvironmentWorkspaceSummaryDisplay({ providerLookup, environmentName, hasMultipleMachines, + hostType, hostName, isProjectless, }: EnvironmentWorkspaceSummaryDisplayArgs): EnvironmentWorkspaceSummaryDisplay | null { @@ -143,7 +147,11 @@ export function getEnvironmentWorkspaceSummaryDisplay({ return null; } if (machineIsWorkspaceIdentity(providerLookup)) { - return (hasMultipleMachines || isProjectless) && hostName !== null + return shouldShowEnvironmentHostIdentity( + hasMultipleMachines, + isProjectless, + hostType, + ) && hostName !== null ? { label: hostName, compactLabel: hostName, diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index 9d56b2b842..8f54f35f29 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, @@ -12,7 +13,6 @@ import type { PluginMessageDirectiveRegistration, PluginNavPanelRegistration, PluginNewThreadPanelActionRegistration, - PluginProviderIconRegistration, PluginSettingsSectionRegistration, PluginSidebarFooterActionRegistration, ExperimentalSidebarNavigationRegistration, @@ -25,6 +25,7 @@ import type { import { adaptSidebarFooterAction, getCollectedSidebarFooterItems, + type CollectedPluginProviderIconRegistration, type CollectedExperimentalSidebarFooterItem, type CollectedManagedSidebarFooterItem, type CollectedSidebarFooterItem, @@ -50,9 +51,10 @@ export interface PluginRegistrationSet { messageDirectives: readonly PluginMessageDirectiveRegistration[]; messageActions?: readonly PluginMessageActionRegistration[]; commandPaletteActions?: readonly PluginCommandPaletteActionRegistration[]; - providerIcons?: readonly PluginProviderIconRegistration[]; + providerIcons?: readonly CollectedPluginProviderIconRegistration[]; timelineRenderers?: readonly PluginTimelineRendererRegistration[]; environmentProviderInputs?: readonly PluginEnvironmentProviderInputsRegistration[]; + machineProviderInputs?: readonly PluginMachineProviderInputsRegistration[]; } interface PluginSlotBase { @@ -97,11 +99,13 @@ export interface PluginMessageActionSlot export interface PluginCommandPaletteActionSlot extends PluginCommandPaletteActionRegistration, PluginSlotBase {} interface PluginProviderIconSlot - extends PluginProviderIconRegistration, PluginSlotBase {} + extends CollectedPluginProviderIconRegistration, PluginSlotBase {} 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), }; } @@ -271,7 +279,9 @@ function collectProviderIcons( if (flattened === undefined) continue; for (const slot of flattened.providerIcons) { const claimed = collected.find( - (existing) => existing.providerId === slot.providerId, + (existing) => + existing.providerKind === slot.providerKind && + existing.providerId === slot.providerId, ); if (claimed !== undefined) { console.warn( diff --git a/apps/app/src/lib/provider-icon.test.tsx b/apps/app/src/lib/provider-icon.test.tsx index f89439faa4..cacb2aae42 100644 --- a/apps/app/src/lib/provider-icon.test.tsx +++ b/apps/app/src/lib/provider-icon.test.tsx @@ -1,3 +1,7 @@ +import { + collectPluginAppRegistrations, + definePluginApp, +} from "./plugin-app-definition"; // @vitest-environment jsdom import { createElement } from "react"; @@ -26,8 +30,84 @@ afterEach(() => { }); describe("getProviderIconInfo", () => { + it("isolates same-id providers, prefers specific overrides, and restores legacy and asset fallbacks on unload", () => { + const kinds = ["agent", "machine", "environment"] as const; + const legacy = collectPluginAppRegistrations( + definePluginApp((app) => { + // @ts-expect-error legacy plugin declaration + app.slots.experimental_providerIcon({ + providerId: "shared", + icon: () => , + }); + }), + ); + setPluginSlotRegistrations("aaa-legacy", legacy); + const views = kinds.map((kind) => { + const info = getProviderIconInfo(kind, "shared", { + logoUrl: "/shared.svg", + }); + if (!info) throw new Error("Missing icon"); + return render(createElement(info.icon)); + }); + for (const view of views) + expect( + view.container.querySelector('[data-mark="legacy"]'), + ).not.toBeNull(); + act(() => { + for (const providerKind of kinds) { + setPluginSlotRegistrations( + `zzz-${providerKind}`, + collectPluginAppRegistrations( + definePluginApp((app) => { + app.slots.experimental_providerIcon({ + providerKind, + providerId: "shared", + icon: () => , + }); + }), + ), + ); + } + }); + for (const [index, kind] of kinds.entries()) { + expect( + views[index]!.container.querySelector("[data-mark]")?.getAttribute( + "data-mark", + ), + ).toBe(kind); + } + act(() => removePluginSlotRegistrations("zzz-machine")); + expect( + views[1]!.container + .querySelector("[data-mark]") + ?.getAttribute("data-mark"), + ).toBe("legacy"); + expect( + views[0]!.container + .querySelector("[data-mark]") + ?.getAttribute("data-mark"), + ).toBe("agent"); + expect( + views[2]!.container + .querySelector("[data-mark]") + ?.getAttribute("data-mark"), + ).toBe("environment"); + act(() => removePluginSlotRegistrations("aaa-legacy")); + expect( + views[1]!.container.querySelector("[data-provider-logo]"), + ).not.toBeNull(); + for (const kind of ["agent", "environment"] as const) + act(() => removePluginSlotRegistrations(`zzz-${kind}`)); + for (const view of views) { + expect( + view.container.querySelector("[data-provider-logo]"), + ).not.toBeNull(); + view.unmount(); + } + }); + it("draws a served logo as a currentColor mask", () => { - const iconInfo = getProviderIconInfo("acp-do-computer", { + const iconInfo = getProviderIconInfo("agent", "acp-do-computer", { logoUrl: "/api/v1/system/providers/acp-do-computer/logo", family: "acp", displayName: "Do Computer", @@ -37,7 +117,7 @@ describe("getProviderIconInfo", () => { } expect(iconInfo.ariaLabel).toBe("Do Computer"); expect( - getProviderIconInfo("acp-do-computer", { + getProviderIconInfo("agent", "acp-do-computer", { logoUrl: "/api/v1/system/providers/acp-do-computer/logo", family: "acp", displayName: "Do Computer", @@ -65,16 +145,19 @@ describe("getProviderIconInfo", () => { it("vendors no brand marks: a provider known only by id has no icon", () => { for (const providerId of ["codex", "claude-code", "pi", "acp-opencode"]) { - expect(getProviderIconInfo(providerId), providerId).toBeUndefined(); expect( - getProviderIconInfo(providerId, { logoUrl: null }), + getProviderIconInfo("agent", providerId), + providerId, + ).toBeUndefined(); + expect( + getProviderIconInfo("agent", providerId, { logoUrl: null }), providerId, ).toBeUndefined(); } }); it("draws a declared host glyph for a provider without a logo, and keeps it below a logo", () => { - const glyphInfo = getProviderIconInfo("echo-agent", { + const glyphInfo = getProviderIconInfo("agent", "echo-agent", { logoUrl: null, icon: { glyph: "Zap" }, }); @@ -82,7 +165,7 @@ describe("getProviderIconInfo", () => { throw new Error("Expected a glyph icon for echo-agent"); } expect( - getProviderIconInfo("echo-agent", { + getProviderIconInfo("agent", "echo-agent", { logoUrl: null, icon: { glyph: "Zap" }, })?.icon, @@ -99,13 +182,13 @@ describe("getProviderIconInfo", () => { glyphView.unmount(); expect( - getProviderIconInfo("echo-agent", { + getProviderIconInfo("agent", "echo-agent", { logoUrl: null, icon: { glyph: "NoSuchGlyph" }, }), ).toBeUndefined(); - const bothInfo = getProviderIconInfo("echo-agent", { + const bothInfo = getProviderIconInfo("agent", "echo-agent", { logoUrl: "/api/v1/system/providers/echo-agent/logo", icon: { glyph: "Zap" }, }); @@ -119,12 +202,12 @@ describe("getProviderIconInfo", () => { bothView.unmount(); expect( - getProviderIconInfo("echo-agent", { logoUrl: null }), + getProviderIconInfo("agent", "echo-agent", { logoUrl: null }), ).toBeUndefined(); }); it("lets a plugin-registered component win, and falls back when it goes away", () => { - const iconInfo = getProviderIconInfo("codex", { + const iconInfo = getProviderIconInfo("agent", "codex", { logoUrl: "/api/v1/system/providers/codex/logo", }); if (iconInfo === undefined) { @@ -137,7 +220,9 @@ describe("getProviderIconInfo", () => { act(() => { setPluginSlotRegistrations("provider-codex", { ...EMPTY_REGISTRATIONS, - providerIcons: [{ providerId: "codex", icon: PluginCodexIcon }], + providerIcons: [ + { providerKind: "agent", providerId: "codex", icon: PluginCodexIcon }, + ], }); }); @@ -160,9 +245,11 @@ describe("getProviderIconInfo", () => { it("renders a plugin icon for a provider that has no vendored mark", () => { setPluginSlotRegistrations("provider-thing", { ...EMPTY_REGISTRATIONS, - providerIcons: [{ providerId: "thing", icon: PluginCodexIcon }], + providerIcons: [ + { providerKind: "agent", providerId: "thing", icon: PluginCodexIcon }, + ], }); - const iconInfo = getProviderIconInfo("thing"); + const iconInfo = getProviderIconInfo("agent", "thing"); if (iconInfo === undefined) { throw new Error("Expected plugin icon info for thing"); } @@ -177,12 +264,15 @@ describe("getProviderIconInfo", () => { it("keeps the first plugin by id when two claim one provider", () => { setPluginSlotRegistrations("aaa-squatter", { ...EMPTY_REGISTRATIONS, - providerIcons: [{ providerId: "codex", icon: PluginCodexIcon }], + providerIcons: [ + { providerKind: "agent", providerId: "codex", icon: PluginCodexIcon }, + ], }); setPluginSlotRegistrations("provider-codex", { ...EMPTY_REGISTRATIONS, providerIcons: [ { + providerKind: "agent", providerId: "codex", icon: ({ className }: { className?: string }) => ( @@ -190,7 +280,7 @@ describe("getProviderIconInfo", () => { }, ], }); - const iconInfo = getProviderIconInfo("codex"); + const iconInfo = getProviderIconInfo("agent", "codex"); if (iconInfo === undefined) { throw new Error("Expected icon info for codex"); } @@ -205,7 +295,7 @@ describe("getProviderIconInfo", () => { }); it("uses the declared family for the generic mark, not the id prefix", () => { - const byFamily = getProviderIconInfo("amp", { + const byFamily = getProviderIconInfo("agent", "amp", { logoUrl: null, family: "acp", }); @@ -217,6 +307,6 @@ describe("getProviderIconInfo", () => { expect(byFamily.ariaLabel).toBe("ACP provider"); familyView.unmount(); - expect(getProviderIconInfo("acp-unregistered")).toBeUndefined(); + expect(getProviderIconInfo("agent", "acp-unregistered")).toBeUndefined(); }); }); diff --git a/apps/app/src/lib/provider-icon.ts b/apps/app/src/lib/provider-icon.ts index c65277de12..82339b2db7 100644 --- a/apps/app/src/lib/provider-icon.ts +++ b/apps/app/src/lib/provider-icon.ts @@ -1,3 +1,4 @@ +import type { PluginProviderIconRegistration } from "@get-bb/plugin-sdk/app"; import type { CSSProperties, ComponentType } from "react"; import { createElement, useSyncExternalStore } from "react"; import { isPresentationTintColor, type ProviderInfo } from "@bb/domain"; @@ -90,10 +91,18 @@ function getConfiguredProviderLogoIcon( } function getRegisteredPluginProviderIcon( + providerKind: PluginProviderIconRegistration["providerKind"], providerId: string, ): ComponentType<{ className?: string }> | undefined { - return getPluginSlotSnapshot().providerIcons.find( - (slot) => slot.providerId === providerId, + const slots = getPluginSlotSnapshot().providerIcons; + return ( + slots.find( + (slot) => + slot.providerKind === providerKind && slot.providerId === providerId, + ) ?? + slots.find( + (slot) => slot.providerKind === "all" && slot.providerId === providerId, + ) )?.icon; } @@ -103,11 +112,12 @@ const pluginAwareProviderIcons = new Map< >(); function getPluginAwareProviderIcon( + providerKind: PluginProviderIconRegistration["providerKind"], providerId: string, source: ProviderIconSource, staticIcon: ComponentType<{ className?: string }> | undefined, ): ComponentType<{ className?: string }> { - const cacheKey = `${providerId}\0${source.logoUrl ?? ""}\0${source.icon?.glyph ?? ""}\0${source.family ?? ""}`; + const cacheKey = `${providerKind}\0${providerId}\0${source.logoUrl ?? ""}\0${source.icon?.glyph ?? ""}\0${source.family ?? ""}`; const cached = pluginAwareProviderIcons.get(cacheKey); if (cached !== undefined) { return cached; @@ -118,7 +128,7 @@ function getPluginAwareProviderIcon( "use no memo"; const pluginIcon = useSyncExternalStore( subscribePluginSlots, - () => getRegisteredPluginProviderIcon(providerId), + () => getRegisteredPluginProviderIcon(providerKind, providerId), () => undefined, ); const ResolvedIcon = pluginIcon ?? staticIcon; @@ -131,17 +141,19 @@ function getPluginAwareProviderIcon( } export function getProviderIconInfo( + providerKind: PluginProviderIconRegistration["providerKind"], providerId: string, source: ProviderIconSource | null = null, ): ProviderIconInfo | undefined { const resolvedSource = source ?? { logoUrl: null }; const staticInfo = resolveStaticProviderIconInfo(providerId, resolvedSource); - const pluginIcon = getRegisteredPluginProviderIcon(providerId); + const pluginIcon = getRegisteredPluginProviderIcon(providerKind, providerId); if (staticInfo === undefined && pluginIcon === undefined) { return undefined; } return { icon: getPluginAwareProviderIcon( + providerKind, providerId, resolvedSource, staticInfo?.icon, 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..8cd9743cf9 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 ?? []; @@ -219,7 +317,11 @@ export function MachineSettingsView() { ...entry, providerId, provider, - ProviderIcon: getProviderIconInfo(providerId, provider ?? null)?.icon, + ProviderIcon: getProviderIconInfo( + "agent", + providerId, + provider ?? null, + )?.icon, }, ]; }); @@ -269,49 +371,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 +544,7 @@ export function MachineSettingsView() { variant="destructive" size="sm" disabled={isPrimary} - onClick={() => { - removeHost.reset(); - setRemoveOpen(true); - }} + onClick={() => setRemoveOpen(true)} > Remove machine @@ -497,46 +575,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.test.tsx b/apps/app/src/views/ProjectDetailSettingsView.test.tsx index 329c8c8a8d..a41a445fe4 100644 --- a/apps/app/src/views/ProjectDetailSettingsView.test.tsx +++ b/apps/app/src/views/ProjectDetailSettingsView.test.tsx @@ -165,6 +165,37 @@ afterEach(() => { }); describe("ProjectDetailSettingsView", () => { + it("keeps checkout counts in sync with the show-all machine toggle", async () => { + const sandbox = host({ + id: "host_sandbox", + name: "Sandbox", + type: "ephemeral", + }); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + primaryHost, + remoteHost, + sandbox, + ]); + stubSidebarBootstrapFetch( + [primaryHost, remoteHost, sandbox].map((machine) => ({ + hostId: machine.id, + path: `/repos/${machine.id}`, + })), + ); + renderView(); + await screen.findByRole("heading", { name: "bb" }); + expect(screen.getByText(/2 of 2 machines/)).toBeDefined(); + expect(screen.queryByRole("link", { name: sandbox.name })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show all machines" })); + expect(screen.getByText(/3 of 3 machines/)).toBeDefined(); + expect(screen.getByRole("link", { name: sandbox.name })).toBeDefined(); + fireEvent.click( + screen.getByRole("button", { name: "Show fewer machines" }), + ); + expect(screen.getByText(/2 of 2 machines/)).toBeDefined(); + expect(screen.queryByRole("link", { name: sandbox.name })).toBeNull(); + }); + it("lists every paired machine with its checkout or a set-up action", async () => { stubSidebarBootstrapFetch([ { hostId: "host_primary", path: "/Users/me/bb" }, diff --git a/apps/app/src/views/ProjectDetailSettingsView.tsx b/apps/app/src/views/ProjectDetailSettingsView.tsx index 30e0050a5b..b3b667bab1 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"; @@ -231,10 +231,17 @@ export function ProjectDetailSettingsView() { const projectSources = project?.sources; const sources = useMemo(() => projectSources ?? [], [projectSources]); const projectName = project?.name ?? ""; - const hosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), + const everyHost = useMemo( + () => selectHosts(hostsQuery.data, "all"), [hostsQuery.data], ); + const persistentHosts = useMemo( + () => selectHosts(hostsQuery.data, "persistent"), + [hostsQuery.data], + ); + const [showAllMachines, setShowAllMachines] = useState(false); + const hosts = showAllMachines ? everyHost : persistentHosts; + const hiddenMachineCount = everyHost.length - persistentHosts.length; const primaryHostId = systemConfig.data?.primaryHostId ?? null; const localSourcePending = @@ -331,7 +338,10 @@ export function ProjectDetailSettingsView() { project.gitRemoteUrl === null ? null : formatGitRemote(project.gitRemoteUrl); - const configuredCount = new Set(sources.map((source) => source.hostId)).size; + const configuredHostIds = new Set(sources.map((source) => source.hostId)); + const configuredCount = hosts.filter((host) => + configuredHostIds.has(host.id), + ).length; const defaults = defaultsQuery.data ?? null; const permissionLabel = defaults === null @@ -444,6 +454,26 @@ export function ProjectDetailSettingsView() { })}
)} + {hiddenMachineCount > 0 ? ( + + ) : null}
{ 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,9 +288,11 @@ function makeProjectSource(hostId = "host_1"): ProjectSource { function makeProjectProvider(id: string): SystemEnvironmentProvider { return { + machineProviderId: null, id, displayName: id, - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", logoUrl: null, pluginId: id, acceptsEmptyInputs: true, @@ -298,9 +313,11 @@ function makeProjectlessProvider( projectless: boolean, ): SystemEnvironmentProvider { return { + machineProviderId: null, id, displayName: id, - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", logoUrl: null, pluginId: id, acceptsEmptyInputs: true, 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, "persistent").length > 1; const threadEnvironmentHost = shouldShowEnvironmentHostIdentity( hasMultipleMachines, thread?.projectId === PERSONAL_PROJECT_ID, + resolvedThreadEnvironmentHost?.type ?? null, ) ? resolvedThreadEnvironmentHost : null; @@ -1229,6 +1232,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { }); const { providers: registeredEnvironmentProviders } = useSystemEnvironmentProviders(); + const { providers: registeredMachineProviders } = useSystemMachineProviders(); const environmentMergeBaseBranch = resolveEnvironmentMergeBaseBranch(environment); const { @@ -2404,9 +2408,19 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { environmentName: environment?.name ?? null, hasMultipleMachines, hostName: resolvedThreadEnvironmentHost?.name ?? null, + hostType: resolvedThreadEnvironmentHost?.type ?? null, 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 +2542,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/environment.test.ts b/apps/cli/src/__tests__/command-output/environment.test.ts index db7992cbc6..74afc4cf34 100644 --- a/apps/cli/src/__tests__/command-output/environment.test.ts +++ b/apps/cli/src/__tests__/command-output/environment.test.ts @@ -108,7 +108,8 @@ describe("bb environment command output", () => { { id: "git-worktree", displayName: "Worktree", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", pluginId: "environment-git-worktree", acceptsEmptyInputs: false, machineAvailability: {}, @@ -128,7 +129,8 @@ describe("bb environment command output", () => { { id: "modal-sandbox", displayName: "Modal sandbox", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", pluginId: "environment-modal-sandbox", acceptsEmptyInputs: true, machineAvailability: {}, @@ -193,7 +195,8 @@ describe("bb environment command output", () => { it("bb environment providers prints each provider's availability on the chosen machine", async () => { const provider = { displayName: "Provider", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", pluginId: "plugin", acceptsEmptyInputs: true, inputs: null, 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..7bbfe0973d 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,22 +54,162 @@ 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("bb machine list --json prints the raw host list", async () => { - stubServerApi({ "v1.hosts.$get": vi.fn(async () => hosts) }); + 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", + ]); + }); - await runCommand(["machine", "list", "--json"], register); + 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("waits for delayed manual enrollment readiness and prints the command once", async () => { + const command = "curl -fsSL https://machine.example/install.sh | sh"; + const enrollment = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue({ command, expiresAt: Date.now() + 60_000 }); + const get = vi + .fn() + .mockResolvedValueOnce(creating) + .mockResolvedValueOnce(creating) + .mockResolvedValue(hosts[1]); + stubServerApi({ + "v1.hosts.$post": vi.fn(async () => creating), + "v1.hosts.:id.enrollment-command.$get": enrollment, + "v1.hosts.:id.$get": get, + }); + await runCommand(["machine", "create", "--provider", "manual"], register); expect( - JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])), - ).toEqual(hosts); + collectLogPayloads(vi.mocked(console.error)).filter( + (value) => value === command, + ), + ).toEqual([command]); + expect(enrollment).toHaveBeenCalledTimes(2); + }); + + 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.each(["list", "show", "remove"] as const)( + "can %s a machine before its first enrollment", + async (command) => { + const pending = { ...creating, id: "host-pending", name: "pending" }; + const get = vi.fn(async () => pending); + const remove = vi.fn(async () => undefined); + stubServerApi({ + "v1.hosts.$get": vi.fn( + async ({ query }: { query: { includeCreating?: string } }) => + query.includeCreating === "true" ? [...hosts, pending] : hosts, + ), + "v1.hosts.:id.$get": get, + "v1.hosts.:id.$delete": remove, + }); + + await runCommand( + command === "list" + ? ["machine", "list", "--json"] + : command === "show" + ? ["machine", "show", pending.id, "--json"] + : ["machine", "remove", pending.name, "--yes"], + register, + ); + + if (command === "remove") { + expect(remove).toHaveBeenCalledWith({ param: { id: pending.id } }); + } else { + expect( + JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])), + ).toEqual(command === "list" ? [...hosts, pending] : pending); + if (command === "show") + expect(get).toHaveBeenCalledWith({ param: { id: pending.id } }); + } + }, + ); + it("bb machine list renders names, IDs, status, and relative last seen", async () => { vi.spyOn(Date, "now").mockReturnValue(1_700_000_120_000); stubServerApi({ "v1.hosts.$get": vi.fn(async () => hosts) }); @@ -62,11 +218,33 @@ 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 Type Status Provider Last seen\n----------- ------------ ---------- ------------ ------------- ---------\nworkstation host-primary persistent connected user-enrolled 2m ago\n----------- ------------ ---------- ------------ ------------- ---------\nlaptop host-remote persistent disconnected ssh never", "", ]); }); + it("hides disposable sandboxes from bb machine list until --all", async () => { + const sandbox: Host = { + ...hosts[0]!, + id: "host-sandbox", + name: "sandbox", + type: "ephemeral", + machineProviderId: "modal-sandbox", + }; + stubServerApi({ "v1.hosts.$get": vi.fn(async () => [...hosts, sandbox]) }); + + await runCommand(["machine", "list", "--json"], register); + expect( + JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])), + ).toEqual(hosts); + + vi.mocked(console.log).mockClear(); + await runCommand(["machine", "list", "--all", "--json"], register); + expect( + JSON.parse(String(vi.mocked(console.log).mock.calls[0]?.[0])), + ).toEqual([...hosts, sandbox]); + }); + it("bb machine retry-update resolves the machine and requests a retry", async () => { const retryUpdate = vi.fn(async () => ({ ok: true as const })); stubServerApi({ @@ -81,6 +259,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..dbf23c01c8 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, @@ -1059,7 +1056,8 @@ describe("bb thread spawn command output", () => { { id: "git-worktree", displayName: "Worktree", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", pluginId: "environment-git-worktree", acceptsEmptyInputs: false, machineAvailability: {}, @@ -1079,7 +1077,8 @@ describe("bb thread spawn command output", () => { { id: "plain", displayName: "Plain", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", pluginId: "plain", acceptsEmptyInputs: true, machineAvailability: {}, @@ -1095,7 +1094,8 @@ describe("bb thread spawn command output", () => { { id: "optional", displayName: "Optional inputs", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", pluginId: "optional", acceptsEmptyInputs: true, machineAvailability: {}, 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..a7754f8e96 100644 --- a/apps/cli/src/commands/machine.ts +++ b/apps/cli/src/commands/machine.ts @@ -1,15 +1,36 @@ +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 MachineEnumerationOptions extends MachineListCommandOptions { + all?: boolean; +} + +interface MachineCreateCommandOptions extends MachineListCommandOptions { + provider: string; + wait: boolean; + key?: string; + inputs?: string; +} + interface MachineMutationCommandOptions extends MachineListCommandOptions { yes?: boolean; } @@ -18,6 +39,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) @@ -30,6 +91,17 @@ function describeMachines(hosts: readonly Host[]): string { return hosts.map((host) => `${host.name} (${host.id})`).join(", "); } +export type MachineScope = "persistent" | "all"; + +export function selectMachines( + hosts: readonly Host[], + scope: MachineScope, +): Host[] { + return scope === "all" + ? [...hosts] + : hosts.filter((host) => host.type !== "ephemeral"); +} + export function resolveMachineId( hosts: readonly Host[], target: string, @@ -106,7 +178,9 @@ export async function resolveMachineHostId(args: { serverUrl: string; target: string; }): Promise { - const hosts = await createCliBbSdk(args.serverUrl).hosts.list(); + const hosts = await createCliBbSdk(args.serverUrl).hosts.list({ + includeCreating: true, + }); const hostId = resolveMachineId(hosts, args.target); if ( args.requireConnected && @@ -125,13 +199,151 @@ 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; + } + let enrollmentCommandShown = false; + const reportEnrollmentCommand = async (current: Host) => { + if ( + enrollmentCommandShown || + current.lifecycle.phase !== "creating" + ) + return; + const enrollmentCommand = + await sdk.hosts.experimental_getEnrollmentCommand({ + hostId: current.id, + signal: controller.signal, + }); + if (enrollmentCommand !== null) { + console.error(enrollmentCommand.command); + console.error( + enrollmentExpiryNotice(enrollmentCommand.expiresAt), + ); + enrollmentCommandShown = true; + } + }; + await reportEnrollmentCommand(host); + console.error(`Following machine ${host.id}`); + host = await waitForMachineLifecycle({ + host, + targetPhase: "active", + getHost: async () => { + const current = await sdk.hosts.get({ + hostId: host.id, + signal: controller.signal, + }); + await reportEnrollmentCommand(current); + return current; + }, + }); + 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") + .option( + "--all", + "Include disposable provider sandboxes alongside persistent machines", + ) .option("--json", "Print machine-readable JSON output") .action( - action(async (opts: MachineListCommandOptions) => { - const hosts = await createCliBbSdk(getUrl()).hosts.list(); + action(async (opts: MachineEnumerationOptions) => { + const hosts = selectMachines( + await createCliBbSdk(getUrl()).hosts.list({ includeCreating: true }), + opts.all ? "all" : "persistent", + ); if (outputJson(opts, hosts)) return; if (hosts.length === 0) { console.log("No machines found"); @@ -148,7 +360,10 @@ export function registerMachineCommands( .action( action(async (target: string, opts: MachineListCommandOptions) => { const sdk = createCliBbSdk(getUrl()); - const hostId = resolveMachineId(await sdk.hosts.list(), target); + const hostId = resolveMachineId( + await sdk.hosts.list({ includeCreating: true }), + target, + ); const host = await sdk.hosts.get({ hostId }); if (outputJson(opts, host)) return; console.log(JSON.stringify(host, null, 2)); @@ -179,7 +394,10 @@ export function registerMachineCommands( opts: MachineListCommandOptions, ) => { const sdk = createCliBbSdk(getUrl()); - const hostId = resolveMachineId(await sdk.hosts.list(), target); + const hostId = resolveMachineId( + await sdk.hosts.list({ includeCreating: true }), + target, + ); const host = await sdk.hosts.update({ hostId, name }); if (outputJson(opts, host)) return; console.log(`Machine ${host.id} renamed to ${host.name}`); @@ -195,7 +413,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({ includeCreating: true }); + const hostId = resolveMachineId(hosts, target); if ( !opts.yes && !(await confirmDestructiveAction(`Remove machine ${hostId}?`)) @@ -214,13 +433,77 @@ export function registerMachineCommands( .action( action(async (target: string, opts: MachineListCommandOptions) => { const sdk = createCliBbSdk(getUrl()); - const hostId = resolveMachineId(await sdk.hosts.list(), target); + const hostId = resolveMachineId( + await sdk.hosts.list({ includeCreating: true }), + target, + ); const result = await sdk.hosts.retryUpdate({ hostId }); if (outputJson(opts, result)) return; console.log(`Machine ${hostId} update retry requested`); }), ); + 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({ includeCreating: true }), + 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({ includeCreating: true }), + 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({ includeCreating: true }), + 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"); @@ -231,7 +514,10 @@ export function registerMachineCommands( .action( action(async (target: string, opts: MachineListCommandOptions) => { const sdk = createCliBbSdk(getUrl()); - const hostId = resolveMachineId(await sdk.hosts.list(), target); + const hostId = resolveMachineId( + await sdk.hosts.list({ includeCreating: true }), + target, + ); const result = await sdk.hosts.providerCliStatus({ hostId }); if (outputJson(opts, result)) return; console.log(JSON.stringify(result, null, 2)); @@ -253,7 +539,10 @@ export function registerMachineCommands( throw new Error("--action must be install or update."); } const sdk = createCliBbSdk(getUrl()); - const hostId = resolveMachineId(await sdk.hosts.list(), target); + const hostId = resolveMachineId( + await sdk.hosts.list({ includeCreating: true }), + target, + ); const events = await sdk.hosts.installProviderCli({ hostId, provider: parseProviderCliKey(provider), @@ -271,20 +560,24 @@ function printMachineTable(hosts: Host[]): void { const rows = hosts.map((host) => [ host.name, host.id, + host.type, 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(4, ...rows.map((row) => row[2].length)), + Math.max(6, ...rows.map((row) => row[3].length)), + Math.max(8, ...rows.map((row) => row[4].length)), + Math.max(9, ...rows.map((row) => row[5].length)), ]; console.log(""); console.log( renderBorderlessTable( { - head: ["Name", "ID", "Status", "Last seen"], + head: ["Name", "ID", "Type", "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/skill.ts b/apps/cli/src/commands/skill.ts index e302998eb9..9e4aaae05c 100644 --- a/apps/cli/src/commands/skill.ts +++ b/apps/cli/src/commands/skill.ts @@ -5,7 +5,7 @@ import type { RegistryRanking, RegistrySkill } from "@bb/server-contract"; import type { SkillsRegistryArea } from "@bb/sdk"; import { action } from "../action.js"; import { createCliBbSdk } from "../client.js"; -import { resolveMachineId } from "./machine.js"; +import { resolveMachineId, selectMachines } from "./machine.js"; import type { ContextSnapshot } from "../context-env.js"; import { renderBorderlessTable } from "../table.js"; import { @@ -402,7 +402,7 @@ export function registerSkillCommands( ) .option( "--machine ", - "Machine to report on (repeatable, defaults to every machine)", + "Machine to report on (repeatable, defaults to every persistent machine)", collectMachineTarget, [], ) @@ -442,7 +442,7 @@ export function registerSkillCommands( ) .option( "--machine ", - "Machine to install onto (repeatable, defaults to every connected machine)", + "Machine to install onto (repeatable, defaults to every connected persistent machine)", collectMachineTarget, [], ) @@ -454,7 +454,7 @@ export function registerSkillCommands( const hostIds = options.machine.length > 0 ? options.machine.map((target) => resolveMachineId(hosts, target)) - : hosts + : selectMachines(hosts, "persistent") .filter((host) => host.status === "connected") .map((host) => host.id); if (hostIds.length === 0) { 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/cli/src/commands/updates.ts b/apps/cli/src/commands/updates.ts index 2ac96bfc33..b9aa7088bb 100644 --- a/apps/cli/src/commands/updates.ts +++ b/apps/cli/src/commands/updates.ts @@ -9,7 +9,7 @@ import { action } from "../action.js"; import { createCliBbSdk } from "../client.js"; import { renderBorderlessTable } from "../table.js"; import { outputJson } from "./helpers.js"; -import { resolveMachineId } from "./machine.js"; +import { resolveMachineId, selectMachines } from "./machine.js"; type ProviderCliKey = string; type ProviderCliStatus = HostProviderCliStatusResponse[string]; @@ -168,7 +168,7 @@ export function registerUpdatesCommands( ]); const selectedHosts = opts.machine === undefined - ? hosts + ? selectMachines(hosts, "persistent") : hosts.filter( (host) => host.id === resolveMachineId(hosts, opts.machine!), ); @@ -214,7 +214,7 @@ export function registerUpdatesCommands( const hosts = await sdk.hosts.list(); const selectedHosts = opts.machine === undefined - ? hosts + ? selectMachines(hosts, "persistent") : hosts.filter( (host) => host.id === resolveMachineId(hosts, opts.machine!), ); diff --git a/apps/demo-server/src/demo-world.ts b/apps/demo-server/src/demo-world.ts index 0d2c5484e0..a6214007f2 100644 --- a/apps/demo-server/src/demo-world.ts +++ b/apps/demo-server/src/demo-world.ts @@ -55,6 +55,20 @@ 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", + description: "Connect machines directly to this server URL.", + pluginId: null, + availability: null, + }, + ], + }, 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/mobile/src/ui/icon-map.ts b/apps/mobile/src/ui/icon-map.ts index 41c398b1e8..5591162ec5 100644 --- a/apps/mobile/src/ui/icon-map.ts +++ b/apps/mobile/src/ui/icon-map.ts @@ -75,6 +75,7 @@ import { FolderIcon, FolderRemoveIcon, FolderSyncIcon, + FolderUnknownIcon, GitBranchIcon, GitForkIcon, GitMergeIcon, @@ -370,6 +371,7 @@ const ICON_MAP = { FolderMinus: FolderRemoveIcon, FolderPlus: FolderAddIcon, FolderSync: FolderSyncIcon, + FolderUnknown: FolderUnknownIcon, Fork: GitForkIcon, GitBranch: GitBranchIcon, GitMerge: GitMergeIcon, diff --git a/apps/mobile/src/ui/sf-symbol-map.ts b/apps/mobile/src/ui/sf-symbol-map.ts index 81331c6d47..75b5208392 100644 --- a/apps/mobile/src/ui/sf-symbol-map.ts +++ b/apps/mobile/src/ui/sf-symbol-map.ts @@ -94,6 +94,7 @@ export const SF_SYMBOL_MAP = { FolderMinus: "folder.badge.minus", FolderPlus: "folder.badge.plus", FolderSync: "folder.badge.gearshape", + FolderUnknown: "folder.badge.questionmark", Fork: "arrow.triangle.branch", GitBranch: "arrow.triangle.branch", GitMerge: "arrow.triangle.merge", 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..a6720078ad 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, @@ -69,7 +70,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 +107,23 @@ export function registerInternalSessionRoutes( } const payload = parsed.data; + const host = getHost(deps.db, daemon.hostId); + if (host?.phase === "suspending" || host?.phase === "suspended") { + 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 +132,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..9f405cbe69 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,8 +414,10 @@ export function registerSystemRoutes( : { hostId: query.hostId }), }); return { + machineProviderId: null, id: record.provider.id, displayName: record.provider.displayName, + description: record.provider.description, icon: record.provider.icon, logoUrl: record.icon === undefined @@ -404,7 +436,85 @@ export function registerSystemRoutes( }; }), ) - ).filter((provider) => provider !== null), + ) + .filter((provider) => provider !== null) + .concat( + query.hostId !== undefined + ? [] + : ( + await Promise.all( + listEnvironmentCompositions().map( + async ({ pluginId, composition, icon }) => { + 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, + description: composition.description, + icon: composition.icon ?? "FolderUnknown", + logoUrl: + icon === undefined + ? null + : `/api/v1/system/providers/${encodeURIComponent("environment:" + composition.id)}/logo?h=${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, + })), + ), }); }); @@ -415,8 +525,14 @@ export function registerSystemRoutes( get(routes.providerLogo, async (context) => { const providerId = context.req.param("id"); const registration = providerId.startsWith("environment:") - ? getEnvironmentProvider(providerId.slice("environment:".length)) - : deps.providerRegistry.get(providerId); + ? (getEnvironmentProvider(providerId.slice("environment:".length)) ?? + listEnvironmentCompositions().find( + (record) => + record.composition.id === providerId.slice("environment:".length), + )) + : 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..1736e0c07d 100644 --- a/apps/server/src/services/environments/environment-engine.ts +++ b/apps/server/src/services/environments/environment-engine.ts @@ -1,3 +1,4 @@ +import { withHostCleanup } from "../hosts/cleanup-context.js"; import { findHostDataDir } from "../lib/entity-lookup.js"; import { updateThread } from "@bb/db"; import { @@ -115,7 +116,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; @@ -642,71 +643,73 @@ async function runRemove( throw new Error( `Environment provider "${row.environmentProviderId}" is unavailable or belongs to another plugin`, ); - try { - if (row.providerOwnsPath && row.hostId !== null && row.path !== null) { - await runEnvironmentHook(deps, { - id: `environment:${environmentId}:${row.environmentProviderInstanceKey}:teardown`, - hostId: row.hostId, - path: row.path, - kind: "teardown", - resumeOnly, - report: { - step: () => undefined, - log: (text) => - deps.logger.warn( - { environmentId, text }, - "Environment teardown hook", - ), - }, - signal, - }); - } - const invocation = await invokeEnvironmentProvider( - record, - "environment remove", - () => - record.provider.remove({ - environment: - row.ownerThreadId !== null ? null : toEnvironmentResponse(row), + await withHostCleanup(deps, row.hostId, async () => { + try { + if (row.providerOwnsPath && row.hostId !== null && row.path !== null) { + await runEnvironmentHook(deps, { + id: `environment:${environmentId}:${row.environmentProviderInstanceKey}:teardown`, hostId: row.hostId, path: row.path, - pathKey: row.environmentProviderInstanceKey ?? row.id, - resource: row.resource, - attempt, - report: emptyReporter(), + kind: "teardown", + resumeOnly, + report: { + step: () => undefined, + log: (text) => + deps.logger.warn( + { environmentId, text }, + "Environment teardown hook", + ), + }, signal, - }), - ); - if (!invocation.ok) throw new Error(invocation.error); - if (invocation.value === null) - throw new Error("The environment provider became unavailable."); - const result = removeResultSchema.parse(invocation.value); - if (result.status === "failed") { + }); + } + const invocation = await invokeEnvironmentProvider( + record, + "environment remove", + () => + record.provider.remove({ + environment: + row.ownerThreadId !== null ? null : toEnvironmentResponse(row), + hostId: row.hostId, + path: row.path, + pathKey: row.environmentProviderInstanceKey ?? row.id, + resource: row.resource, + attempt, + report: emptyReporter(), + signal, + }), + ); + if (!invocation.ok) throw new Error(invocation.error); + if (invocation.value === null) + throw new Error("The environment provider became unavailable."); + const result = removeResultSchema.parse(invocation.value); + if (result.status === "failed") { + writeEnvironment(deps, environmentId, { + teardownStatus: "failed", + teardownMessage: result.message, + retireAt: Date.now() + REMOVE_RETRY_MS, + }); + return; + } + writeEnvironment(deps, environmentId, { + teardownStatus: "removed", + teardownMessage: null, + claimPath: null, + resource: null, + retireAt: null, + }); + applyLoggedEnvironmentLifecycleEvent(deps, { + environmentId, + event: { type: "destroy.recorded" }, + }); + } catch (error) { writeEnvironment(deps, environmentId, { teardownStatus: "failed", - teardownMessage: result.message, + teardownMessage: message(error), retireAt: Date.now() + REMOVE_RETRY_MS, }); - return; } - writeEnvironment(deps, environmentId, { - teardownStatus: "removed", - teardownMessage: null, - claimPath: null, - resource: null, - retireAt: null, - }); - applyLoggedEnvironmentLifecycleEvent(deps, { - environmentId, - event: { type: "destroy.recorded" }, - }); - } catch (error) { - writeEnvironment(deps, environmentId, { - teardownStatus: "failed", - teardownMessage: message(error), - retireAt: Date.now() + REMOVE_RETRY_MS, - }); - } + }); } async function removeEnvironment( @@ -918,7 +921,7 @@ interface AdvanceEnvironmentProvisioningArgs { threadId?: string; creation?: { record: PluginEnvironmentProviderRecord; - context: ProviderOperationContext; + context?: ProviderOperationContext; }; environmentId: string | null | undefined; request?: EnvironmentProvisionRequest | null; @@ -1577,17 +1580,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/cleanup-context.ts b/apps/server/src/services/hosts/cleanup-context.ts new file mode 100644 index 0000000000..68ec76d093 --- /dev/null +++ b/apps/server/src/services/hosts/cleanup-context.ts @@ -0,0 +1,20 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { DbConnection } from "@bb/db"; + +const cleanup = new AsyncLocalStorage<{ db: DbConnection; hostId: string }>(); + +export function withHostCleanup( + deps: { db: DbConnection }, + hostId: string | null, + run: () => Promise, +): Promise { + return hostId === null ? run() : cleanup.run({ db: deps.db, hostId }, run); +} + +export function isHostCleanupAllowed( + deps: { db: DbConnection }, + hostId: string, +): boolean { + const context = cleanup.getStore(); + return context?.db === deps.db && context.hostId === hostId; +} 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..972f59b1ae 100644 --- a/apps/server/src/services/hosts/online-rpc.ts +++ b/apps/server/src/services/hosts/online-rpc.ts @@ -1,3 +1,6 @@ +import { isHostCleanupAllowed } from "./cleanup-context.js"; +import { assertMachineLifecycleAdmission } from "../machines/lifecycle.js"; +import { getHost, getThread } from "@bb/db"; import { randomUUID } from "node:crypto"; import { type HostDaemonOnlineRpcResponseMessage, @@ -14,6 +17,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 +43,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 +75,125 @@ 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 isCleanupRpc( deps: WorkSessionDeps, args: CallHostOnlineRpcArgs, - options: { retryOnTransportFailure: boolean }, -): Promise { +): boolean { + return ( + getHost(deps.db, args.hostId)?.phase === "removing" && + isHostCleanupAllowed(deps, args.hostId) && + (args.command.type === "plugin.host.call" || + (args.command.type === "environment.hook.run" && + args.command.kind === "teardown")) + ); +} + +function assertHostActiveForRead( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, +): void { + if ( + isCleanupRpc(deps, args) || + 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 { + if (isCleanupRpc(deps, args)) return; 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 +213,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..5e71fdae21 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,34 @@ 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.phase === "resuming" || + 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..771d796dac --- /dev/null +++ b/apps/server/src/services/machines/bootstrap.test.ts @@ -0,0 +1,157 @@ +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, + })); + 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, + }; + }); + 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 }; + }); + 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 clears pending enrollment", 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(); + expect(h.enrollments.clearPending).toHaveBeenCalledWith("key"); + }); + + 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("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(); + }); +}); + +it("cancels pending preparation without executing or waiting for enrollment", async () => { + const h = harness(); + const controller = new AbortController(); + vi.mocked(h.enrollments.prepare).mockImplementation( + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }), + ); + const pending = h.api.bootstrap({ + key: "pending", + executor: { exec: h.exec }, + report: h.report, + signal: controller.signal, + }); + controller.abort(new Error("cancelled")); + await expect(pending).rejects.toThrow("cancelled"); + expect(h.exec).not.toHaveBeenCalled(); + expect(h.enrollments.waitForConnection).not.toHaveBeenCalled(); + expect(h.enrollments.clearPending).toHaveBeenCalledWith("pending"); +}); diff --git a/apps/server/src/services/machines/bootstrap.ts b/apps/server/src/services/machines/bootstrap.ts new file mode 100644 index 0000000000..629360bca3 --- /dev/null +++ b/apps/server/src/services/machines/bootstrap.ts @@ -0,0 +1,122 @@ +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"); + try { + const enrollment = await enrollments.prepare({ + key: request.key, + signal: request.signal, + }); + request.signal.throwIfAborted(); + request.report.step( + enrollment.state === "enrolled" + ? "Starting enrolled machine" + : "Bootstrapping machine", + ); + 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(); + request.report.step("Waiting for machine connection"); + await enrollments.waitForConnection({ + enrollmentId: enrollment.id, + timeoutMs: 120_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..d167d16398 --- /dev/null +++ b/apps/server/src/services/machines/enrollments.test.ts @@ -0,0 +1,256 @@ +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 (_request: { + key: string; + hostId: string; + signal: AbortSignal; + }) => ({ + id: "grant", + serverUrl: "https://server.example", + }), + ), + }; + 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, + service, + 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({ signal: new AbortController().signal, key: "create" }), + h.api.prepare({ signal: new AbortController().signal, key: "create" }), + ]); + expect(parallel.hostId).toBe(first.hostId); + expect(h.serverAccess.resolve).toHaveBeenCalledTimes(2); + const restarted = await h + .create() + .forOwner("plugin-a") + .prepare({ signal: new AbortController().signal, 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 removed identities", async () => { + const h = await harness(); + h.seed("removed"); + const prepared = await h.api.prepare({ + signal: new AbortController().signal, + 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({ signal: new AbortController().signal, 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({ + signal: new AbortController().signal, + 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({ signal: new AbortController().signal, 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({ + signal: new AbortController().signal, + 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({ + signal: new AbortController().signal, + 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({ signal: new AbortController().signal, key: "create" }), + ).rejects.toThrow("temporarily unavailable"); + const retry = await h.api.prepare({ + signal: new AbortController().signal, + key: "create", + }); + expect(retry.hostId).toBe("host_create"); + }); +}); + +it("propagates cancellation to acquisition and never publishes a late enrollment command", async () => { + const h = await harness(); + h.seed("cancel"); + const controller = new AbortController(); + let finish: () => void = () => {}; + let acquisitionSignal: AbortSignal | undefined; + h.serverAccess.resolve.mockImplementation(async ({ signal }) => { + acquisitionSignal = signal; + await new Promise((resolve) => { + finish = resolve; + }); + return { id: "late-grant", serverUrl: "https://server.example" }; + }); + const pending = h.api.prepare({ key: "cancel", signal: controller.signal }); + await vi.waitFor(() => expect(acquisitionSignal).toBeDefined()); + controller.abort(new Error("cancelled")); + expect(acquisitionSignal?.aborted).toBe(true); + finish(); + await expect(pending).rejects.toThrow("cancelled"); + expect( + await h.service.pendingBootstrapForHost({ + hostId: "host_cancel", + owner: "plugin-a", + }), + ).toBeNull(); +}); diff --git a/apps/server/src/services/machines/enrollments.ts b/apps/server/src/services/machines/enrollments.ts new file mode 100644 index 0000000000..027fd5e41e --- /dev/null +++ b/apps/server/src/services/machines/enrollments.ts @@ -0,0 +1,276 @@ +import { isHostCleanupAllowed } from "../hosts/cleanup-context.js"; +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 } 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; + signal: AbortSignal; + }): 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; + 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) { + request.signal.throwIfAborted(); + 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 () => { + request.signal.throwIfAborted(); + 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" && !isHostCleanupAllowed(deps, host.id)) + ) + throw new Error("Machine enrollment was cancelled"); + if ( + getMachineProvider(host.machineProviderId ?? "")?.pluginId !== owner + ) + throw new Error("Machine creation belongs to a different plugin"); + 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, + signal: AbortSignal.any([ + request.signal, + AbortSignal.timeout(60_000), + ]), + }); + request.signal.throwIfAborted(); + 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, + }, + }; + if (request.signal.aborted) { + await deps.machineAuth.revokeHostEnrollKeys({ hostId: host.id }); + request.signal.throwIfAborted(); + } + 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" && !isHostCleanupAllowed(deps, host.id)) + ) + 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..2dca641cfa --- /dev/null +++ b/apps/server/src/services/machines/lifecycle.ts @@ -0,0 +1,251 @@ +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" || + host?.phase === "resuming" + ); +} + +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..83c1479392 --- /dev/null +++ b/apps/server/src/services/machines/machine-services.ts @@ -0,0 +1,28 @@ +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), + }, + 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..413dfe0d68 --- /dev/null +++ b/apps/server/src/services/machines/manual-provider.ts @@ -0,0 +1,111 @@ +import { validatePluginMachineProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import type { MachineEnrollments } from "./enrollments.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( + enrollments: MachineEnrollments, +): 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); + context.report.step("Preparing machine enrollment"); + let hostId: string; + try { + const enrollment = await enrollments.prepare({ + key: context.key, + signal: context.signal, + }); + context.report.step("Run the enrollment command shown below"); + ({ hostId } = await enrollments.waitForConnection({ + enrollmentId: enrollment.id, + timeoutMs: 15 * 60_000, + signal: context.signal, + })); + } catch (error) { + enrollments.clearPending(context.key); + throw error; + } + 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( + 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..45e8b793f7 --- /dev/null +++ b/apps/server/src/services/machines/provider-orchestration.ts @@ -0,0 +1,1455 @@ +import { withHostCleanup } from "../hosts/cleanup-context.js"; +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, + machineHasStartingThreadLaunch, + machineHasProvisioningEnvironment, + machineHasLiveThreads, + updateHost, +} from "@bb/db"; +import { jsonValueSchema, type Host, type JsonValue } from "@bb/domain"; +import type { + PluginMachineProviderCreateResult, + PluginMachineProviderResource, + 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): value is PluginMachineProviderResource => value !== null, + "Allocated machine resource must not be null", + ) + .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), + 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 { + 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: PluginMachineProviderResource; + 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, + 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; + } + 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; + lifetime: "thread" | "standalone"; + 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.lifetime === "thread" && 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, + lifetime: "standalone", + 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 () => { + 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`, + ); + } + } + updateHost(deps.db, deps.hub, hostId, { suspendedAt: Date.now() }); + 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, + }); + }, + }), + ); + 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, [ + "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) && + !machineHasStartingThreadLaunch(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" && + row.phase !== "resuming" + ) { + 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" && + row.phase !== "resuming" + ) { + 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 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 !== "resuming" && + !(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 initialPhase = row.phase; + const resumePhase = preserveRemoval ? "removing" : "resuming"; + 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…", + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + 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); + const ownsResume = lifecycleOwns( + current, + record.provider.id, + operationId, + resumePhase, + ); + updateHost(deps.db, deps.hub, hostId, { + ...(ownsResume && !preserveRemoval + ? { + phase: initialPhase === "suspending" ? "suspending" : "suspended", + } + : {}), + statusMessage: `Machine resume failed: ${errorMessage(error)}`, + suspendRetryAt: Date.now() + 10_000, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + 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, + 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.phase === "resuming") { + await resumeMachine(deps, hostId); + return; + } + if ( + row.phase !== "removing" && + 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(); + const removing = operations(removeOperations, deps.db).get(hostId); + if (removing !== undefined) { + await removing.done; + 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 withHostCleanup(deps, hostId, () => + 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..5f3657b889 --- /dev/null +++ b/apps/server/src/services/machines/server-access.ts @@ -0,0 +1,269 @@ +import { getAppSettings, getHost, hosts } from "@bb/db"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import type { ServerAccessGrant } from "@get-bb/plugin-sdk"; +import type { ServerAccessStatus } from "@bb/server-contract"; +import { decideWithinBox } from "../threads/dispatch-hooks.js"; +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, + }; +} + +function serverAccessConfiguration(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, + }; +} + +export async function serverAccessStatus( + deps: Dependencies, +): Promise { + const configuration = serverAccessConfiguration(deps); + const records = listServerAccessProviders(); + const providers = await Promise.all( + configuration.providers.map(async (provider) => { + if (provider.id === "direct") return { ...provider, availability: null }; + const record = records.find((entry) => entry.provider.id === provider.id); + const result = + record === undefined + ? null + : await decideWithinBox( + () => + invokeServerAccessProvider(record, async () => + record.provider.availability(), + ), + 5_000, + ); + const parsed = result?.ok + ? availabilitySchema.safeParse(result.value) + : null; + const availability: z.infer = parsed?.success + ? parsed.data + : { + status: "unavailable", + message: + "Could not check machine access. Try again or check the provider's configuration.", + }; + return { ...provider, availability }; + }), + ); + return { ...configuration, providers }; +} + +async function resolve( + deps: Dependencies, + args: { + key: string; + hostId: string; + 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 = serverAccessConfiguration(deps); + const providerId = host.serverAccessProviderId ?? status.defaultProviderId; + 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; + let onAbort: (() => void) | undefined; + try { + args.signal.throwIfAborted(); + availability = availabilitySchema.parse( + await Promise.race([ + invokeServerAccessProvider(record, async () => + record.provider.availability(), + ), + new Promise((_resolve, reject) => { + onAbort = () => reject(args.signal.reason); + args.signal.addEventListener("abort", onAbort, { once: true }); + if (args.signal.aborted) onAbort(); + }), + ]), + ); + } catch { + args.signal.throwIfAborted(); + throw new Error("Server access provider is unavailable"); + } finally { + if (onAbort) args.signal.removeEventListener("abort", onAbort); + } + args.signal.throwIfAborted(); + 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..8cbd107ac6 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -1,3 +1,11 @@ +import { + environmentCompositionSchema, + validateServerAccessProviderDeclaration, + 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 +14,7 @@ import { CronExpressionParser } from "cron-parser"; import { deletePluginKvValue, getPluginKvValue, + getHost, listPluginKvKeys, setPluginKvValue, type DbConnection, @@ -42,6 +51,7 @@ import type { PluginMentionItem, PluginMentionSearchContext, PluginMentionTrigger, + PluginMachines, PluginAiServiceDeclaration, PluginAiServices, PluginProviderDeclaration, @@ -92,6 +102,7 @@ import { pluginHookAlreadyRegisteredMessage, storePluginHook, validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, providerAlreadyRegisteredMessage, providerIconRefusalMessage, undeclaredIconProblem, @@ -103,10 +114,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 +262,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 +442,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 +450,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 +550,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 +568,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 +1547,40 @@ 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 problem = + composition.icon === null + ? null + : undeclaredIconProblem( + pluginId, + declaredIconNames, + composition.icon, + ); + if (problem !== null) + throw new Error(providerIconRefusalMessage(composition.id, problem)); + 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 +1603,72 @@ export function createPluginApi(options: { }, }; + const experimental_serverAccess: import("@get-bb/plugin-sdk").PluginServerAccess = + { + register(declaration) { + assertLive(); + validateServerAccessProviderDeclaration(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 +1699,8 @@ export function createPluginApi(options: { events, experimental_hooks, experimental_environments, + experimental_machines, + experimental_serverAccess, status, server, hosts, @@ -1598,7 +1730,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..5ac1b90b4f 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,13 @@ export interface PluginEnvironmentProviderRecord { icon?: { bytes: Uint8Array; contentType: string; hash: string }; } +export interface PluginEnvironmentCompositionRecord { + pluginId: string; + composition: NormalizedPluginEnvironmentComposition; + icon?: { bytes: Uint8Array; contentType: string; hash: string }; +} export interface PluginEnvironmentProviderBridge { + listEnvironmentCompositions?(): PluginEnvironmentCompositionRecord[]; listEnvironmentProviders(): PluginEnvironmentProviderRecord[]; getEnvironmentProvider( id: string, @@ -29,6 +36,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..2bb0518548 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,28 @@ export function createPluginRuntime(context: PluginRuntimeContext) { return registrations; } + function listPluginEnvironmentCompositions() { + return Array.from(loaded).flatMap(([pluginId, plugin]) => + Array.from( + plugin.handle.environmentCompositions.values(), + (composition) => { + const declared = + composition.icon === null + ? null + : parseNamespacedGlyph(composition.icon); + const icon = + declared !== null + ? brandingAssets.get(pluginId)?.icons.get(declared.name) + : readPluginProviderIcon( + plugin.manifest.rootDir, + composition.icon ?? undefined, + ); + return { pluginId, composition, ...(icon == null ? {} : { icon }) }; + }, + ), + ); + } + function listPluginEnvironmentProviders(): PluginEnvironmentProviderRecord[] { const records: PluginEnvironmentProviderRecord[] = []; const seen = new Set(); @@ -671,6 +696,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 +1418,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 +1442,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 +1867,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..89209131c0 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"]; @@ -21,7 +24,9 @@ const STATUS_TIMEOUT_MS = 5_000; export function listInstallableMachineIds( deps: GlobalSkillInstallDeps, ): string[] { - return listHosts(deps.db).map((host) => host.id); + return listHosts(deps.db) + .filter((host) => host.type !== "ephemeral") + .map((host) => host.id); } type InstallGlobalCliSkillsResult = SystemInstallCliSkillsResponse; @@ -161,7 +166,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..3efa520429 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 { and, eq, isNull, isNotNull, inArray } from "drizzle-orm"; +import { sweepMachineLifecycles } from "../machines/provider-orchestration.js"; import { CLOSED_SESSION_ROW_RETENTION_MS, compactDatabase, @@ -41,7 +42,10 @@ import { advanceProjectDeletion, listProjectsPendingDeletion, } from "../projects/project-deletion.js"; -import { hasLiveThreadStartInFlight } from "../threads/thread-lifecycle.js"; +import { + finalizeStoppedThread, + hasLiveThreadStartInFlight, +} from "../threads/thread-lifecycle.js"; import { advanceThreadProvisioning } from "../threads/thread-provisioning.js"; import { runQueuedMessageDispatch, @@ -338,6 +342,14 @@ async function runThreadProvisioningOrphanCleanupSweep( ); } } + const deletedUnattachedThreads = deps.db + .select({ id: threads.id }) + .from(threads) + .where(and(isNotNull(threads.deletedAt), isNull(threads.environmentId))) + .all(); + for (const thread of deletedUnattachedThreads) { + finalizeStoppedThread(deps, { threadId: thread.id }); + } } export async function runThreadLifecycleSweep( @@ -345,6 +357,7 @@ export async function runThreadLifecycleSweep( ): Promise { await runThreadProvisioningOrphanCleanupSweep(deps); await sweepProviderLifecycles(deps); + await sweepMachineLifecycles(deps); } async function runMachineAuthPruneSweep( @@ -481,6 +494,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..f21e434fd8 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, @@ -59,7 +64,7 @@ import { threadForkDescriptorSchema, threadProvisionEnvironmentIntentSchema, } from "./thread-startup-store.js"; -import { getThreadProvisionContext } from "./thread-startup-store.js"; +import { readThreadProvisionContext } from "./thread-startup-store.js"; import { buildThreadStatusChangeMetadata, toThreadResponseFromThread, @@ -74,7 +79,7 @@ import { type SendThreadMessageTransactionPreflight, } from "./thread-send.js"; import type { TurnRequestRetryMarker } from "./thread-events.js"; -import { restoreFailedThreadStartupRequest } from "./thread-provisioning.js"; +import { restoreInterruptedThreadStartupRequest } from "./thread-provisioning.js"; export const pendingThreadStartContextSchema = z.object({ environmentIntent: threadProvisionEnvironmentIntentSchema, @@ -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( @@ -133,7 +138,7 @@ function intendedThreadIntent( threadId: string, ): PendingThreadStartContext["environmentIntent"] | null { return ( - getThreadProvisionContext(deps.db, threadId)?.request.environmentIntent ?? + readThreadProvisionContext(deps.db, threadId)?.request.environmentIntent ?? readPendingThreadStartContext(deps, threadId)?.environmentIntent ?? null ); @@ -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. @@ -303,20 +315,21 @@ async function runDispatchAttempt( targetThread: thread, }); - const failedStartupRequest = - thread.status === "error" && thread.environmentId === null - ? await restoreFailedThreadStartupRequest(deps, thread.id) + const interruptedStartupRequest = + (thread.status === "error" || thread.status === "idle") && + thread.environmentId === null + ? await restoreInterruptedThreadStartupRequest(deps, thread.id) : null; const firstDispatch = - thread.status === "pending" || failedStartupRequest !== null; + thread.status === "pending" || interruptedStartupRequest !== null; const retryStartContext: PendingThreadStartContext | null = - failedStartupRequest === null + interruptedStartupRequest === null ? null : { - environmentIntent: failedStartupRequest.environmentIntent, - fork: failedStartupRequest.fork, + environmentIntent: interruptedStartupRequest.environmentIntent, + fork: interruptedStartupRequest.fork, startedOnBehalfOf: args.startedOnBehalfOf, - titleProvided: failedStartupRequest.titleProvided, + titleProvided: interruptedStartupRequest.titleProvided, }; const claimed = args.source.kind === "drain" ? args.source.claimed : null; const sendNow = args.source.kind === "drain" && args.source.sendNow; @@ -365,6 +378,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 +406,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..fbda4b8249 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, @@ -57,7 +60,7 @@ interface QueuedMessageDispatchRef { type PreparedQueuedMessageDispatchWake = Exclude< QueuedMessageDispatchWake, - { kind: "provisioning-ended" } | { kind: "host-connected" } + { kind: "provisioning-ended" } >; const pendingPluginRechecks = new WeakSet< @@ -92,20 +95,8 @@ function prepareQueuedMessageDispatchWake( }); return []; case "host-connected": { - const prepared: PreparedQueuedMessageDispatchWake[] = []; - for (const threadId of listThreadIdsWithHostOfflineQueueWaits( - deps.db, - wake.hostId, - )) { - const cleared = clearThreadQueueWaitsOfKind(deps, { - threadId, - kind: "host-offline", - }); - if (cleared > 0) { - prepared.push({ kind: "thread-ready", threadId }); - } - } - return prepared; + if (isMachineWaitingForExecution(deps, wake.hostId)) return []; + return [wake]; } default: return [wake]; @@ -116,6 +107,8 @@ function dispatchWakeContext( wake: PreparedQueuedMessageDispatchWake, ): Record { switch (wake.kind) { + case "host-connected": + return { hostId: wake.hostId, wake: wake.kind }; case "workspace-ready": case "thread-ready": case "turn-started": @@ -156,10 +149,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); } @@ -179,6 +212,22 @@ async function executePreparedQueuedMessageDispatch( wake: PreparedQueuedMessageDispatchWake, ): Promise { switch (wake.kind) { + case "host-connected": + for (const threadId of listThreadIdsWithHostOfflineQueueWaits( + deps.db, + wake.hostId, + )) { + for (const row of listQueuedThreadMessagesWaitingOnKind(deps.db, { + kind: "host-offline", + threadId, + })) { + await attemptAutomaticQueuedMessage(deps, row, { + now: Date.now(), + respectRequeuePacing: false, + }); + } + } + return; case "workspace-ready": await runWorkspaceReadyDispatch(deps, wake.threadId); return; diff --git a/apps/server/src/services/threads/queued-messages.ts b/apps/server/src/services/threads/queued-messages.ts index 5fe7bdf964..944f494846 100644 --- a/apps/server/src/services/threads/queued-messages.ts +++ b/apps/server/src/services/threads/queued-messages.ts @@ -5,6 +5,7 @@ import { deleteClaimedQueuedThreadMessageBatchInTransaction, getQueuedThreadMessage, getEnvironment, + getHost, getThread, isOrdinaryTurnEndQueuedMessage, isThreadQueueAutoSendPaused, @@ -117,7 +118,7 @@ interface SendClaimedQueuedMessageForThreadArgs { } export function createAutomaticQueuedMessageGroupEligibility( - deps: Pick, + deps: Pick, args: { now: number; thread: Thread }, ): QueuedThreadMessageGroupEligibility { const activeTurnId = getActiveTurnId(deps, args.thread.id); @@ -140,8 +141,21 @@ export function createAutomaticQueuedMessageGroupEligibility( args.thread.status === "idle" || (args.thread.status === "active" && activeTurnId !== null) ); + case "host-offline": { + const environment = + args.thread.environmentId === null + ? null + : getEnvironment(deps.db, args.thread.environmentId); + const host = + environment === null ? null : getHost(deps.db, environment.hostId); + return ( + host !== null && + host.destroyedAt === null && + host.phase === "active" && + deps.hub.hasDaemonForHost(host.id) + ); + } case "provisioning": - case "host-offline": case "interaction": return false; } @@ -729,7 +743,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..07cacc2e6b 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( @@ -246,7 +283,15 @@ export async function validateProviderSelection( : getProjectSourceByHost(deps.db, args.projectId, host.id); 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.gitRemote && project.gitRemoteUrl === null) { throw new ApiError( @@ -394,12 +439,55 @@ export async function resolveThreadEnvironmentPlacement( ): Promise { if (args.requestedEnvironment.type === "provider") { const requested = args.requestedEnvironment; - return providerPlacement( - deps, - args.projectId, - requested.environmentProviderId, - requested, - ); + 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, environmentProviderId, { + machine, + inputs: requested.inputs, + }); } const resolvedEnvironment = resolveStableThreadRequestEnvironment(deps, { ...(args.allowUnmanagedPersonalProjectReuseEnvironmentId !== undefined @@ -489,7 +577,7 @@ export async function resolveProviderOperationContext( { type: "provider" } >, record: NonNullable>, -) { +): Promise { const project = requirePublicProject(deps.db, thread.projectId); let selection; try { @@ -506,7 +594,29 @@ 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, + lifetime: "thread", + 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 +624,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 +657,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 +716,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 +856,106 @@ 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, + lifetime: "thread", + 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 +974,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 +1064,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 +1155,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 +1181,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 +1199,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..b77cbf93cf 100644 --- a/apps/server/src/services/threads/thread-lifecycle.ts +++ b/apps/server/src/services/threads/thread-lifecycle.ts @@ -99,6 +99,7 @@ import { getThreadProvisionContext, } from "./thread-startup-store.js"; import { cancelEnvironmentProviderCreation } from "./thread-environment-providers.js"; +import { scheduleThreadProvisioningAdvance } from "./thread-provisioning.js"; import { isPreStartThreadStatus } from "./thread-status.js"; import { settleDanglingBackgroundTasksForStoppedThreadInTransaction } from "./background-task-reconciliation.js"; @@ -1132,6 +1133,9 @@ async function requestThreadStartOnce( args.thread.id, "thread.start.title-sync", ); + if (getThreadProvisionContext(deps.db, args.thread.id) !== null) { + scheduleThreadProvisioningAdvance(deps, args.thread.id); + } }); } } @@ -1350,6 +1354,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 +1373,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 +1427,7 @@ async function runAwaitedThreadStopCommand( deps: RequestThreadStopForCurrentStateDeps, args: { command: ThreadStopCommand; + requireStopped?: boolean; hostId: string; threadId: string; }, @@ -1438,6 +1445,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-provisioning.ts b/apps/server/src/services/threads/thread-provisioning.ts index 953c14a3a4..f5f8a3dba2 100644 --- a/apps/server/src/services/threads/thread-provisioning.ts +++ b/apps/server/src/services/threads/thread-provisioning.ts @@ -1,3 +1,5 @@ +import { getNonDestroyedHostByLaunchKey } from "@bb/db"; +import { sweepProviderMachine } from "../machines/provider-orchestration.js"; import { readThreadProvisioningStage } from "./thread-provisioning-context.js"; import { cancelProviderEnvironmentCreation } from "../environments/environment-engine.js"; import { getPreparingEnvironment } from "@bb/db"; @@ -368,14 +370,17 @@ async function advanceThreadProvisioningOnce( args: AdvanceThreadProvisioningArgs, ): Promise { const thread = getThread(deps.db, args.threadId); - if (!thread || thread.deletedAt !== null) { + if ( + !thread || + thread.deletedAt !== null || + hasLiveThreadStartInFlight(thread.id) + ) { return; } if (readThreadProvisioningStage(deps.db, thread.id) === "inactive") { clearThreadProvisionSchedule(thread.id); return; } - if (hasLiveThreadStartInFlight(thread.id)) return; let context = loadActiveThreadProvisionContext(deps, thread.id); if (!context) { failThreadProvisioning(deps, { @@ -451,7 +456,7 @@ export function scheduleThreadProvisioningAdvance( }); } -export async function restoreFailedThreadStartupRequest( +export async function restoreInterruptedThreadStartupRequest( deps: ThreadProvisioningDeps, threadId: string, ): Promise { @@ -461,5 +466,9 @@ export async function restoreFailedThreadStartupRequest( if (provisioning !== null) { await cancelProviderEnvironmentCreation(deps, threadId); } + const machine = getNonDestroyedHostByLaunchKey(deps.db, threadId); + if (machine?.phase === "removing") { + await sweepProviderMachine(deps, machine.id); + } return context.request; } 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/environment-provider.ts b/apps/server/test/helpers/environment-provider.ts index da15f10f5d..d53661722c 100644 --- a/apps/server/test/helpers/environment-provider.ts +++ b/apps/server/test/helpers/environment-provider.ts @@ -71,6 +71,8 @@ export function defaultEnvironmentProviderRecords(): PluginEnvironmentProviderRe const checkout = validatePluginEnvironmentProviderDeclaration({ id: DEFAULT_ENVIRONMENT_PROVIDER_ID.projectCheckout, displayName: "Checkout", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { projectCheckout: true }, inputs: checkoutProviderInputsSchema, ...providerOperations((context) => { @@ -119,6 +121,8 @@ export function installFakeEnvironmentProvider( const normalized = validatePluginEnvironmentProviderDeclaration({ id: args.id, displayName: args.displayName, + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: args.requires, ...(args.inputs === undefined ? {} : { inputs: args.inputs }), ...(args.validate === undefined ? {} : { validate: args.validate }), 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..c6a70ae774 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,139 @@ 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("accepts a session open while the machine is resuming", async () => { + const server = await startTestServer(); + try { + const hostId = "host-resuming"; + const hostKey = createTestDaemonHostKey({ hostId }); + upsertHost(server.db, server.hub, { id: hostId, name: "Resuming Host" }); + updateHost(server.db, server.hub, hostId, { phase: "resuming" }); + + 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-resuming", + hostName: "Resuming Host", + hasMachineCredential: true, + platform: "linux", + dataDir: `/tmp/${hostId}`, + localApiPort: 38_888, + protocolVersion: HOST_DAEMON_PROTOCOL_VERSION, + activeThreads: [], + loadedEnvironments: [], + }), + }); + + expect(response.status).toBe(201); + } 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 +145,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 +184,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 +211,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 +247,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 +265,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 +283,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/environments/provider-orchestration.test.ts b/apps/server/test/services/environments/provider-orchestration.test.ts index 01ff8ba01f..2d369aed31 100644 --- a/apps/server/test/services/environments/provider-orchestration.test.ts +++ b/apps/server/test/services/environments/provider-orchestration.test.ts @@ -87,6 +87,8 @@ function setup( provider: validatePluginEnvironmentProviderDeclaration({ id: "test-provider", displayName: "Test", + description: "Prepare a workspace for this thread.", + icon: "Folder", create: async () => ({ status: "created", path: `/tmp/${thread.id}`, @@ -412,6 +414,23 @@ describe("core environment orchestration", () => { expect(fixture.row().claimPath).toBeNull(); })); + it("finalizes a workspace path already claimed by the same launch", async () => + withTestHarness(async (harness) => { + const fixture = setup(harness, { + create: async (context) => { + expect(await context.experimental_claimPath("/tmp/project")).toBe( + true, + ); + context.report.log("Checkout prepared"); + return { status: "created", path: "/tmp/project", ownsPath: false }; + }, + }); + fixture.ask(); + await fixture.settled(); + expect(fixture.row().path).toBe("/tmp/project"); + expect(["provisioning", "ready"]).toContain(fixture.row().status); + })); + it.each([true, false])( "runs teardown only for ownsPath=%s, in provider order", async (ownsPath) => @@ -749,7 +768,10 @@ describe("core environment orchestration", () => { expect( await provider.validate({ ...fixture.context, - projectCheckout: { path: "/tmp/project" }, + projectCheckout: { + experimental_ownsPath: false, + path: "/tmp/project", + }, inputs: { branch: { kind: "existing", name: "release" } }, }), ).toEqual({ @@ -820,7 +842,7 @@ describe("core environment orchestration", () => { }); const context = { ...fixture.context, - projectCheckout: { path: "/tmp/project" }, + projectCheckout: { experimental_ownsPath: false, path: "/tmp/project" }, inputs: { branch: { kind: "existing", name: "release" } }, }; try { diff --git a/apps/server/test/services/machines/lifecycle-recovery.test.ts b/apps/server/test/services/machines/lifecycle-recovery.test.ts new file mode 100644 index 0000000000..bda24a855a --- /dev/null +++ b/apps/server/test/services/machines/lifecycle-recovery.test.ts @@ -0,0 +1,397 @@ +import { z } from "zod"; +import { afterEach, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { + createEnvironment, + environments, + getEnvironment, + getHost, + hosts, + listThreadIdsWithHostOfflineQueueWaits, + updateHost, +} from "@bb/db"; +import type { PluginMachineProviderDeclaration } from "@get-bb/plugin-sdk"; +import { + validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk/internal/host-policy"; +import { + requestMachineRemoval, + requestMachineSuspension, + requestMachineResume, + sweepProviderMachine, +} from "../../../src/services/machines/provider-orchestration.js"; +import { setPluginMachineProviderBridge } from "../../../src/services/plugins/plugin-machine-provider-registry.js"; +import { setPluginEnvironmentProviderBridge } from "../../../src/services/plugins/plugin-environment-provider-registry.js"; +import { callPluginHostRpc } from "../../../src/services/plugins/plugin-host-rpc.js"; +import { callHostOnlineRpcForWork } from "../../../src/services/hosts/online-rpc.js"; +import { createMachineEnrollmentService } from "../../../src/services/machines/enrollments.js"; +import { + registerTestHostRpcCapture, + reportQueuedCommandSuccess, + waitForQueuedCommand, +} from "../../helpers/commands.js"; +import { readJson } from "../../helpers/json.js"; +import { + seedHostSession, + seedProjectWithSource, + seedThread, + seedQueuedMessage, + seedSession, +} 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); + setPluginEnvironmentProviderBridge(undefined); +}); + +it("can resume after a snapshot fails following daemon shutdown", async () => + withTestHarness(async (harness) => { + const target = seedHostSession(harness.deps, { + id: "review-failed-suspend", + }); + const resume = vi.fn(async () => { + seedSession(harness.deps, target.host.id); + return { resource: { id: "owned" } }; + }); + installMachineProvider({ + suspend: async () => { + throw new Error("snapshot API unavailable"); + }, + resume, + }); + updateHost(harness.db, harness.hub, target.host.id, { + machineProviderId: "test-machine", + resource: { id: "owned" }, + }); + registerTestHostRpcCapture(harness, { + hostId: target.host.id, + sessionId: target.session.id, + }); + const shutdown = vi.spyOn(harness.hub, "requestDaemonShutdown"); + await expect( + requestMachineSuspension(harness.deps, target.host.id), + ).rejects.toThrow("snapshot API unavailable"); + expect(shutdown).toHaveBeenCalledOnce(); + expect(getHost(harness.db, target.host.id)?.phase).toBe("suspended"); + expect(harness.hub.hasDaemonForHost(target.host.id)).toBe(false); + await requestMachineResume(harness.deps, target.host.id); + expect(resume).toHaveBeenCalledOnce(); + expect(getHost(harness.db, target.host.id)?.phase).toBe("active"); + expect(harness.hub.hasDaemonForHost(target.host.id)).toBe(true); + })); + +it("recovers a persisted resuming machine without queued work", async () => + withTestHarness(async (harness) => { + const target = seedHostSession(harness.deps, { + id: "interrupted-resume", + }); + const resume = vi.fn(async () => ({ resource: { id: "restored" } })); + installMachineProvider({ + suspend: async () => ({ resource: { id: "owned" } }), + resume, + }); + updateHost(harness.db, harness.hub, target.host.id, { + machineProviderId: "test-machine", + machineOperationId: "test-machine-plugin:interrupted", + phase: "resuming", + resource: { id: "owned" }, + suspendedAt: Date.now(), + }); + harness.hub.unregisterDaemon(target.session.id); + + await sweepProviderMachine(harness.deps, target.host.id); + + expect(resume).toHaveBeenCalledOnce(); + expect(getHost(harness.db, target.host.id)).toMatchObject({ + machineOperationId: expect.stringMatching(/^test-machine-plugin:/u), + phase: "active", + resource: { id: "restored" }, + suspendedAt: null, + }); + })); + +it.each(["active", "suspended"] as const)( + "removes environments on a %s persistent machine without admitting new work", + async (phase) => + withTestHarness(async (harness) => { + const target = seedHostSession(harness.deps, { id: "review-removing" }); + registerTestHostRpcCapture(harness, { + hostId: target.host.id, + sessionId: target.session.id, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: target.host.id, + }); + const machineRemove = vi.fn(async () => ({ status: "removed" as const })); + const enrollment = createMachineEnrollmentService({ + db: harness.db, + machineAuth: harness.deps.machineAuth, + isConnected: (hostId) => harness.hub.hasDaemonForHost(hostId), + serverAccess: { + resolve: async () => ({ + id: "direct", + serverUrl: "https://example.test", + }), + }, + }).forOwner("test-machine-plugin"); + const resume = vi.fn(async () => { + expect( + await enrollment.prepare({ + signal: new AbortController().signal, + key: "cleanup-machine", + }), + ).toMatchObject({ state: "enrolled" }); + seedSession(harness.deps, target.host.id); + await enrollment.waitForConnection({ + enrollmentId: target.host.id, + timeoutMs: 100, + signal: new AbortController().signal, + }); + return { resource: { id: "owned" } }; + }); + installMachineProvider({ + remove: machineRemove, + resume, + suspend: async () => ({ resource: { id: "owned" } }), + }); + updateHost(harness.db, harness.hub, target.host.id, { + machineProviderId: "test-machine", + resource: { id: "owned" }, + launchKey: "cleanup-machine", + phase, + suspendedAt: phase === "suspended" ? Date.now() : null, + }); + harness.db + .update(hosts) + .set({ lastSeenAt: Date.now() }) + .where(eq(hosts.id, target.host.id)) + .run(); + if (phase === "suspended") + harness.hub.unregisterDaemon(target.session.id); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: target.host.id, + path: "/tmp/review-worktree", + providerOwnsPath: true, + status: "ready", + environmentProvider: null, + }); + harness.db + .update(environments) + .set({ + environmentProviderId: "review-worktree", + environmentProviderPluginId: "review-worktree-plugin", + environmentProviderInstanceKey: "review-worktree", + }) + .where(eq(environments.id, environment.id)) + .run(); + const record = { + pluginId: "review-worktree-plugin", + provider: validatePluginEnvironmentProviderDeclaration({ + id: "review-worktree", + displayName: "Review worktree", + description: "Prepare a workspace for this thread.", + icon: "Folder", + create: async () => ({ + status: "created", + path: "/tmp/review-worktree", + ownsPath: true, + }), + remove: async () => { + await callPluginHostRpc(harness.deps, { + pluginId: "review-worktree-plugin", + hostId: target.host.id, + contract: { + remove: { input: z.object({}), output: z.object({}) }, + }, + method: "remove", + input: {}, + timeoutMs: 100, + artifact: { + path: "/tmp/review-artifact", + generation: "1", + digest: "a".repeat(64), + byteLength: 1, + }, + }); + return { status: "removed" }; + }, + }), + }; + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => [record], + getEnvironmentProvider: (id) => + id === record.provider.id ? record : undefined, + invokeProvider: async (_id, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 1000, + }); + expect(requestMachineRemoval(harness.deps, target.host.id)).toBe(true); + const sweeping = sweepProviderMachine(harness.deps, target.host.id); + const call = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "plugin.host.call", + ); + expect(getHost(harness.db, target.host.id)?.phase).toBe("removing"); + await expect( + callHostOnlineRpcForWork(harness.deps, { + hostId: target.host.id, + timeoutMs: 100, + command: { type: "host.paths_exist", paths: ["/tmp/new-work"] }, + }), + ).rejects.toMatchObject({ body: { code: "machine_removing" } }); + await expect( + enrollment.prepare({ + signal: new AbortController().signal, + key: "cleanup-machine", + }), + ).rejects.toThrow("cancelled"); + await reportQueuedCommandSuccess(harness, call, { output: {} }); + await sweeping; + expect(getEnvironment(harness.db, environment.id)).toMatchObject({ + status: "destroyed", + teardownStatus: "removed", + }); + expect(getHost(harness.db, target.host.id)?.phase).toBe("destroyed"); + expect(resume).toHaveBeenCalledTimes(phase === "suspended" ? 1 : 0); + expect(machineRemove).toHaveBeenCalledOnce(); + }), +); + +it("keeps a standalone ephemeral machine available after successful creation", async () => + withTestHarness(async (harness) => { + const remove = vi.fn(async () => ({ status: "removed" as const })); + installMachineProvider({ + ephemeral: true, + inputs: z.object({ size: z.string().default("small") }), + create: async ({ key, inputs }) => { + expect(inputs).toEqual({ size: "small" }); + return { + status: "created", + name: "Standalone machine", + resource: { key }, + }; + }, + remove, + }); + const response = await harness.app.request("/api/v1/hosts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineProviderId: "test-machine", inputs: null }), + }); + expect(response.status).toBe(201); + const result = z.object({ id: z.string() }).parse(await readJson(response)); + await expect + .poll(() => getHost(harness.db, result.id)?.phase) + .not.toBe("creating"); + await sweepProviderMachine(harness.deps, result.id); + expect(getHost(harness.db, result.id)).toMatchObject({ + phase: "active", + type: "persistent", + destroyedAt: null, + }); + expect(remove).not.toHaveBeenCalled(); + })); + +it("removes a suspended machine when its last thread is archived with an offline follow-up queued", async () => + withTestHarness(async (harness) => { + const target = seedHostSession(harness.deps, { + id: "review-archived-queue", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: target.host.id, + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: target.host.id, + path: "/tmp/review-archived-queue", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + const remove = vi.fn(async () => ({ status: "removed" as const })); + installMachineProvider({ + ephemeral: true, + remove, + suspend: async () => ({ resource: { id: "owned" } }), + resume: async () => ({ resource: { id: "owned" } }), + }); + updateHost(harness.db, harness.hub, target.host.id, { + machineProviderId: "test-machine", + launchKey: thread.id, + resource: { id: "owned" }, + type: "ephemeral", + phase: "suspended", + suspendedAt: Date.now(), + }); + seedQueuedMessage(harness.deps, { + threadId: thread.id, + content: [{ type: "text", text: "Follow up", mentions: [] }], + waitingOn: { kind: "host-offline", hostName: target.host.name }, + }); + expect( + listThreadIdsWithHostOfflineQueueWaits(harness.db, target.host.id), + ).toEqual([thread.id]); + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/archive`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + expect( + listThreadIdsWithHostOfflineQueueWaits(harness.db, target.host.id), + ).toEqual([]); + await sweepProviderMachine(harness.deps, target.host.id); + await sweepProviderMachine(harness.deps, target.host.id); + expect(getHost(harness.db, target.host.id)?.phase).toBe("destroyed"); + expect(remove).toHaveBeenCalledOnce(); + })); 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..39033a02e4 --- /dev/null +++ b/apps/server/test/services/machines/manual-provider.test.ts @@ -0,0 +1,87 @@ +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"; + +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..e58d4fb589 --- /dev/null +++ b/apps/server/test/services/machines/provider-orchestration.test.ts @@ -0,0 +1,730 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createEnvironment, + createHostId, + environments, + getHost, + getEnvironment, + 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("recovers a persisted creating host with its original launch key", 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, { + lifetime: "standalone", + 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, { + lifetime: "standalone", + 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, { + lifetime: "standalone", + 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, { + lifetime: "standalone", + 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: Exclude) => 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.each([1, 2])( + "retires each of %i machines only after every project and shared environment releases it", + async (machineCount) => + withTestHarness(async (harness) => { + const remove = vi.fn(async () => ({ status: "removed" as const })); + installMachineProvider({ ephemeral: true, remove }); + const machineIds = Array.from({ length: machineCount }, () => { + const id = createHostId(); + const now = Date.now(); + harness.db + .insert(hosts) + .values({ + id, + name: id, + type: "ephemeral", + machineProviderId: "test-machine", + phase: "active", + resource: { allocation: id }, + createdAt: now, + updatedAt: now, + }) + .run(); + return id; + }); + const projects = ["alpha", "beta"].map( + (name) => + seedProjectWithSource(harness.deps, { + hostId: machineIds[0], + path: `/tmp/${name}`, + }).project, + ); + const allocations = machineIds.map((hostId) => ({ + hostId, + environments: projects.flatMap((project) => + ["checkout", "worktree"].map((kind) => { + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId, + path: `/tmp/${hostId}/${project.id}/${kind}`, + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + return { + environment, + threads: Array.from({ length: 2 }, () => + seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }), + ), + }; + }), + ), + })); + for (const allocation of allocations) { + updateHost(harness.db, harness.hub, allocation.hostId, { + launchKey: allocation.environments[0].threads[0].id, + }); + const owners = allocation.environments.flatMap( + (entry) => entry.threads, + ); + for (const [index, thread] of owners.entries()) { + expect( + requestAutomaticMachineRemoval(harness.deps, allocation.hostId), + ).toBe(false); + archiveThread(harness.db, harness.hub, thread.id); + expect( + requestAutomaticMachineRemoval(harness.deps, allocation.hostId), + ).toBe(index === owners.length - 1); + } + await sweepProviderMachine(harness.deps, allocation.hostId); + expect(getHost(harness.db, allocation.hostId)).toMatchObject({ + phase: "destroyed", + teardownAttempt: 1, + teardownStatus: "removed", + resource: null, + }); + for (const entry of allocation.environments) { + expect( + getEnvironment(harness.db, entry.environment.id)?.status, + ).toBe("destroyed"); + } + for (const other of allocations.slice( + allocations.indexOf(allocation) + 1, + )) { + expect(getHost(harness.db, other.hostId)?.phase).toBe("active"); + for (const entry of other.environments) { + expect( + getEnvironment(harness.db, entry.environment.id)?.status, + ).toBe("ready"); + } + } + } + expect(remove).toHaveBeenCalledTimes(machineCount); + }), + ); + + it("keeps a persistent machine with no threads", async () => + withTestHarness(async (harness) => { + installMachineProvider(); + const { host } = seedHostSession(harness.deps); + updateHost(harness.db, harness.hub, host.id, { + machineProviderId: "test-machine", + type: "persistent", + phase: "active", + resource: { allocation: "persistent" }, + }); + expect(requestAutomaticMachineRemoval(harness.deps, host.id)).toBe(false); + expect(getHost(harness.db, host.id)?.phase).toBe("active"); + })); + + it("waits for in-flight cleanup before a second sweep completes", async () => + withTestHarness(async (harness) => { + const started = createDeferredPromise(); + const release = createDeferredPromise(); + const remove = vi.fn(async () => { + started.resolve(); + await release.promise; + return { status: "removed" as const }; + }); + installMachineProvider({ remove }); + const { host } = seedHostSession(harness.deps); + updateHost(harness.db, harness.hub, host.id, { + machineProviderId: "test-machine", + resource: { allocation: "cancelled" }, + phase: "active", + }); + expect(requestMachineRemoval(harness.deps, host.id)).toBe(true); + const first = sweepProviderMachine(harness.deps, host.id); + await started.promise; + let settled = false; + const second = sweepProviderMachine(harness.deps, host.id).then(() => { + settled = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + } finally { + release.resolve(); + await Promise.all([first, second]); + } + expect(getHost(harness.db, host.id)?.phase).toBe("destroyed"); + expect(remove).toHaveBeenCalledOnce(); + })); + + 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("returns the durable resuming phase from an explicit resume request", async () => + withTestHarness(async (harness) => { + const started = createDeferredPromise(); + const release = createDeferredPromise(); + const target = seedHostSession(harness.deps, { id: "explicit-resume" }); + installMachineProvider({ + suspend: async () => ({ resource: { id: "owned" } }), + resume: async () => { + started.resolve(); + await release.promise; + return { resource: { id: "owned" } }; + }, + }); + updateHost(harness.db, harness.hub, target.host.id, { + machineProviderId: "test-machine", + phase: "suspended", + resource: { id: "owned" }, + suspendedAt: Date.now(), + }); + harness.hub.unregisterDaemon(target.session.id); + const notifyHost = vi.spyOn(harness.hub, "notifyHost"); + + const response = await harness.app.request( + `/api/v1/hosts/${target.host.id}/resume`, + { method: "POST" }, + ); + await started.promise; + try { + expect(response.status).toBe(202); + expect(await readJson(response)).toMatchObject({ + lifecycle: { phase: "resuming" }, + }); + expect(getHost(harness.db, target.host.id)?.phase).toBe("resuming"); + expect(notifyHost).toHaveBeenCalledWith(target.host.id, [ + "host-disconnected", + ]); + } finally { + release.resolve(); + } + + await expect + .poll(() => getHost(harness.db, target.host.id)?.phase) + .toBe("active"); + })); + + 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"); + })); +}); + +it.each(["null result", "null checkpoint", "missing name"])( + "rejects a provider's %s and retains a cleanup path", + async (invalid) => + withTestHarness(async (harness) => { + const reconcileCleanup = vi.fn(async () => ({ + status: "removed" as const, + })); + const record = installMachineProvider({ reconcileCleanup }); + Reflect.set( + record.provider, + "create", + async ( + context: Parameters[0], + ) => { + if (invalid === "null checkpoint") + await Reflect.apply(context.checkpoint, undefined, [null]); + if (invalid === "missing name") + return { status: "created", resource: {} }; + return { status: "created", name: "Invalid machine", resource: null }; + }, + ); + const host = await submitMachine(harness.deps, { + key: "invalid-resource", + machineProviderId: record.provider.id, + inputs: null, + }); + await expect + .poll(() => getHost(harness.db, host.id)?.phase) + .toBe("removing"); + expect(getHost(harness.db, host.id)?.teardownStatus).toBe("failed"); + expect(getHost(harness.db, host.id)?.resource).toBeNull(); + requestMachineRemoval(harness.deps, host.id); + await sweepProviderMachine(harness.deps, host.id); + expect(reconcileCleanup).toHaveBeenCalledWith({ + key: "invalid-resource", + report: expect.any(Object), + signal: expect.any(AbortSignal), + }); + expect(getHost(harness.db, host.id)?.phase).toBe("destroyed"); + }), +); 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..c2d0e82ccf --- /dev/null +++ b/apps/server/test/services/machines/runtime-enrollments.test.ts @@ -0,0 +1,355 @@ +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 { setPluginEnvironmentProviderBridge } from "../../../src/services/plugins/plugin-environment-provider-registry.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({ + signal: new AbortController().signal, + 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({ + signal: new AbortController().signal, + 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({ + signal: new AbortController().signal, + 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", + executor: { exec }, + 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, + }); + setAppSettings(h.db, { + ...defaultAppSettings, + defaultMachineAccess: "runtime-access", + }); + launch(h, "failure-launch", "enrollment-runtime-machine"); + await expect( + otherEnrollments.prepare({ + signal: new AbortController().signal, + key: "failure-launch", + }), + ).rejects.toThrow("different plugin"); + acquire.mockResolvedValueOnce({ + status: "failed", + message: "Cloud device may need dashboard revocation", + }); + await expect( + enrollments.prepare({ + signal: new AbortController().signal, + key: "failure-launch", + }), + ).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({ + signal: new AbortController().signal, + key: "failure-launch", + }); + 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({ + signal: new AbortController().signal, + key: "standalone", + }), + ).rejects.toThrow("host was not found"); + }); + }); +}); + +it("serves a composition's explicit icon instead of the machine provider's icon", async () => { + await withTestHarness(async (h) => { + setPluginEnvironmentProviderBridge(h.pluginService.environmentProviders); + const api = await installPlugin(h, "icon-runtime"); + const svg = + ''; + await writeFile( + join(h.config.dataDir, "bb-plugin-icon-runtime", "composition.svg"), + svg, + ); + api.experimental_environments.register({ + id: "icon-workspace", + displayName: "Icon workspace", + description: "Prepare a workspace for this thread.", + icon: "Folder", + create: async () => ({ status: "failed", message: "unused" }), + remove: async () => ({ status: "removed" }), + }); + api.experimental_environments.register({ + id: "icon-composition", + displayName: "Explicit composition", + description: "Prepare a workspace for this thread.", + icon: "./composition.svg", + machineProviderId: "icon-runtime-machine", + environmentProviderId: "icon-workspace", + }); + const listing = await h.app.request("/api/v1/system/environment-providers"); + const data = await listing.json(); + const composition = data.providers.find( + (entry: { id: string }) => entry.id === "icon-composition", + ); + expect(composition).toMatchObject({ + icon: "./composition.svg", + logoUrl: expect.stringContaining("environment%3Aicon-composition"), + }); + const response = await h.app.request(composition.logoUrl); + expect(response.status).toBe(200); + expect(await response.text()).toBe(svg); + }); +}); 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..b4a78bdb46 --- /dev/null +++ b/apps/server/test/services/machines/server-access.test.ts @@ -0,0 +1,406 @@ +import { createDeferredPromise } from "@bb/test-helpers"; +import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +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 } 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("cancels a pending availability check without acquiring access when it later resolves", async () => { + await withTestHarness(async ({ deps }) => { + const controller = new AbortController(); + const pending = createDeferredPromise<{ status: "available" }>(); + const availability = vi.fn(() => pending.promise); + const acquire = vi.fn(provider().acquire); + installProvider({ ...provider(), availability, acquire }); + const host = upsertHost(deps.db, deps.hub, { name: "cancelled" })!; + const result = serverAccess.resolve(deps, { + key: "cancelled", + hostId: host.id, + signal: controller.signal, + }); + const rejected = expect(result).rejects.toThrow("Cancelled by user"); + await vi.waitFor(() => expect(availability).toHaveBeenCalledOnce()); + controller.abort(new Error("Cancelled by user")); + await rejected; + pending.resolve({ status: "available" }); + await pending.promise; + expect(acquire).not.toHaveBeenCalled(); + expect(getHost(deps.db, host.id)?.serverAccessProviderId).toBeNull(); + }); + }); + + it("reports provider availability without acquiring a grant", async () => { + await withTestHarness(async ({ deps }) => { + const availability = vi.fn(() => ({ status: "available" as const })); + const acquire = vi.fn(provider().acquire); + installProvider({ ...provider(), availability, acquire }); + const status = await serverAccessStatus(deps); + const access = status.providers.find((entry) => entry.id === "relay"); + expect(access).toMatchObject({ id: "relay", pluginId: "access-plugin" }); + expect(access?.availability).toEqual({ status: "available" }); + expect(availability).toHaveBeenCalledOnce(); + expect(acquire).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"); + availability.mockClear(); + 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, + 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"); + setAppSettings(deps.db, { + ...defaultAppSettings, + defaultMachineAccess: "direct", + machineServerUrl: "https://other.example.com", + }); + expect( + await serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }), + ).toEqual(grant); + 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("refreshes access status through the real recheck notification and configuration route", 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"); + let availability: Awaited< + ReturnType + > = { status: "setup-required", message: "Pair the relay" }; + const acquire = vi.fn(provider().acquire); + api.experimental_serverAccess.register({ + ...provider(), + availability: () => availability, + acquire, + }); + setAppSettings(h.db, { + ...defaultAppSettings, + defaultMachineAccess: "relay", + }); + const notify = vi.spyOn(h.hub, "notifySystem"); + for (const next of [ + { status: "setup-required", message: "Pair the relay" }, + { status: "available", serverUrl: "https://relay.example.com" }, + { status: "unavailable", message: "Credential revoked" }, + { status: "available" }, + ] as const) { + availability = next; + notify.mockClear(); + api.experimental_serverAccess.recheck(); + expect(notify).toHaveBeenCalledWith(["config-changed"]); + const response = await h.app.request("/api/v1/system/config"); + expect(response.status).toBe(200); + const config = await response.json(); + expect( + config.serverAccess.providers.find( + (entry: { id: string }) => entry.id === "relay", + ).availability, + ).toEqual(next); + } + expect(acquire).not.toHaveBeenCalled(); + }); +}); + +it.each([ + { status: "available", serverUrl: "https://secret:password@example.com" }, + { status: "unexpected", message: "private diagnostics" }, + new Error("private diagnostics"), +])( + "fails closed without exposing invalid provider output: %s", + async (output) => { + await withTestHarness(async ({ deps }) => { + const availability = vi.fn(); + if (output instanceof Error) availability.mockRejectedValue(output); + else availability.mockResolvedValue(output); + installProvider({ ...provider(), availability }); + const status = await serverAccessStatus(deps); + expect(status.providers[0]?.availability?.status).toBe("unavailable"); + expect(JSON.stringify(status)).not.toMatch( + /secret|password|private diagnostics/, + ); + }); + }, +); + +it("bounds a stalled availability check and recovers on the next read", async () => { + await withTestHarness(async ({ deps }) => { + const pending = createDeferredPromise<{ status: "available" }>(); + installProvider({ ...provider(), availability: () => pending.promise }); + vi.useFakeTimers(); + try { + const result = serverAccessStatus(deps); + await vi.advanceTimersByTimeAsync(5_000); + expect((await result).providers[0]?.availability?.status).toBe( + "unavailable", + ); + pending.resolve({ status: "available" }); + expect( + (await serverAccessStatus(deps)).providers[0]?.availability, + ).toEqual({ status: "available" }); + } finally { + vi.useRealTimers(); + } + }); +}); + +it.each([ + { id: "direct" }, + { id: "Invalid ID" }, + { displayName: " " }, + { description: " " }, +])( + "matches real and fake server-access registration validation: %j", + async (invalid) => { + await withTestHarness(async (h) => { + await h.pluginService.install("builtin:keep-awake", { kind: "root" }); + const real = h.pluginService.getApi("keep-awake"); + if (!real) throw new Error("Test plugin did not load"); + const fake = createFakePluginHost(); + try { + for (const api of [real, fake.bb]) + expect(() => + api.experimental_serverAccess.register({ + ...provider(), + ...invalid, + }), + ).toThrow(); + } finally { + await fake.harness.lifecycle.dispose(); + } + }); + }, +); + +it("rejects duplicate server-access registrations in real and fake hosts", async () => { + await withTestHarness(async (h) => { + await h.pluginService.install("builtin:keep-awake", { kind: "root" }); + const real = h.pluginService.getApi("keep-awake"); + if (!real) throw new Error("Test plugin did not load"); + const fake = createFakePluginHost(); + try { + for (const api of [real, fake.bb]) { + api.experimental_serverAccess.register(provider()); + expect(() => + api.experimental_serverAccess.register(provider()), + ).toThrow("already registered"); + } + } finally { + await fake.harness.lifecycle.dispose(); + } + }); +}); 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-agent-tools.test.ts b/apps/server/test/services/plugins/plugin-agent-tools.test.ts index 7b39e4149d..a2542b2cc3 100644 --- a/apps/server/test/services/plugins/plugin-agent-tools.test.ts +++ b/apps/server/test/services/plugins/plugin-agent-tools.test.ts @@ -472,6 +472,8 @@ describe("bb.agents.registerTool", () => { bb.experimental_environments.register({ id: "shared-env", displayName: "Shared", + description: "Create a shared workspace.", + icon: "Folder", create: async () => ({ status: "failed", message: "waiting" }), remove: async () => ({ status: "removed" }), }); } @@ -484,6 +486,8 @@ describe("bb.agents.registerTool", () => { bb.experimental_environments.register({ id: "shared-env", displayName: "Shared again", + description: "Create another shared workspace.", + icon: "Folder", create: async () => ({ status: "failed", message: "waiting" }), remove: async () => ({ status: "removed" }), }); } 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..d49d80736f 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import ts from "typescript"; import * as pluginSdkApp from "@get-bb/plugin-sdk/app"; import { type BbPluginApi, @@ -24,6 +25,7 @@ import { type PluginNewThreadPanelProps, type PluginPendingInteractionProps, type PluginEnvironmentProviderInputsProps, + type PluginMachineProviderInputsProps, type PluginProviderIconRegistration, type PluginTimelineRendererProps, type PluginSettingDescriptor, @@ -165,6 +167,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 +213,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 +273,7 @@ type SlotPropsByName = { experimental_providerIcon: PluginProviderIconRegistration; experimental_timelineRenderer: PluginTimelineRendererProps; experimental_environmentProviderInputs: PluginEnvironmentProviderInputsProps; + experimental_machineProviderInputs: PluginMachineProviderInputsProps; }; type MissingSlot = Exclude; @@ -377,7 +384,7 @@ const FRONTEND_SLOT_PROP_FIELDS = { messageDirective: ["attributes", "source", "message", "openWorkspaceFile"], messageAction: ["threadId", "message", "selectedText", "openPanel"], commandPaletteAction: ["threadId", "projectId", "openPanel"], - experimental_providerIcon: ["providerId", "icon"], + experimental_providerIcon: ["providerKind", "providerId", "icon"], experimental_timelineRenderer: [ "row", "payload", @@ -387,10 +394,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 +523,41 @@ 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("typechecks the machine provider guide example against the public SDK", () => { + const source = readReference("backend-machines.md").match( + /```ts\n([\s\S]*?)```/u, + )?.[1]; + expect(source).toBeDefined(); + const filename = fileURLToPath( + new URL("./machine-guide-example.ts", import.meta.url), + ); + const options: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + }; + const host = ts.createCompilerHost(options); + const readSource = host.getSourceFile.bind(host); + host.getSourceFile = ( + file, + languageVersion, + onError, + shouldCreateNewSourceFile, + ) => + file === filename + ? ts.createSourceFile(filename, source!, languageVersion) + : readSource(file, languageVersion, onError, shouldCreateNewSourceFile); + const program = ts.createProgram([filename], options, host); + expect( + ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => + ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + ), + ).toEqual([]); }); it("has frontmatter naming the skill after its directory", () => { @@ -667,7 +702,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-provider-registration.test.ts b/apps/server/test/services/plugins/plugin-provider-registration.test.ts index aba0baefe2..41e809e921 100644 --- a/apps/server/test/services/plugins/plugin-provider-registration.test.ts +++ b/apps/server/test/services/plugins/plugin-provider-registration.test.ts @@ -243,6 +243,60 @@ describe("bb.providers.register (server)", () => { }); }); + it.each(["Terminal", "./icons/agent.svg"])( + "uses the unknown-folder fallback for legacy compositions with machine icon %s", + async (icon) => { + await withTestHarness(async (harness) => { + const rootDir = await writePlugin(workDir, { + name: "bb-plugin-legacy-environments", + withBridge: false, + serverSource: `export default function(bb) { + bb.experimental_machines.register({ + id: "legacy-machine", displayName: "Legacy machine", description: "Create a test machine.", icon: ${JSON.stringify(icon)}, + create: async () => ({ status: "failed", message: "unused" }), + reconcileCleanup: async () => ({ status: "removed" }), remove: async () => ({ status: "removed" }) + }); + bb.experimental_environments.register({ + id: "legacy-workspace", displayName: "Legacy workspace", + create: async () => ({ status: "failed", message: "unused" }), remove: async () => ({ status: "removed" }) + }); + bb.experimental_environments.register({ + id: "legacy-composition", displayName: "Legacy composition", + machineProviderId: "legacy-machine", environmentProviderId: "legacy-workspace" + }); + }`, + }); + const svg = + ''; + await mkdir(join(rootDir, "icons"), { recursive: true }); + await writeFile(join(rootDir, "icons/agent.svg"), svg); + const entry = await harness.pluginService.installPath(rootDir); + expect(entry.status, entry.statusDetail ?? "").toBe("running"); + setPluginEnvironmentProviderBridge( + harness.pluginService.environmentProviders, + ); + const response = await harness.app.request( + "/api/v1/system/environment-providers", + ); + expect(response.status).toBe(200); + const { providers } = systemEnvironmentProvidersResponseSchema.parse( + await response.json(), + ); + expect( + providers.find((provider) => provider.id === "legacy-workspace"), + ).toMatchObject({ description: null, icon: null, logoUrl: null }); + const composition = providers.find( + (provider) => provider.id === "legacy-composition", + ); + expect(composition).toMatchObject({ + description: null, + icon: "FolderUnknown", + logoUrl: null, + }); + }); + }, + ); + it.each(["./icons/agent.svg", "marked-environment/mark"])( "serves environment provider icon %s with a hashed logo URL", async (icon) => { @@ -251,7 +305,7 @@ describe("bb.providers.register (server)", () => { name: "bb-plugin-marked-environment", withBridge: false, icons: { mark: "./icons/agent.svg" }, - serverSource: `export default function plugin(bb) { bb.experimental_environments.register({ id: "marked-environment", displayName: "Marked", icon: ${JSON.stringify(icon)}, create: async () => ({ status: "failed", message: "waiting" }), remove: async () => ({ status: "removed" }) }); }`, + serverSource: `export default function plugin(bb) { bb.experimental_environments.register({ id: "marked-environment", displayName: "Marked", description: "Prepare a marked workspace.", icon: ${JSON.stringify(icon)}, create: async () => ({ status: "failed", message: "waiting" }), remove: async () => ({ status: "removed" }) }); }`, }); const svg = ''; @@ -271,6 +325,10 @@ describe("bb.providers.register (server)", () => { const provider = providers.find( (provider) => provider.id === "marked-environment", ); + expect(provider).toMatchObject({ + description: "Prepare a marked workspace.", + icon, + }); expect(provider?.logoUrl).toContain( "environment%3Amarked-environment/logo?h=", ); 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..2d4d485757 100644 --- a/apps/server/test/threads/environment-providers.test.ts +++ b/apps/server/test/threads/environment-providers.test.ts @@ -12,12 +12,15 @@ import { createProjectSource, ensurePersonalProject, getEnvironment, + getNonDestroyedHostByLaunchKey, getPreparingEnvironment, getDefaultProjectSource, getThread, getThreadStartupContext, listEnvironments, listEvents, + setProjectGitRemoteUrlIfMissing, + updateHost, } from "@bb/db"; import { PERSONAL_PROJECT_ID, type JsonValue } from "@bb/domain"; import type { @@ -27,19 +30,28 @@ 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 { + listEnvironmentProviders, 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, } from "../../src/services/plugins/plugin-hook-registry.js"; import { attemptDispatch } from "../../src/services/threads/dispatch-attempt.js"; +import { advanceThreadProvisioning } from "../../src/services/threads/thread-provisioning.js"; +import { setPluginThreadEventEmitter } from "../../src/services/plugins/plugin-thread-events.js"; import { recheckEnvironmentProviderCreations, scheduledEnvironmentProviderAskCount, @@ -51,6 +63,7 @@ import { } from "../../src/services/threads/thread-startup-store.js"; import { registerTestHostRpcCapture, + listQueuedThreadCommands, reportQueuedCommandError, reportQueuedCommandSuccess, waitForQueuedCommand, @@ -64,6 +77,7 @@ import { seedPrimaryHost, seedProjectWithSource, seedThread, + seedThreadRuntimeState, } from "../helpers/seed.js"; import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; @@ -95,12 +109,17 @@ 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({ id: fake.id ?? PROVIDER_ID, displayName: "Fake container", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { projectCheckout: fake.requiresProjectCheckout ?? false, gitCheckout: fake.requiresGitCheckout ?? false, @@ -117,6 +136,7 @@ function installTargets(fakes: FakeTarget[]): void { }), })); setPluginEnvironmentProviderBridge({ + listEnvironmentCompositions: () => compositions, listEnvironmentProviders: () => records, getEnvironmentProvider: (id) => records.find((record) => record.provider.id === id), @@ -165,6 +185,7 @@ function installEnvironmentIntentProbe(): PluginDispatchEnvironmentIntent[] { afterEach(() => { clearAllThreadProvisionSchedules(); setPluginEnvironmentProviderBridge(undefined); + setPluginMachineProviderBridge(undefined); setPluginHookProvider(undefined); }); @@ -192,6 +213,376 @@ function seedTargetFixture( return { environment, host, project, session }; } +describe("machine and environment provider composition", () => { + function installCompositionMachine( + create: ( + context: PluginMachineProviderCreateContext, + ) => Promise< + | { status: "created"; name: string; resource: Record } + | { status: "failed"; message: string } + >, + inputs?: z.ZodType, + ): void { + const machine = { + pluginId: "cloud", + provider: validatePluginMachineProviderDeclaration({ + description: "Provision a test machine.", + icon: "Terminal", + id: "test-machine", + displayName: "Test machine", + ...(inputs === undefined ? {} : { inputs }), + create, + reconcileCleanup: async () => ({ status: "removed" as const }), + remove: async () => ({ status: "removed" as const }), + }), + }; + 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: () => ({ action: "wait", reason: "Waiting" }), + }, + ], + [ + { + pluginId: "cloud", + composition: { + id: "test-sandbox", + displayName: "Test sandbox", + description: "Prepare a workspace for this thread.", + icon: "Cloud", + machineProviderId: "test-machine", + environmentProviderId: "project-checkout", + }, + }, + ], + ); + } + + it("shows a machine bootstrap failure in the provisioning transcript", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "composition-bootstrap-failure", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + "https://example.test/project.git", + ); + const message = + "Machine bootstrap command failed:\nbb-machine-install: 9: node: not found\nbb-machine-install: 9: curl: not found"; + installCompositionMachine(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 }; + }); + + 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 expect + .poll(() => getThread(harness.db, thread.id)?.status) + .toBe("error"); + const entries = provisioningEvents(harness, thread.id).flatMap( + (event) => event.entries, + ); + expect(entries).toContainEqual( + expect.objectContaining({ + key: expect.stringMatching(/^provider-step-1-/u), + text: "Bootstrapping machine", + }), + ); + expect(entries).toContainEqual( + expect.objectContaining({ + key: expect.stringMatching(/^provider-output-1-/u), + text: `${message}\n`, + }), + ); + expect(provisioningEvents(harness, thread.id).at(-1)?.status).toBe( + "failed", + ); + }); + }); + + it("validates composition machine inputs and passes the parsed value to create", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "composition-inputs", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + "https://example.test/project.git", + ); + const create = vi.fn( + async (_context: PluginMachineProviderCreateContext) => ({ + status: "failed" as const, + message: "Observed inputs", + }), + ); + installCompositionMachine( + create, + z.object({ imageId: z.string().trim().min(1) }), + ); + + await createThreadFromRequest(harness.deps, { + environment: { + type: "provider", + environmentProviderId: "test-sandbox", + machine: { + type: "new", + machineProviderId: "test-machine", + inputs: { imageId: " im-custom " }, + }, + inputs: null, + }, + projectId: project.id, + input: textInput("Create it"), + origin: "app", + providerId: "codex", + model: "requested-model", + startedOnBehalfOf: null, + }); + await expect + .poll(() => create.mock.calls[0]?.[0].inputs) + .toEqual({ imageId: "im-custom" }); + }); + }); + + it("passes null machine inputs when a composition omits machine", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "composition-default", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + "https://example.test/project.git", + ); + const create = vi.fn( + async (_context: PluginMachineProviderCreateContext) => ({ + status: "failed" as const, + message: "Observed inputs", + }), + ); + installCompositionMachine(create); + + 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 expect.poll(() => create.mock.calls[0]?.[0].inputs).toBeNull(); + }); + }); + + it("refuses a composition request with the wrong machine provider before allocating", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "composition-wrong", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + "https://example.test/project.git", + ); + const create = vi.fn(async () => ({ + status: "created" as const, + name: "Test machine", + resource: {}, + })); + installCompositionMachine(create); + + await expect( + createThreadFromRequest(harness.deps, { + environment: { + type: "provider", + environmentProviderId: "test-sandbox", + machine: { + type: "new", + machineProviderId: "other-machine", + inputs: null, + }, + inputs: null, + }, + projectId: project.id, + input: textInput("Do not allocate"), + origin: "app", + providerId: "codex", + model: "requested-model", + startedOnBehalfOf: null, + }), + ).rejects.toThrow("must select that provider"); + expect(create).not.toHaveBeenCalled(); + }); + }); + + 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", + description: "Prepare a workspace for this thread.", + icon: "Cloud", + 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", + }); + expect(blockEntries(harness, thread.id)).toContainEqual([ + "output", + 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: { @@ -253,6 +644,24 @@ function readyAt(host: { id: string }): TestProviderDecision { } describe("environment providers are asked inside provisioning", () => { + it("refuses placement on a machine being removed", async () => { + await withTestHarness(async (harness) => { + installTarget({ provision: () => ({ action: "wait", reason: "…" }) }); + const { host, project } = seedTargetFixture( + harness, + "host-being-removed", + ); + updateHost(harness.db, harness.hub, host.id, { phase: "removing" }); + + await expect( + createTargetThread(harness, { + projectId: project.id, + hostId: host.id, + }), + ).rejects.toThrow("Machine removal has begun"); + }); + }); + it("shares a projectless parent's personal workspace when no environment flags are supplied", async () => { await withTestHarness(async (harness) => { installTargets([ @@ -1417,9 +1826,11 @@ describe("provider inputs are parsed at create time", () => { }; expect(body.providers).toEqual([ { + machineProviderId: null, id: PROVIDER_ID, displayName: "Fake container", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", logoUrl: null, pluginId: PLUGIN_ID, requires: { @@ -1441,9 +1852,11 @@ describe("provider inputs are parsed at create time", () => { availability: null, }, { + machineProviderId: null, id: "plain", displayName: "Fake container", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", logoUrl: null, pluginId: PLUGIN_ID, requires: { @@ -1866,14 +2279,485 @@ describe("environment provider listing", () => { }); }); -it("stops an unattached provider creation using the thread's durable startup state", async () => { +describe("core's worktree beside providers", () => { + it("lists only registered providers and turns a worktree request into a worktree provider intent", async () => { + await withTestHarness(async (harness) => { + installTarget({ provision: () => ({ action: "wait", reason: "…" }) }); + expect( + listEnvironmentProviders().map( + (record) => `${record.pluginId}:${record.provider.id}`, + ), + ).toEqual([`${PLUGIN_ID}:${PROVIDER_ID}`]); + const environmentIntents = installEnvironmentIntentProbe(); + const { host, project, session } = seedTargetFixture( + harness, + "host-core-worktree", + ); + registerTestHostRpcCapture(harness, { + hostId: host.id, + sessionId: session.id, + }); + + const created = await createThreadFromRequest(harness.deps, { + environment: { + type: "host", + hostId: host.id, + workspace: { + type: "managed-worktree", + baseBranch: { kind: "named", name: "main" }, + }, + }, + input: textInput("Do the thing"), + origin: "app", + projectId: project.id, + providerId: "codex", + model: "requested-model", + startedOnBehalfOf: null, + }); + + expect( + getThreadProvisionContext(harness.db, created.id)?.request + .environmentIntent, + ).toEqual({ + type: "provider", + environmentProviderId: "git-worktree", + machine: { type: "existing", hostId: host.id }, + inputs: { branch: { kind: "named", name: "main" } }, + selectionResolved: false, + }); + expect(environmentIntents).toEqual([ + { + kind: "provider", + environmentProviderId: "git-worktree", + machine: { type: "existing", hostId: host.id }, + inputs: { branch: { kind: "named", name: "main" } }, + }, + ]); + }); + }); +}); + +describe("a provider-produced environment over its life", () => { + it("records the producing provider on the environment it creates", async () => { + await withTestHarness(async (harness) => { + const { host, project, session } = seedTargetFixture( + harness, + "host-target-provenance", + ); + registerTestHostRpcCapture(harness, { + hostId: host.id, + sessionId: session.id, + }); + installTarget({ + inputs: CONTAINER_INPUTS, + provision: () => ({ + action: "ready", + environment: { + type: "host", + hostId: host.id, + path: "/tmp/environment-providers-fresh", + }, + }), + }); + const created = await createTargetThread(harness, { + projectId: project.id, + inputs: { image: "img" }, + }); + const queued = await waitForQueuedCommand( + harness, + (candidate) => + candidate.command.type === "environment.attach" && + candidate.command.initiator?.threadId === created.id, + ); + if (queued.command.type !== "environment.attach") + throw new Error("Expected environment.attach command"); + await reportQueuedCommandSuccess(harness, queued, { + path: queued.command.path, + isGitRepo: true, + isWorktree: true, + branchName: "feature", + defaultBranch: "main", + transcript: [], + }); + await vi.waitFor(() => + expect(getThread(harness.db, created.id)?.environmentId).not.toBeNull(), + ); + const environmentId = getThread(harness.db, created.id)?.environmentId; + const environment = getEnvironment(harness.db, environmentId ?? ""); + expect(environment).toMatchObject({ + environmentProviderId: PROVIDER_ID, + environmentProviderSelection: { + machine: { type: "existing", hostId: host.id }, + inputs: { image: "img", cpus: 2 }, + }, + environmentProviderInstanceKey: created.id, + providerOwnsPath: true, + }); + const listed = (await readJson( + await harness.app.request( + `/api/v1/environments?environmentProviderId=${PROVIDER_ID}&instanceKey=${created.id}`, + ), + )) as Array<{ id: string }>; + expect(listed.map((row) => row.id)).toEqual([environmentId]); + }); + }); + + it("records that a provider only attached to a directory it does not own", async () => { + await withTestHarness(async (harness) => { + const { host, project } = seedTargetFixture( + harness, + "host-target-attached", + ); + installTarget({ + inputs: CONTAINER_INPUTS, + provision: () => ({ + action: "ready", + environment: { + type: "host", + hostId: host.id, + path: "/tmp/environment-providers-attached", + ownsPath: false, + }, + }), + }); + const created = await createTargetThread(harness, { + projectId: project.id, + inputs: { image: "img" }, + }); + await vi.waitFor(() => + expect(getThread(harness.db, created.id)?.environmentId).not.toBeNull(), + ); + const environment = getEnvironment( + harness.db, + getThread(harness.db, created.id)?.environmentId ?? "", + ); + expect(environment?.providerOwnsPath).toBe(false); + const response = (await readJson( + await harness.app.request(`/api/v1/environments/${environment?.id}`), + )) as { managed: boolean; workspaceProvisionType: string | null }; + expect(response).toMatchObject({ + managed: false, + workspaceProvisionType: null, + }); + }); + }); + + it("records the base branch a provider says it branched from", async () => { + await withTestHarness(async (harness) => { + const { host, project, session } = seedTargetFixture( + harness, + "host-target-merge-base", + ); + registerTestHostRpcCapture(harness, { + hostId: host.id, + sessionId: session.id, + }); + installTarget({ + inputs: CONTAINER_INPUTS, + provision: () => ({ + action: "ready", + environment: { + type: "host", + hostId: host.id, + path: "/tmp/environment-providers-merge-base", + mergeBaseBranch: "origin/main", + }, + }), + }); + const created = await createTargetThread(harness, { + projectId: project.id, + inputs: { image: "img" }, + }); + const queued = await waitForQueuedCommand( + harness, + (candidate) => + candidate.command.type === "environment.attach" && + candidate.command.path === "/tmp/environment-providers-merge-base", + ); + if (queued.command.type !== "environment.attach") + throw new Error("Expected environment.attach command"); + await reportQueuedCommandSuccess(harness, queued, { + path: queued.command.path, + isGitRepo: true, + isWorktree: true, + branchName: "feature", + defaultBranch: "main", + transcript: [], + }); + await vi.waitFor(() => { + const environmentId = getThread(harness.db, created.id)?.environmentId; + expect(getEnvironment(harness.db, environmentId ?? "")?.status).toBe( + "ready", + ); + }); + const environment = getEnvironment( + harness.db, + getThread(harness.db, created.id)?.environmentId ?? "", + ); + expect(environment?.mergeBaseBranch).toBe("origin/main"); + expect(environment?.baseBranch).toBeNull(); + }); + }); + + it("generates the instance key from the core launch path key", async () => { + await withTestHarness(async (harness) => { + const { host, project } = seedTargetFixture( + harness, + "host-target-no-key", + ); + installTarget({ + provision: () => ({ + action: "ready", + environment: { + type: "host", + hostId: host.id, + path: "/tmp/environment-providers-unkeyed", + }, + }), + }); + const created = await createTargetThread(harness, { + projectId: project.id, + }); + await vi.waitFor(() => + expect(getThread(harness.db, created.id)?.environmentId).not.toBeNull(), + ); + const environmentId = getThread(harness.db, created.id)?.environmentId; + expect( + getEnvironment(harness.db, environmentId ?? "") + ?.environmentProviderInstanceKey, + ).toBe(created.id); + }); + }); + + it("aborts create and asks the provider to remove by path key when stopped", async () => { + await withTestHarness(async (harness) => { + const cancelled: string[] = []; + installTarget({ + provision: () => ({ action: "wait", reason: "Starting container…" }), + remove: async ({ pathKey }) => { + cancelled.push(pathKey); + return { status: "removed" }; + }, + }); + const { project } = seedTargetFixture(harness, "host-target-cancel"); + const created = await createTargetThread(harness, { + projectId: project.id, + }); + await vi.waitFor(() => + expect(scheduledEnvironmentProviderAskCount()).toBe(1), + ); + const response = await harness.app.request( + `/api/v1/threads/${created.id}/stop`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + await vi.waitFor(() => expect(cancelled).toEqual([created.id])); + expect(scheduledEnvironmentProviderAskCount()).toBe(0); + expect(getThread(harness.db, created.id)?.status).not.toBe("starting"); + expect(provisioningEvents(harness, created.id).at(-1)?.status).toBe( + "cancelled", + ); + }); + }); + + it("never asks again about a thread deleted while it was waiting", async () => { + await withTestHarness(async (harness) => { + const asks: string[] = []; + const cancelled: string[] = []; + installTarget({ + provision: (context) => { + asks.push(context.thread.id); + return { action: "wait", reason: "Starting container…" }; + }, + remove: async ({ pathKey }) => { + cancelled.push(pathKey); + return { status: "removed" }; + }, + }); + const { project } = seedTargetFixture(harness, "host-target-deleted"); + const created = await createTargetThread(harness, { + projectId: project.id, + }); + await vi.waitFor(() => + expect(scheduledEnvironmentProviderAskCount()).toBe(1), + ); + const response = await harness.app.request( + `/api/v1/threads/${created.id}`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ childThreadsConfirmed: false }), + }, + ); + expect(response.status).toBe(200); + recheckEnvironmentProviderCreations(harness.deps, PLUGIN_ID); + await vi.waitFor(() => + expect(scheduledEnvironmentProviderAskCount()).toBe(0), + ); + expect(new Set(asks)).toEqual(new Set([created.id])); + await vi.waitFor(() => expect(cancelled).toEqual([created.id])); + }); + }); + + it("fires thread.unarchived and dispatches provider unarchive when a thread comes back", async () => { + await withTestHarness(async (harness) => { + const unarchived: string[] = []; + setPluginThreadEventEmitter({ + emitThreadEvents: () => {}, + emitTerminalInput: () => {}, + emitThreadCreated: () => {}, + emitThreadActive: () => {}, + emitThreadIdle: () => {}, + emitThreadFailed: () => {}, + emitThreadArchived: () => {}, + emitThreadUnarchived: (thread) => { + unarchived.push(thread.id); + }, + emitThreadDeleted: () => {}, + emitMessageQueued: () => {}, + emitMessageDispatched: () => {}, + emitMessageCancelled: () => {}, + emitInteractionPending: () => {}, + emitTurnFailed: () => 0, + }); + try { + const { environment, host, project, session } = seedTargetFixture( + harness, + "host-target-unarchive", + ); + registerTestHostRpcCapture(harness, { + hostId: host.id, + sessionId: session.id, + }); + const thread = seedThread(harness.deps, { + environmentId: environment.id, + projectId: project.id, + status: "idle", + }); + const providerThreadId = "provider-unarchive"; + seedThreadRuntimeState(harness.deps, { + environmentId: environment.id, + providerThreadId, + threadId: thread.id, + }); + expect( + ( + await harness.app.request(`/api/v1/threads/${thread.id}/archive`, { + method: "POST", + }) + ).status, + ).toBe(200); + expect( + ( + await harness.app.request( + `/api/v1/threads/${thread.id}/unarchive`, + { method: "POST" }, + ) + ).status, + ).toBe(200); + expect(unarchived).toEqual([thread.id]); + expect( + listQueuedThreadCommands(harness, "thread.unarchive", thread.id), + ).toEqual([ + expect.objectContaining({ + environmentId: environment.id, + providerThreadId, + providerId: thread.providerId, + threadId: thread.id, + type: "thread.unarchive", + }), + ]); + } finally { + setPluginThreadEventEmitter(undefined); + } + }); + }); +}); + +it("resumes a provider launch and its original request after the in-memory context is lost", async () => { + await withTestHarness(async (harness) => { + const { host, project } = seedTargetFixture(harness, "host-restart", { + environmentProviderId: PROVIDER_ID, + }); + let ready = false; + installTarget({ + provision: () => + ready ? readyAt(host) : { action: "wait", reason: "Creating" }, + }); + const created = await createTargetThread(harness, { + projectId: project.id, + }); + await vi.waitFor(() => + expect(getPreparingEnvironment(harness.db, created.id)?.status).toBe( + "creating", + ), + ); + const preparing = getPreparingEnvironment(harness.db, created.id); + const attempt = preparing?.attempt; + const environmentId = preparing?.id; + expect(getThreadStartupContext(harness.db, created.id)).not.toBeNull(); + clearAllThreadProvisionSchedules(); + ready = true; + await advanceThreadProvisioning(harness.deps, { threadId: created.id }); + await vi.waitFor(() => + expect(getThread(harness.db, created.id)?.environmentId).not.toBeNull(), + ); + expect(getEnvironment(harness.db, environmentId!)?.attempt).toBe(attempt); + expect(getThread(harness.db, created.id)?.status).not.toBe("error"); + }); +}); + +it("keeps an existing request waiting when its provider is not registered", async () => { + await withTestHarness(async (harness) => { + const { host, project } = seedTargetFixture( + harness, + "host-register-later", + { environmentProviderId: PROVIDER_ID }, + ); + let ready = false; + const provision = () => + ready ? readyAt(host) : ({ action: "wait", reason: "Creating" } as const); + installTarget({ provision }); + const thread = await createTargetThread(harness, { + projectId: project.id, + }); + await vi.waitFor(() => + expect(getPreparingEnvironment(harness.db, thread.id)?.status).toBe( + "creating", + ), + ); + const preparing = getPreparingEnvironment(harness.db, thread.id); + const attempt = preparing?.attempt; + const environmentId = preparing?.id; + clearAllThreadProvisionSchedules(); + installTarget(null); + await advanceThreadProvisioning(harness.deps, { threadId: thread.id }); + expect(getPreparingEnvironment(harness.db, thread.id)).toMatchObject({ + attempt, + status: "creating", + }); + expect(getThreadStartupContext(harness.db, thread.id)).not.toBeNull(); + + ready = true; + installTarget({ provision }); + await vi.waitFor(() => + expect(getThread(harness.db, thread.id)?.environmentId).not.toBeNull(), + ); + expect(getEnvironment(harness.db, environmentId!)?.attempt).toBe(attempt); + }); +}); + +it("stops an unattached provider creation and provisions its follow-up", async () => { await withTestHarness(async (harness) => { const remove = vi.fn(async () => ({ status: "removed" as const })); installTarget({ provision: () => ({ action: "wait", reason: "Allocating" }), remove, }); - const { project } = seedTargetFixture(harness, "host-stop-durable-startup"); + const { project, host } = seedTargetFixture( + harness, + "host-stop-durable-startup", + ); const created = await createTargetThread(harness, { projectId: project.id, }); @@ -1895,6 +2779,33 @@ it("stops an unattached provider creation using the thread's durable startup sta ); expect(remove).toHaveBeenCalledTimes(1); expect(getThread(harness.db, created.id)?.status).toBe("idle"); - expect(getThreadStartupContext(harness.db, created.id)).toBeNull(); + installTarget({ provision: () => readyAt(host), remove }); + const response = await harness.app.request( + `/api/v1/threads/${created.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: textInput("Continue after stopping setup"), + mode: "auto", + }), + }, + ); + expect(response.status, await response.text()).toBe(200); + const start = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "thread.start" && command.threadId === created.id, + ); + expect(start.command).toMatchObject({ + input: textInput("Continue after stopping setup"), + }); + expect(getThread(harness.db, created.id)?.environmentId).not.toBe( + environmentId, + ); + await reportQueuedCommandError(harness, start, { + errorCode: "test_cleanup", + errorMessage: "Settle test start", + }); }); }); diff --git a/apps/server/test/threads/machine-lifecycle.test.ts b/apps/server/test/threads/machine-lifecycle.test.ts new file mode 100644 index 0000000000..450e8cb379 --- /dev/null +++ b/apps/server/test/threads/machine-lifecycle.test.ts @@ -0,0 +1,358 @@ +import { + getEnvironment, + getHost, + getLatestSessionForHost, + getNonDestroyedHostByLaunchKey, + getThread, + listQueuedThreadMessages, + setProjectGitRemoteUrlIfMissing, +} from "@bb/db"; +import { threadScope, turnScope, type ThreadEvent } from "@bb/domain"; +import { groupHostDaemonEvents } from "@bb/host-daemon-contract"; +import { validatePluginMachineProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import { createDeferredPromise } from "@bb/test-helpers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sweepMachineLifecycles } from "../../src/services/machines/provider-orchestration.js"; +import { setPluginEnvironmentProviderBridge } from "../../src/services/plugins/plugin-environment-provider-registry.js"; +import { setPluginMachineProviderBridge } from "../../src/services/plugins/plugin-machine-provider-registry.js"; +import { createThreadFromRequest } from "../../src/services/threads/thread-create.js"; +import { clearAllThreadProvisionSchedules } from "../../src/services/threads/thread-startup-store.js"; +import { + createTestDaemonEventEnvelope, + internalAuthHeaders, + reportQueuedCommandSuccess, + waitForQueuedCommand, +} from "../helpers/commands.js"; +import { defaultEnvironmentProviderRecords } from "../helpers/environment-provider.js"; +import { textInput } from "../helpers/prompt-input.js"; +import { + seedHostSession, + seedProjectWithSource, + seedSession, +} from "../helpers/seed.js"; +import { withTestHarness } from "../helpers/test-app.js"; + +afterEach(() => { + clearAllThreadProvisionSchedules(); + setPluginEnvironmentProviderBridge(undefined); + setPluginMachineProviderBridge(undefined); +}); + +describe("composed machine thread lifecycle", () => { + it.each([ + { firstTurn: "completed", archiveFrom: "active" }, + { firstTurn: "stopped", archiveFrom: "suspended" }, + ] as const)( + "pauses after a $firstTurn turn, resumes for a follow-up, and retires from $archiveFrom", + async ({ firstTurn, archiveFrom }) => { + await withTestHarness(async (harness) => { + const source = seedHostSession(harness.deps, { id: "source-machine" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: source.host.id, + }); + const remoteUrl = "https://example.test/project.git"; + const path = "/tmp/machine-lifecycle-checkout"; + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + remoteUrl, + ); + const suspend = vi.fn(async ({ hostId }: { hostId: string }) => { + expect(harness.hub.hasDaemonForHost(hostId)).toBe(false); + return { resource: { snapshot: "saved" } }; + }); + const resumeStarted = createDeferredPromise(); + const finishResume = createDeferredPromise(); + const resume = vi.fn(async ({ hostId }: { hostId: string }) => { + seedSession(harness.deps, hostId); + resumeStarted.resolve(); + await finishResume.promise; + return { resource: { snapshot: "saved" } }; + }); + const remove = vi.fn(async () => ({ status: "removed" as const })); + const machine = { + pluginId: "test-cloud", + provider: validatePluginMachineProviderDeclaration({ + id: "test-machine", + displayName: "Test machine", + description: "Disposable lifecycle test machine", + icon: "Terminal", + ephemeral: true, + async create({ key }) { + const host = getNonDestroyedHostByLaunchKey(harness.db, key); + if (host === null) throw new Error("Missing creating machine"); + seedSession(harness.deps, host.id); + return { + status: "created", + name: "Test machine", + resource: { snapshot: null }, + }; + }, + reconcileCleanup: async () => ({ status: "removed" }), + suspend, + resume, + remove, + }), + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [machine], + getMachineProvider: (id) => + id === machine.provider.id ? machine : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + const environments = defaultEnvironmentProviderRecords(); + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => environments, + getEnvironmentProvider: (id) => + environments.find((record) => record.provider.id === id), + listEnvironmentCompositions: () => [ + { + pluginId: machine.pluginId, + composition: { + id: "test-sandbox", + displayName: "Test sandbox", + description: "Prepare a workspace for this thread.", + icon: "Cloud", + machineProviderId: machine.provider.id, + environmentProviderId: "project-checkout", + }, + }, + ], + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + + const thread = await createThreadFromRequest(harness.deps, { + projectId: project.id, + environment: { + type: "provider", + environmentProviderId: "test-sandbox", + inputs: {}, + }, + input: textInput("First turn"), + providerId: "codex", + model: "requested-model", + origin: "app", + startedOnBehalfOf: null, + }); + const defaultPath = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone_default_path", + 5_000, + ); + const hostId = defaultPath.row.hostId; + expect(getHost(harness.db, hostId)).toMatchObject({ + launchKey: thread.id, + type: "ephemeral", + phase: "active", + }); + const busy = await harness.app.request( + `/api/v1/hosts/${hostId}/suspend`, + { method: "POST" }, + ); + expect(busy.status).toBe(409); + expect(await busy.json()).toMatchObject({ code: "machine_busy" }); + await reportQueuedCommandSuccess(harness, defaultPath, { path }); + const exists = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "host.paths_exist", + ); + await reportQueuedCommandSuccess(harness, exists, { + existence: { [path]: false }, + }); + const clone = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone", + ); + await reportQueuedCommandSuccess(harness, clone, { + path, + gitRemoteUrl: remoteUrl, + }); + const attach = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "environment.attach", + ); + if (attach.command.type !== "environment.attach") + throw new Error("Missing environment attach"); + const environmentId = attach.command.environmentId; + await reportQueuedCommandSuccess(harness, attach, { + path, + branchName: "main", + defaultBranch: "main", + isGitRepo: true, + isWorktree: false, + }); + const start = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "thread.start", + ); + const providerThreadId = "provider-machine-lifecycle"; + await reportQueuedCommandSuccess(harness, start, { providerThreadId }); + + async function reportEvents(events: ThreadEvent[]) { + const session = getLatestSessionForHost(harness.db, { hostId }); + if (session === null) throw new Error("Missing daemon session"); + const response = await harness.app.request( + "/internal/session/events", + { + method: "POST", + headers: internalAuthHeaders(harness, { hostId }), + body: JSON.stringify({ + sessionId: session.id, + eventGroups: groupHostDaemonEvents( + events.map((event) => + createTestDaemonEventEnvelope({ event }), + ), + ), + }), + }, + ); + expect(response.status).toBe(200); + } + + await reportEvents([ + { + type: "thread/identity", + threadId: thread.id, + providerThreadId, + scope: threadScope(), + }, + { + type: "turn/started", + threadId: thread.id, + providerThreadId, + scope: turnScope("first-turn"), + }, + ]); + expect(getThread(harness.db, thread.id)?.status).toBe("active"); + if (firstTurn === "stopped") { + const stopping = harness.app.request( + `/api/v1/threads/${thread.id}/stop`, + { method: "POST" }, + ); + const stop = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "thread.stop", + ); + await reportQueuedCommandSuccess(harness, stop, { + providerCheckpointId: null, + }); + expect((await stopping).status).toBe(200); + } else { + await reportEvents([ + { + type: "turn/completed", + threadId: thread.id, + providerThreadId, + scope: turnScope("first-turn"), + status: "completed", + }, + ]); + } + expect(getThread(harness.db, thread.id)).toMatchObject({ + status: "idle", + archivedAt: null, + environmentId, + }); + expect(getEnvironment(harness.db, environmentId)?.status).toBe("ready"); + + async function pause() { + const response = await harness.app.request( + `/api/v1/hosts/${hostId}/suspend`, + { method: "POST" }, + ); + expect(response.status).toBe(202); + await expect + .poll(() => getHost(harness.db, hostId)?.phase) + .toBe("suspended"); + expect(harness.hub.hasDaemonForHost(hostId)).toBe(false); + } + + await pause(); + await sweepMachineLifecycles(harness.deps); + expect(getHost(harness.db, hostId)?.phase).toBe("suspended"); + expect(remove).not.toHaveBeenCalled(); + const send = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: textInput("Follow-up"), + mode: "auto", + }), + }, + ); + expect(send.status).toBe(200); + await resumeStarted.promise; + try { + expect(getHost(harness.db, hostId)?.phase).toBe("resuming"); + const resuming = await harness.app.request(`/api/v1/hosts/${hostId}`); + expect(resuming.status).toBe(200); + expect(await resuming.json()).toMatchObject({ + status: "connected", + lifecycle: { phase: "resuming" }, + }); + } finally { + finishResume.resolve(); + } + const followup = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "turn.submit", + ); + expect(followup.row.hostId).toBe(hostId); + await reportQueuedCommandSuccess(harness, followup, { + appliedAs: "new-turn", + }); + await reportEvents([ + { + type: "turn/started", + threadId: thread.id, + providerThreadId, + scope: turnScope("followup-turn"), + }, + ]); + expect(getThread(harness.db, thread.id)?.status).toBe("active"); + expect(getHost(harness.db, hostId)?.phase).toBe("active"); + expect(resume).toHaveBeenCalledOnce(); + await reportEvents([ + { + type: "turn/completed", + threadId: thread.id, + providerThreadId, + scope: turnScope("followup-turn"), + status: "completed", + }, + ]); + expect(getThread(harness.db, thread.id)?.status).toBe("idle"); + expect(listQueuedThreadMessages(harness.db, thread.id)).toEqual([]); + if (archiveFrom === "suspended") await pause(); + const archive = await harness.app.request( + `/api/v1/threads/${thread.id}/archive`, + { method: "POST" }, + ); + expect(archive.status).toBe(200); + await expect + .poll(() => getHost(harness.db, hostId)?.phase) + .toBe("destroyed"); + expect(remove).toHaveBeenCalledOnce(); + expect(suspend).toHaveBeenCalledTimes( + archiveFrom === "suspended" ? 2 : 1, + ); + expect(getEnvironment(harness.db, environmentId)?.status).toBe( + "destroyed", + ); + expect(getThread(harness.db, thread.id)?.archivedAt).not.toBeNull(); + expect(getHost(harness.db, source.host.id)?.destroyedAt).toBeNull(); + }); + }, + ); +}); diff --git a/apps/server/test/threads/queue-drain-failure.test.ts b/apps/server/test/threads/queue-drain-failure.test.ts index d265748b50..47f1e96413 100644 --- a/apps/server/test/threads/queue-drain-failure.test.ts +++ b/apps/server/test/threads/queue-drain-failure.test.ts @@ -13,10 +13,7 @@ import { } from "../../src/services/plugins/plugin-hook-registry.js"; import { noteDispatchRequeued } from "../../src/services/threads/dispatch-hooks.js"; import { recordQueuedMessageDrainFailure } from "../../src/services/threads/queue-drain-failure.js"; -import { - requestQueuedMessageDispatch, - runQueuedMessageDispatch, -} from "../../src/services/threads/queued-message-dispatch.js"; +import { runQueuedMessageDispatch } from "../../src/services/threads/queued-message-dispatch.js"; import { toThreadQueuedMessage } from "../../src/services/threads/thread-queued-messages.js"; import { textInput } from "../helpers/prompt-input.js"; import { @@ -81,7 +78,7 @@ function reread(harness: TestAppHarness, queuedMessageId: string) { } describe("host-connected queue dispatch", () => { - it("releases exactly the returning machine's host-offline rows", async () => { + it("dispatches only the returning machine's rows after its daemon connects", async () => { await withTestHarness(async (harness) => { const away = seedQueuedRow(harness, { hostConnected: false, @@ -99,15 +96,25 @@ describe("host-connected queue dispatch", () => { }); } - requestQueuedMessageDispatch(harness.deps, { + await runQueuedMessageDispatch(harness.deps, { hostId: away.host.id, kind: "host-connected", }); - - // The returning machine's row is an ordinary queued row again, eligible - // at the next drain; the other machine is still away and its row still - // says so — a reconnect is one host's signal, not an amnesty. - expect(reread(harness, away.row.id).waitingOn).toBeNull(); + expect(reread(harness, away.row.id).waitingOn).toEqual({ + kind: "host-offline", + hostName: "M4", + }); + seedHostSession(harness.deps, { id: away.host.id, name: "M4" }); + seedThreadRuntimeState(harness.deps, { + environmentId: away.thread.environmentId, + providerThreadId: "returning-machine-thread", + threadId: away.thread.id, + }); + await runQueuedMessageDispatch(harness.deps, { + hostId: away.host.id, + kind: "host-connected", + }); + expect(getQueuedThreadMessage(harness.db, away.row.id)).toBeNull(); expect(reread(harness, otherAway.row.id).waitingOn).toEqual({ kind: "host-offline", hostName: "M2", diff --git a/apps/server/test/threads/requested-queue-drain.test.ts b/apps/server/test/threads/requested-queue-drain.test.ts index c5116ce61f..5d1ba17d09 100644 --- a/apps/server/test/threads/requested-queue-drain.test.ts +++ b/apps/server/test/threads/requested-queue-drain.test.ts @@ -453,6 +453,37 @@ describe("the requested queue drain", () => { }, ); + it("resumes host-offline work without releasing ordinary work paused by Stop", async () => { + await withTestHarness(async (harness) => { + const { thread, environment } = seedRunnableThread(harness, { + hostId: "host-stopped-offline", + status: "active", + }); + const ordinary = seedQueuedMessage(harness.deps, { + threadId: thread.id, + content: textInput("Work queued before Stop"), + waitingOn: { kind: "thread-busy" }, + }); + await stopThread(harness, thread.id); + seedQueuedMessage(harness.deps, { + threadId: thread.id, + content: textInput("Follow-up waiting for the machine"), + waitingOn: { kind: "host-offline", hostName: "Test Host" }, + }); + const turnsBefore = turnRequests(harness, thread.id).length; + + await runQueuedMessageDispatch(harness.deps, { + kind: "host-connected", + hostId: environment.hostId, + }); + + expect(listQueuedThreadMessages(harness.db, thread.id)).toMatchObject([ + { id: ordinary.id }, + ]); + expect(turnRequests(harness, thread.id)).toHaveLength(turnsBefore + 1); + }); + }); + it.each(["scheduled", "plugin"] as const)( "does not dispatch a %s group containing a failed row", async (drain) => { 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-recovery.test.ts b/apps/server/test/threads/thread-provisioning-recovery.test.ts index cc8562030d..7b6b6efc32 100644 --- a/apps/server/test/threads/thread-provisioning-recovery.test.ts +++ b/apps/server/test/threads/thread-provisioning-recovery.test.ts @@ -1,15 +1,26 @@ +import { intendedThreadHostId } from "../../src/services/threads/dispatch-attempt.js"; import { dispatchTurnDuringReprovision } from "../../src/services/threads/thread-turn-dispatch.js"; import { requestThreadStopForCurrentState } from "../../src/services/threads/thread-lifecycle.js"; import { runEnvironmentProvisioningSweep } from "../../src/services/system/periodic-sweeps.js"; import { eq } from "drizzle-orm"; -import { environments, getEnvironment, getThread, listEvents } from "@bb/db"; +import { + environments, + getEnvironment, + getThread, + listEvents, + setThreadStartupContext, + markThreadDeleted, +} from "@bb/db"; import { encodeClientTurnRequestIdNumber, threadScope, type ResolvedThreadExecutionOptions, } from "@bb/domain"; import { describe, expect, it } from "vitest"; -import { runThreadLifecycleSweep } from "../../src/services/system/periodic-sweeps.js"; +import { + runThreadLifecycleSweep, + runPeriodicSweeps, +} from "../../src/services/system/periodic-sweeps.js"; import { appendThreadProvisioningEvent, buildCwdBranchEntries, @@ -102,7 +113,7 @@ describe("thread provisioning recovery", () => { }); }); - it("does not record a restart error while same-process workspace-ready provisioning is still live", async () => { + it("does not fail a live start when provisioning advances again after dispatch", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps, { id: "host-live-thread-start-recovery", @@ -174,7 +185,7 @@ describe("thread provisioning recovery", () => { expect( listQueuedThreadCommands(harness, "thread.start", thread.id), ).toHaveLength(1); - expect(getThread(harness.db, thread.id)?.status).not.toBe("error"); + expect(getThread(harness.db, thread.id)?.status).toBe("starting"); expect( listEvents(harness.db, { threadId: thread.id }).map( (event) => event.type, @@ -837,3 +848,145 @@ it("waits for the host to reconnect before recovering workspace setup", async () expect(getEnvironment(harness.db, environment.id)?.status).toBe("ready"); }); }); + +it.each(["pending", "starting"] as const)( + "starts a follow-up after stopping %s setup before an environment attaches", + async (status) => { + 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, + path: "/tmp/stopped-before-attachment", + status: "ready", + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + status, + }); + const startup = { + environmentIntent: { + type: "reuse" as const, + environmentId: environment.id, + }, + fork: null, + startedOnBehalfOf: null, + titleProvided: true, + }; + if (status === "pending") { + setThreadStartupContext(harness.db, { + threadId: thread.id, + startupContext: JSON.stringify({ kind: "pending", ...startup }), + }); + } else { + requestThreadProvision(harness.deps, { + ...startup, + thread, + execution: THREAD_START_EXECUTION, + input: textInput("cancelled initial prompt"), + }); + } + const stopped = await harness.app.request( + `/api/v1/threads/${thread.id}/stop`, + { method: "POST" }, + ); + expect(stopped.status).toBe(200); + expect(getThread(harness.db, thread.id)).toMatchObject({ + status: status === "pending" ? "pending" : "idle", + environmentId: null, + }); + expect(intendedThreadHostId(harness.deps, thread.id)).toBe(host.id); + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/send`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + input: textInput("new follow-up after stop"), + mode: "auto", + model: THREAD_START_EXECUTION.model, + }), + }, + ); + expect(response.status, await response.text()).toBe(200); + const start = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "thread.start" && command.threadId === thread.id, + ); + expect(start.command).toMatchObject({ + input: textInput("new follow-up after stop"), + threadId: thread.id, + }); + expect(getThread(harness.db, thread.id)?.environmentId).toBe( + environment.id, + ); + expect( + listQueuedThreadCommands(harness, "thread.start", thread.id), + ).toHaveLength(1); + await reportQueuedCommandError(harness, start, { + errorCode: "test_cleanup", + errorMessage: "Settle pending test start", + }); + }); + }, +); + +it.each(["startup", "periodic"] as const)( + "finishes deleting an unattached thread after asynchronous cleanup during %s recovery", + async (sweep) => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + status: "starting", + }); + const other = seedThread(harness.deps, { + projectId: project.id, + status: "idle", + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + status: "error", + }); + harness.db + .update(environments) + .set({ ownerThreadId: thread.id, teardownStatus: "running" }) + .where(eq(environments.id, environment.id)) + .run(); + markThreadDeleted(harness.db, harness.hub, { threadId: thread.id }); + await (sweep === "startup" + ? runThreadLifecycleSweep(harness.deps) + : runPeriodicSweeps({ + ...harness.deps, + pluginSchedules: harness.pluginService, + plugins: harness.pluginService, + })); + expect(getThread(harness.db, thread.id)).toMatchObject({ + id: thread.id, + deletedAt: expect.any(Number), + }); + harness.db + .update(environments) + .set({ status: "destroyed", teardownStatus: "removed" }) + .where(eq(environments.id, environment.id)) + .run(); + await (sweep === "startup" + ? runThreadLifecycleSweep(harness.deps) + : runPeriodicSweeps({ + ...harness.deps, + pluginSchedules: harness.pluginService, + plugins: harness.pluginService, + })); + expect(getThread(harness.db, thread.id)).toBeNull(); + expect(getThread(harness.db, other.id)?.deletedAt).toBeNull(); + }); + }, +); 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..1734eb2846 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. @@ -410,6 +410,8 @@ registering the same provider ID cannot recover or remove its resources. Migration assigns the three bundled owners explicitly and leaves unknown historical owners unassigned, preventing automatic adoption. +Concrete providers and compositions require a nonblank description (up to 200 characters) and icon, alongside their display name. The provider listing exposes both; creation choices show the description unless setup or availability guidance takes precedence. Icons remain required when a frontend slot supplies an override. + A declaration has four eligibility facts in `requires` (`projectCheckout`, `gitCheckout`, `gitRemote`, `projectless`), all defaulted to false at registration. Optional `inputs` uses Standard Schema v1; core parses it @@ -516,8 +518,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 +569,71 @@ 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. `PluginMachineProviderResource` excludes +top-level null; null in storage means no checkpoint exists. + +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 opaque 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 durable key and discovers/removes allocations +when no checkpoint exists; remove receives known resources. 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,16 +1772,21 @@ 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). +without a frontend bundle; the remaining questions concern bundle loading cost +and rendering behavior. **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: `{ providerKind, 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. +Registrations target a provider kind and id. Omitted kinds in older plugins +normalize to all kinds; exact-kind matches take precedence over all-kind matches. +Persistent machine labels draw a laptop directly and bypass the slot. Existing +environment reuse, sidebar, and metadata rows still resolve only built-in glyphs; +those rows bypass both asset URLs and the slot. Registrations are replaced wholesale with the rest of the plugin's slot set, so disable/uninstall/failed reload falls back to `logoUrl`, then the declared glyph, then the generic glyph. No provider bb ships registers the slot: each @@ -1721,13 +1796,10 @@ every boot). **Audit before stabilizing.** -1. **Id squatting and scoping.** `providerId` names a provider in a shared - namespace, not a per-plugin slot id, and nothing checks that the plugin - registering it also declared that provider. Today the host keeps the first - claim by sorted plugin id and warns. Before stabilizing, decide whether the - host should reject an icon for a provider the plugin does not own (the - frontend does not currently know the registry's provider→plugin mapping), - and whether the picker should surface a rejected claim to the user. +1. **Scoping and overrides.** `providerKind` and `providerId` select the target; + cross-plugin icon overrides are allowed. Within each kind/id pair, the first + claim by sorted plugin id wins and later claims warn. Verify kind isolation, + specific-over-all precedence, and fallback after unload before stabilizing. 2. **Bundle size and boot ordering.** An icon now costs a frontend bundle: a provider plugin that previously shipped only a server entry pays esbuild + Tailwind on install and an extra module fetch, and the served logo covers @@ -2594,3 +2666,234 @@ 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` + +`recheck` sends a system `config-changed` notification to connected clients. +It carries no payload. Connect signals when its paired state or public URL +changes, rather than on every tunnel status publish. + +The configuration response refreshes registered providers' availability in +parallel, with a five-second deadline per check. Exceptions, invalid output, and +timeouts produce an unavailable result without exposing raw provider errors. +Machines settings, manual setup, and promptbox banners consume this same status. +Reading configuration never acquires a grant; acquisition checks its selected +provider independently, including when an existing machine retains an older +provider selection. + +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`. Core validates HTTP(S) +URLs without embedded credentials before exposing them through configuration. +Machines settings displays that URL for available providers; an available result +without a URL is valid. The acquired grant's URL still drives machine traffic. + +`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. +Core chooses the configured access provider and retains it for the machine. 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 private plugin storage; never +put credentials in machine resources, inputs, or progress output. + +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` requires argv, stdin, a timeout, an abort signal, and an output callback; it returns only an exit code. `bootstrap` requires the executor, passes the bundle through private stdin, and streams command output into progress logs. It installs pending enrollments and restarts enrolled identities after snapshot restoration. Core Manual setup uses internal enrollment operations. Bootstrap returns the reserved host ID. Installation requires Node, npm, and curl and installs no OS packages. Cancellation propagates through enrollment preparation and access acquisition. + +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, progress reporter and a cleanup signal. +This callback is only used without a resource checkpoint; remove receives known resources. 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, known-resource removal, and access release +retain failed cleanup state and retry at the core retry interval until successful. + +## Transient provider setup data + +Manual setup is built into core. 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. The field is a required boolean whenever a +checkout is present, 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, description, icon, machineProviderId, +environmentProviderId })` declares a new-machine environment option backed by +one concrete environment provider. It cannot also supply lifecycle callbacks +or inputs. Its required icon is resolved from the composition’s owning plugin; +a provider-icon slot may customize its rendering. 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..a423311894 100644 --- a/docs/cli-guide-and-skill.md +++ b/docs/cli-guide-and-skill.md @@ -14,3 +14,25 @@ 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. + +`bb machine list` enumerates persistent machines and takes `--all` to include +disposable provider sandboxes, matching the app's Show all machines reveal. +`bb updates` and `bb skill install-cli-skills` default to persistent machines +and still accept a sandbox through an explicit `--machine`. + +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/bundled-plugins/package.json b/packages/bundled-plugins/package.json index e8d9c88c68..fbe2734ad1 100644 --- a/packages/bundled-plugins/package.json +++ b/packages/bundled-plugins/package.json @@ -16,6 +16,7 @@ "bb-plugin-connect": "workspace:*", "bb-plugin-custom-instructions": "workspace:*", "bb-plugin-environment-git-worktree": "workspace:*", + "bb-plugin-environment-modal-sandbox": "workspace:*", "bb-plugin-environment-personal-workspace": "workspace:*", "bb-plugin-environment-project-checkout": "workspace:*", "bb-plugin-github": "workspace:*", 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..5c9e6fe42a 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,37 @@ 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" + | "resuming" + | "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 +89,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 +113,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 +162,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 +224,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 +235,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..976f18eb83 --- /dev/null +++ b/packages/db/src/data/machines.ts @@ -0,0 +1,94 @@ +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 + ); +} + +export function machineHasStartingThreadLaunch( + 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), + eq(threads.status, "starting"), + isNull(threads.archivedAt), + isNull(threads.deletedAt), + ), + ) + .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/queued-thread-messages.ts b/packages/db/src/data/queued-thread-messages.ts index 57a307e1b8..1e80f50c2d 100644 --- a/packages/db/src/data/queued-thread-messages.ts +++ b/packages/db/src/data/queued-thread-messages.ts @@ -1906,6 +1906,8 @@ export function listThreadIdsWithHostOfflineQueueWaits( .where( and( eq(environments.hostId, hostId), + isNull(threads.archivedAt), + isNull(threads.deletedAt), sql`json_extract(${queuedThreadMessages.waitingOn}, '$.kind') = 'host-offline'`, automaticallyDrainableQueuedThreadMessage(), ), 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/data/threads.ts b/packages/db/src/data/threads.ts index a7252ce1a8..a71ba5aa6b 100644 --- a/packages/db/src/data/threads.ts +++ b/packages/db/src/data/threads.ts @@ -2074,7 +2074,12 @@ export function applyThreadLifecycleEventInTransaction( status: evaluation.to, updatedAt: now, }; - if (evaluation.to === "active" || evaluation.to === "idle") set.startupContext = null; + if ( + evaluation.to === "active" || + (evaluation.to === "idle" && thread.environmentId !== null) + ) { + set.startupContext = null; + } if ( statusTransitionNeedsAttention({ currentStatus: thread.status, 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..2307ea4127 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,37 @@ 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" + | "resuming" + | "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 +134,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 +451,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 +974,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 +1099,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..f5abc595f0 --- /dev/null +++ b/packages/db/test/data/machines.test.ts @@ -0,0 +1,69 @@ +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, + machineHasStartingThreadLaunch, +} 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, threads } 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("distinguishes starting launches from live threads retained until archive", () => { + 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); + expect(machineHasStartingThreadLaunch(db, host.id)).toBe(true); + for (const status of ["active", "idle", "error"] as const) { + db.update(threads) + .set({ status }) + .where(eq(threads.id, thread.id)) + .run(); + expect(machineHasStartingThreadLaunch(db, host.id)).toBe(false); + expect(machineHasLiveThreadLaunch(db, host.id)).toBe(true); + } + archiveThread(db, noopNotifier, thread.id); + expect(machineHasLiveThreadLaunch(db, host.id)).toBe(false); + expect(machineHasStartingThreadLaunch(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..17b084a03f 100644 --- a/packages/domain/src/host.ts +++ b/packages/domain/src/host.ts @@ -1,18 +1,38 @@ 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", + "resuming", + "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..936de0e3cb --- /dev/null +++ b/packages/domain/test/host.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { hostSchema, machineLifecycleSchema } 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); + }); + + it("exposes every durable machine lifecycle phase", () => { + expect(machineLifecycleSchema.shape.phase.options).toEqual([ + "creating", + "active", + "suspending", + "suspended", + "resuming", + "removing", + "destroyed", + ]); + }); +}); 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..42f40e2f8b 100644 --- a/packages/plugin-api-map/src/surfaces.ts +++ b/packages/plugin-api-map/src/surfaces.ts @@ -397,7 +397,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ bullets: [ "Appear in the model picker beside bb's built-in providers", "Declare what the provider supports, then serve its model list at runtime", - "Supply a small icon that appears next to its name", + "Supply a small icon that appears next to its name; React icon overrides require providerKind and providerId", "Receive every message in a thread started with it, through a bridge process the plugin ships", "Contribute validated environment variables to any provider for each session and turn", ], @@ -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", ], @@ -731,13 +734,14 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ summary: "Offers plugin-provisioned places a thread can run, picked like any environment. With this, a plugin can:", bullets: [ - "Declare a provider with a display name and icon, picked in New Thread or bb thread spawn --environment-provider", - "Use a host glyph, plugin-relative asset, declared icon, or React provider icon slot", + "Declare a provider with a required display name, description, and icon, picked in New Thread or bb thread spawn --environment-provider", + "Use a host glyph, plugin-relative asset, declared icon, or React provider icon slot targeted by required providerKind and providerId", "Declare the project facts it consumes in one place — requires.projectCheckout, requires.gitCheckout, requires.gitRemote, requires.projectless — which structurally decides where the picker offers it", "Answer availability for a project and machine with available, setup-required, or unavailable; core probes connected machines in the background so pickers hide unsupported ones, caches the answer, and checks it afresh for the selected machine at thread creation", "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 an explicit display name, description, icon, 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,96 @@ 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 automatic retirement for thread-created ephemeral machines; standalone machines remain until explicit removal. Core parses Standard Schema inputs and checks availability and validation 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 required MachineExecutor; core Manual setup uses internal enrollment operations; 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 by durable key when no checkpoint exists; remove receives known resources; 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, starting 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 required readable machine name from create", + "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", + "PluginMachineProviderResource", + "PluginMachineProviderRemoveResult", + ], + firstParty: ["Modal sandbox"], + 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; refreshed configuration checks availability for Machines settings, manual setup and creation banners", + "Return { id, serverUrl, headers? }; machines attach headers to all server requests without provider-specific redemption", + "Choose a General default; core retains the selection for each 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", + ], + firstParty: ["Connect"], + experimental: true, + }, { id: "host-workers", tagline: "Run code on enrolled machines", @@ -818,6 +914,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 +941,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..2bef004524 100644 --- a/packages/plugin-api-map/test/surfaces.test.ts +++ b/packages/plugin-api-map/test/surfaces.test.ts @@ -26,11 +26,6 @@ function surfaceIds(groupId: string): string[] { } describe("product-map surfaces", () => { - it("describes environment selections using only existing enrolled machines", () => { - const surface = JSON.stringify(SURFACES_BY_ID.get("environment-providers")); - expect(surface).toContain("existing enrolled machine"); - expect(surface).not.toContain("newly provider-created machine"); - }); it("keeps app-window annotations in column-major visual reading order", () => { const ordered = [ "sidebar-navigation", @@ -208,6 +203,31 @@ describe("surface card copy", () => { expect(eventCopy).toContain("cancelled before dispatch"); }); + 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", + "PluginMachineProviderLifecycleContext", + "PluginMachineProviderResource", + "PluginMachineProviderInputsProps", + "PluginMachineProviderInputsChange", + "PluginMachineProviderInputsRegistration", + ]), + ); + expect(SURFACES_BY_ID.get("server-access")?.apiSymbols).toEqual( + expect.arrayContaining([ + "PluginServerAccess", + "ServerAccessProviderDeclaration", + "ServerAccessGrant", + ]), + ); + }); + 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..6a0eb80778 100644 --- a/packages/plugin-build/src/builtin-server-artifacts.test.ts +++ b/packages/plugin-build/src/builtin-server-artifacts.test.ts @@ -1,5 +1,5 @@ -import { cp, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { cp, mkdtemp, readFile, rm, symlink } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { buildPluginServer } from "./build-plugin-server.js"; @@ -73,21 +73,19 @@ 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.endsWith(".ts") || - fileName.endsWith(".svg"), - ); - for (const fileName of fileNames) { - await cp(join(source, fileName), join(root, fileName)); - } + + await cp(source, root, { + recursive: true, + filter: (path) => + !["node_modules", "dist", ".bundled-runtime"].includes(basename(path)), + }); await symlink( join(source, "node_modules"), join(root, "node_modules"), @@ -98,6 +96,15 @@ describe("builtin server artifacts", () => { ); const built = await buildPluginServer(root, "0.9.0-test", toolchain); + if (pluginDir === "environment-modal-sandbox") { + 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-registry/r/icon.json b/packages/plugin-registry/r/icon.json index f602664fc9..270c9049f5 100644 --- a/packages/plugin-registry/r/icon.json +++ b/packages/plugin-registry/r/icon.json @@ -16,7 +16,7 @@ "files": [ { "path": "registry/components/ui/icon.tsx", - "content": "import type { CSSProperties } from \"react\";\nimport { HugeiconsIcon, type IconSvgElement } from \"@hugeicons/react\";\nimport {\n Alert02Icon,\n AlertCircleIcon,\n Archive03Icon,\n ArrowDown01Icon,\n ArrowLeft01Icon,\n ArrowRight01Icon,\n BotIcon,\n BubbleChatAddIcon,\n BubbleChatIcon,\n Bug01Icon,\n Cancel01Icon,\n CancelCircleIcon,\n CheckListIcon,\n CheckmarkCircle02Icon,\n CircleIcon,\n ComputerTerminal01Icon,\n Copy01Icon,\n DashedLineCircleIcon,\n Delete02Icon,\n Download01Icon,\n Edit02Icon,\n FolderAddIcon,\n FolderExportIcon,\n FolderGitTwoIcon,\n Folder02Icon,\n FolderIcon,\n FolderSyncIcon,\n HelpCircleIcon,\n InformationCircleIcon,\n Loading03Icon,\n MessageQuestionIcon,\n MoreHorizontalIcon,\n Search01Icon,\n Settings01Icon,\n SidebarLeftIcon,\n SlidersHorizontalIcon,\n SourceCodeIcon,\n Target02Icon,\n Tick02Icon,\n ToolboxIcon,\n ToolCaseIcon,\n UserAdd01Icon,\n WorkflowCircle03Icon,\n ZapIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { useSyncExternalStore } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n EXTENDED_ICON_NAMES,\n type ExtendedIconName,\n getExtendedIcons,\n subscribeExtendedIcons,\n} from \"./icon-registry\";\n\nconst SectionAddStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M2 3.4C2 2.24173 2.24173 2 3.4 2H20.6C21.7583 2 22 2.24173 22 3.4V4.6C22 5.75827 21.7583 6 20.6 6H3.4C2.24173 6 2 5.75827 2 4.6V3.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 11.4C2 10.2417 2.24173 10 3.4 10H10.6C11.7583 10 12 10.2417 12 11.4V12.6C12 13.7583 11.7583 14 10.6 14H3.4C2.24173 14 2 13.7583 2 12.6V11.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 19.4C2 18.2417 2.24173 18 3.4 18H10.6C11.7583 18 12 18.2417 12 19.4V20.6C12 21.7583 11.7583 22 10.6 22H3.4C2.24173 22 2 21.7583 2 20.6V19.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18 13V21M22 17H14\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n];\n\nconst CORE_ICON_MAP = {\n AlertCircle: AlertCircleIcon,\n AlertTriangle: Alert02Icon,\n Archive: Archive03Icon,\n Bot: BotIcon,\n Bug: Bug01Icon,\n Check: Tick02Icon,\n ChevronDown: ArrowDown01Icon,\n ChevronLeft: ArrowLeft01Icon,\n ChevronRight: ArrowRight01Icon,\n Circle: CircleIcon,\n CircleCheck: CheckmarkCircle02Icon,\n CircleQuestion: HelpCircleIcon,\n CircleX: CancelCircleIcon,\n ClosePluginPane: Cancel01Icon,\n CloseThreadPane: Cancel01Icon,\n Code: SourceCodeIcon,\n ComputerTerminal01: ComputerTerminal01Icon,\n Copy: Copy01Icon,\n Download: Download01Icon,\n Edit: Edit02Icon,\n Folder: FolderIcon,\n FolderExport: FolderExportIcon,\n FolderGit: FolderGitTwoIcon,\n FolderPlus: FolderAddIcon,\n FolderSync: FolderSyncIcon,\n Folder02: Folder02Icon,\n Info: InformationCircleIcon,\n ListTodo: CheckListIcon,\n Loading: Loading03Icon,\n MessageQuestion: MessageQuestionIcon,\n MessageCirclePlus: BubbleChatAddIcon,\n MessageSquarePlus: BubbleChatAddIcon,\n MessageSquare: BubbleChatIcon,\n MoreHorizontal: MoreHorizontalIcon,\n PanelLeft: SidebarLeftIcon,\n Search: Search01Icon,\n SectionAdd: SectionAddStrokeRoundedIcon,\n Settings: Settings01Icon,\n SlidersHorizontal: SlidersHorizontalIcon,\n Spinner: DashedLineCircleIcon,\n Target: Target02Icon,\n Terminal: ComputerTerminal01Icon,\n Toolbox: ToolboxIcon,\n ToolCase: ToolCaseIcon,\n Trash2: Delete02Icon,\n UserRoundPlus: UserAdd01Icon,\n Workflow: WorkflowCircle03Icon,\n X: Cancel01Icon,\n Zap: ZapIcon,\n} as const satisfies Record;\n\ntype CoreIconName = keyof typeof CORE_ICON_MAP;\n\nexport type IconName = CoreIconName | ExtendedIconName;\n\nconst CORE_ICON_NAMES = Object.keys(CORE_ICON_MAP) as readonly CoreIconName[];\n\nexport const ICON_NAMES: readonly IconName[] = [\n ...CORE_ICON_NAMES,\n ...EXTENDED_ICON_NAMES,\n];\n\nconst CORE_ICON_LOOKUP: Readonly> =\n CORE_ICON_MAP;\n\nlet extendedIconsLoad: Promise | null = null;\n\nexport function preloadExtendedIcons(): Promise {\n if (getExtendedIcons() !== null) return Promise.resolve();\n extendedIconsLoad ??= import(\"./icon-extended\").then(\n () => undefined,\n (error: unknown) => {\n extendedIconsLoad = null;\n throw error;\n },\n );\n return extendedIconsLoad;\n}\n\nconst EMPTY_ICON: IconSvgElement = [];\n\nexport interface IconProps {\n name: IconName;\n className?: string;\n style?: CSSProperties;\n \"aria-hidden\"?: boolean | \"true\" | \"false\";\n \"aria-label\"?: string;\n}\n\nexport function Icon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n const coreIcon = CORE_ICON_LOOKUP[name];\n if (coreIcon !== undefined) {\n return (\n \n );\n }\n return (\n \n );\n}\n\nfunction ExtendedIcon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n const extendedIcons: Readonly<\n Record\n > | null = useSyncExternalStore(\n subscribeExtendedIcons,\n getExtendedIcons,\n getExtendedIcons,\n );\n const icon = extendedIcons?.[name];\n if (icon === undefined) {\n void preloadExtendedIcons().catch(() => undefined);\n }\n return (\n \n );\n}\n", + "content": "import type { CSSProperties } from \"react\";\nimport { HugeiconsIcon, type IconSvgElement } from \"@hugeicons/react\";\nimport {\n Alert02Icon,\n AlertCircleIcon,\n Archive03Icon,\n ArrowDown01Icon,\n ArrowLeft01Icon,\n ArrowRight01Icon,\n BotIcon,\n BubbleChatAddIcon,\n BubbleChatIcon,\n Bug01Icon,\n Cancel01Icon,\n CancelCircleIcon,\n CheckListIcon,\n CheckmarkCircle02Icon,\n CircleIcon,\n ComputerTerminal01Icon,\n Copy01Icon,\n DashedLineCircleIcon,\n Delete02Icon,\n Download01Icon,\n Edit02Icon,\n FolderAddIcon,\n FolderExportIcon,\n FolderGitTwoIcon,\n Folder02Icon,\n FolderIcon,\n FolderSyncIcon,\n FolderUnknownIcon,\n HelpCircleIcon,\n InformationCircleIcon,\n Loading03Icon,\n MessageQuestionIcon,\n MoreHorizontalIcon,\n Search01Icon,\n Settings01Icon,\n SidebarLeftIcon,\n SlidersHorizontalIcon,\n SourceCodeIcon,\n Target02Icon,\n Tick02Icon,\n ToolboxIcon,\n ToolCaseIcon,\n UserAdd01Icon,\n WorkflowCircle03Icon,\n ZapIcon,\n} from \"@hugeicons/core-free-icons\";\nimport { useSyncExternalStore } from \"react\";\nimport { cn } from \"../../lib/utils\";\nimport {\n EXTENDED_ICON_NAMES,\n type ExtendedIconName,\n getExtendedIcons,\n subscribeExtendedIcons,\n} from \"./icon-registry\";\n\nconst SectionAddStrokeRoundedIcon: IconSvgElement = [\n [\n \"path\",\n {\n d: \"M2 3.4C2 2.24173 2.24173 2 3.4 2H20.6C21.7583 2 22 2.24173 22 3.4V4.6C22 5.75827 21.7583 6 20.6 6H3.4C2.24173 6 2 5.75827 2 4.6V3.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"0\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 11.4C2 10.2417 2.24173 10 3.4 10H10.6C11.7583 10 12 10.2417 12 11.4V12.6C12 13.7583 11.7583 14 10.6 14H3.4C2.24173 14 2 13.7583 2 12.6V11.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"1\",\n },\n ],\n [\n \"path\",\n {\n d: \"M2 19.4C2 18.2417 2.24173 18 3.4 18H10.6C11.7583 18 12 18.2417 12 19.4V20.6C12 21.7583 11.7583 22 10.6 22H3.4C2.24173 22 2 21.7583 2 20.6V19.4Z\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"2\",\n },\n ],\n [\n \"path\",\n {\n d: \"M18 13V21M22 17H14\",\n stroke: \"currentColor\",\n strokeLinecap: \"round\",\n strokeWidth: \"1.5\",\n key: \"3\",\n },\n ],\n];\n\nconst CORE_ICON_MAP = {\n AlertCircle: AlertCircleIcon,\n AlertTriangle: Alert02Icon,\n Archive: Archive03Icon,\n Bot: BotIcon,\n Bug: Bug01Icon,\n Check: Tick02Icon,\n ChevronDown: ArrowDown01Icon,\n ChevronLeft: ArrowLeft01Icon,\n ChevronRight: ArrowRight01Icon,\n Circle: CircleIcon,\n CircleCheck: CheckmarkCircle02Icon,\n CircleQuestion: HelpCircleIcon,\n CircleX: CancelCircleIcon,\n ClosePluginPane: Cancel01Icon,\n CloseThreadPane: Cancel01Icon,\n Code: SourceCodeIcon,\n ComputerTerminal01: ComputerTerminal01Icon,\n Copy: Copy01Icon,\n Download: Download01Icon,\n Edit: Edit02Icon,\n Folder: FolderIcon,\n FolderExport: FolderExportIcon,\n FolderGit: FolderGitTwoIcon,\n FolderPlus: FolderAddIcon,\n FolderSync: FolderSyncIcon,\n FolderUnknown: FolderUnknownIcon,\n Folder02: Folder02Icon,\n Info: InformationCircleIcon,\n ListTodo: CheckListIcon,\n Loading: Loading03Icon,\n MessageQuestion: MessageQuestionIcon,\n MessageCirclePlus: BubbleChatAddIcon,\n MessageSquarePlus: BubbleChatAddIcon,\n MessageSquare: BubbleChatIcon,\n MoreHorizontal: MoreHorizontalIcon,\n PanelLeft: SidebarLeftIcon,\n Search: Search01Icon,\n SectionAdd: SectionAddStrokeRoundedIcon,\n Settings: Settings01Icon,\n SlidersHorizontal: SlidersHorizontalIcon,\n Spinner: DashedLineCircleIcon,\n Target: Target02Icon,\n Terminal: ComputerTerminal01Icon,\n Toolbox: ToolboxIcon,\n ToolCase: ToolCaseIcon,\n Trash2: Delete02Icon,\n UserRoundPlus: UserAdd01Icon,\n Workflow: WorkflowCircle03Icon,\n X: Cancel01Icon,\n Zap: ZapIcon,\n} as const satisfies Record;\n\ntype CoreIconName = keyof typeof CORE_ICON_MAP;\n\nexport type IconName = CoreIconName | ExtendedIconName;\n\nconst CORE_ICON_NAMES = Object.keys(CORE_ICON_MAP) as readonly CoreIconName[];\n\nexport const ICON_NAMES: readonly IconName[] = [\n ...CORE_ICON_NAMES,\n ...EXTENDED_ICON_NAMES,\n];\n\nconst CORE_ICON_LOOKUP: Readonly> =\n CORE_ICON_MAP;\n\nlet extendedIconsLoad: Promise | null = null;\n\nexport function preloadExtendedIcons(): Promise {\n if (getExtendedIcons() !== null) return Promise.resolve();\n extendedIconsLoad ??= import(\"./icon-extended\").then(\n () => undefined,\n (error: unknown) => {\n extendedIconsLoad = null;\n throw error;\n },\n );\n return extendedIconsLoad;\n}\n\nconst EMPTY_ICON: IconSvgElement = [];\n\nexport interface IconProps {\n name: IconName;\n className?: string;\n style?: CSSProperties;\n \"aria-hidden\"?: boolean | \"true\" | \"false\";\n \"aria-label\"?: string;\n}\n\nexport function Icon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n const coreIcon = CORE_ICON_LOOKUP[name];\n if (coreIcon !== undefined) {\n return (\n \n );\n }\n return (\n \n );\n}\n\nfunction ExtendedIcon({\n name,\n className,\n style,\n \"aria-hidden\": ariaHidden,\n \"aria-label\": ariaLabel,\n}: IconProps) {\n const extendedIcons: Readonly<\n Record\n > | null = useSyncExternalStore(\n subscribeExtendedIcons,\n getExtendedIcons,\n getExtendedIcons,\n );\n const icon = extendedIcons?.[name];\n if (icon === undefined) {\n void preloadExtendedIcons().catch(() => undefined);\n }\n return (\n \n );\n}\n", "type": "registry:ui", "target": "components/ui/icon.tsx" } 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__/environment-provider-policy.test.ts b/packages/plugin-sdk/src/__tests__/environment-provider-policy.test.ts index 6fa12e5c77..1f406c0406 100644 --- a/packages/plugin-sdk/src/__tests__/environment-provider-policy.test.ts +++ b/packages/plugin-sdk/src/__tests__/environment-provider-policy.test.ts @@ -4,6 +4,8 @@ import { validatePluginEnvironmentProviderDeclaration } from "../internal/host-p const declaration = { id: "test-provider", displayName: "Test provider", + description: "Prepare a workspace for this thread.", + icon: "Folder", create: async () => ({ status: "created", path: "/tmp/test", 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..e40ea06636 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -1,6 +1,11 @@ import { readFile } from "node:fs/promises"; import { describe, expect, expectTypeOf, it } from "vitest"; -import type { BbPluginApi } from "../index.js"; +import type { + BbPluginApi, + PluginEnvironmentProviderDeclaration, + PluginEnvironments, +} from "../index.js"; +import type { PluginProviderIconRegistration } from "../app-contract.js"; type ExpectedBbPluginApiKey = | "agents" @@ -10,6 +15,8 @@ type ExpectedBbPluginApiKey = | "experimental_aiServices" | "experimental_environments" | "experimental_hooks" + | "experimental_machines" + | "experimental_serverAccess" | "hosts" | "http" | "log" @@ -77,6 +84,9 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginInteractionResult", "PluginKvStorage", "PluginLogger", + "PluginMachineProviderDeclaration", + "PluginMachineValidateDecision", + "PluginMachines", "PluginMentionItem", "PluginMentionProviderRegistration", "PluginMentionSearchContext", @@ -117,6 +127,9 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginThreadEventPayloads", "PluginTurnFailedEvent", "PluginUi", + "PluginServerAccess", + "ServerAccessGrant", + "ServerAccessProviderDeclaration", ] as const; const EXPECTED_BACKEND_ROOT_VALUE_EXPORTS = [ @@ -279,3 +292,23 @@ describe("backend plugin SDK public surface", () => { } }); }); + +it("requires provider presentation fields in author-facing declarations", () => { + expectTypeOf< + Pick + >().toEqualTypeOf<{ + providerKind: "agent" | "machine" | "environment"; + }>(); + expectTypeOf< + Pick + >().toEqualTypeOf<{ + description: string; + icon: string; + }>(); + expectTypeOf< + Pick[0], "description" | "icon"> + >().toEqualTypeOf<{ + description: string; + icon: string; + }>(); +}); diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index ad217a9952..3b21034506 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1304,20 +1304,21 @@ export interface PluginCommandPaletteActionRegistration { } /** - * Supply the inline React mark bb draws for one agent provider. + * Supply an inline React mark for a provider. Agent, machine, and environment + * icon renderers select the mark by provider kind and id. + * Only surfaces using the provider icon renderer consult this slot. Persistent + * machine labels use a laptop glyph directly. * - * A manifest `branding.icon` (or a provider's `logoUrl`) is fetched and drawn - * through ``, a separate document where `currentColor` resolves to black - * — invisible on dark themes and unreachable from app CSS. A component is - * rendered inline, so it inherits the app's theme colors and the host's sizing - * classes. Register a static color logo as a file and a theme-aware mark here. + * Provider logo assets use a currentColor mask. Inline components can also + * render multiple colors and inherit the app's theme and sizing classes. * * The host passes only `className` (sizing plus the provider's color class); * the component must render an inline SVG (or other inline markup) and must - * not fetch. One registration per provider id per plugin; when two plugins - * claim the same provider id the host keeps the first by plugin id and warns. + * not fetch. One registration per provider kind and id per plugin; when two + * plugins claim the same pair the host keeps the first by plugin id and warns. */ export interface PluginProviderIconRegistration { + providerKind: "agent" | "machine" | "environment"; /** * The provider this mark is for — the id bb knows the provider by (the * provider declaration's id, e.g. `codex` or `acp-cursor`), not the plugin @@ -1422,10 +1423,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 +1458,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,8 +1561,8 @@ export interface PluginAppSlots { registration: PluginCommandPaletteActionRegistration, ): void; /** - * Draw one agent or environment provider's icon with an inline - * React component instead of its ``-rendered logo file (see + * Draw one agent, environment, or machine provider's icon with an inline + * React component instead of its masked logo asset (see * {@link PluginProviderIconRegistration}). Experimental: see * docs/api_to_audit.md. */ @@ -1559,6 +1585,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..99482eea14 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,18 @@ export interface PluginEnvironments { import("./environment-provider.js").PluginEnvironmentProviderInputsSchema = undefined, >( - declaration: PluginEnvironmentProviderDeclaration, + declaration: + | PluginEnvironmentProviderDeclaration + | { + id: string; + displayName: string; + description: 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 +425,70 @@ 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 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; + /** + * Notify connected clients that server access configuration changed. + * Call when access is gained or lost. Clients reload system configuration, + * which re-checks availability for Machines settings and creation banners. + */ + 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 +500,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 +1611,6 @@ export interface ExperimentalPluginProviderEnvEntry { name: string; value: string | { serverPath: string }; reason: string; - secret: boolean; } export interface ExperimentalPluginProviderEnvHealthContext { @@ -1794,6 +1880,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..e5f6ffeb78 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; @@ -111,8 +111,10 @@ export interface PluginEnvironmentProviderDefinition< > { id: string; displayName: string; + /** Short explanation shown in environment choices. */ + description: string; /** Host glyph, plugin-relative icon path, or this plugin’s declared namespaced icon. */ - icon?: string; + icon: string; requires?: R; inputs?: S; policy?: Partial; 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..620674d188 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -24,6 +24,8 @@ import type { PluginHookHandler, PluginHookName, PluginMentionTrigger, + PluginMachineProviderDeclaration, + ServerAccessProviderDeclaration, PluginProviderCapabilities, PluginProviderComposerAction, PluginProviderDeclaration, @@ -108,7 +110,6 @@ const pluginProviderEnvEntrySchema = z z.object({ serverPath: z.string().startsWith("/") }).strict(), ]), reason: z.string(), - secret: z.boolean(), }) .strict(); @@ -2232,6 +2233,7 @@ export function pluginHookAlreadyRegisteredMessage( export const ENVIRONMENT_PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/; export const ENVIRONMENT_PROVIDER_DISPLAY_NAME_MAX_CHARS = 80; +export const ENVIRONMENT_PROVIDER_DESCRIPTION_MAX_CHARS = 200; export const ENVIRONMENT_PROVIDER_REQUIREMENT_NAMES = [ "projectCheckout", @@ -2244,9 +2246,44 @@ 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), + description: z + .string() + .trim() + .min(1) + .max(ENVIRONMENT_PROVIDER_DESCRIPTION_MAX_CHARS) + .optional() + .transform((value) => value ?? null), + icon: z + .string() + .trim() + .min(1) + .transform((icon) => + isPluginOwnedIconPath(icon) + ? validateProviderRelativePath(icon, "Composition icon") + : icon, + ) + .optional() + .transform((value) => value ?? null), + 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; + description: string | null; icon: string | null; requires: NormalizedPluginEnvironmentProviderRequirements; inputs: StandardSchemaV1 | null; @@ -2286,20 +2323,27 @@ export function validatePluginEnvironmentProviderDeclaration( `environment provider "${id}" needs a displayName of 1-${ENVIRONMENT_PROVIDER_DISPLAY_NAME_MAX_CHARS} characters`, ); } - const icon = - declaration.icon === undefined - ? null - : z.string().min(1).parse(declaration.icon).trim(); - if (icon !== null) { - if (isPluginOwnedIconPath(icon)) - validateProviderRelativePath(icon, `"${id}" icon`); - else if (!isNamespacedGlyph(icon) && /[/\\]/u.test(icon)) - throw new Error( - `environment provider "${id}" icon must be a glyph, declared icon, or plugin-relative path`, - ); - } - if (icon !== null && icon.length === 0) { - throw new Error(`environment provider "${id}" declares an empty icon`); + const description = z + .string() + .trim() + .min(1) + .max(ENVIRONMENT_PROVIDER_DESCRIPTION_MAX_CHARS) + .optional() + .transform((value) => value ?? null) + .parse(declaration.description); + const icon = z + .string() + .trim() + .min(1) + .optional() + .transform((value) => value ?? null) + .parse(declaration.icon); + if (icon !== null && isPluginOwnedIconPath(icon)) { + validateProviderRelativePath(icon, `"${id}" icon`); + } else if (icon !== null && !isNamespacedGlyph(icon) && /[/\\]/u.test(icon)) { + throw new Error( + `environment provider "${id}" icon must be a glyph, declared icon, or plugin-relative path`, + ); } const requires = normalizeEnvironmentProviderRequirements(id, declaration); const inputs = normalizeEnvironmentProviderInputs(id, declaration); @@ -2330,6 +2374,7 @@ export function validatePluginEnvironmentProviderDeclaration( return { id, displayName, + description, icon, requires, inputs: inputs === null ? null : inputs.schema, @@ -2419,4 +2464,166 @@ 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 }; +} + +export function validateServerAccessProviderDeclaration( + declaration: ServerAccessProviderDeclaration, +): ServerAccessProviderDeclaration { + if (typeof declaration !== "object" || declaration === null) + throw new Error("Invalid server access provider declaration"); + if ( + typeof declaration.id !== "string" || + !/^[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"); + return declaration; +} diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 99b1886426..cfc1b69237 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, @@ -281,6 +282,13 @@ function rejectStaleNavPanelKeys(kind: string, registration: object): void { } } +export type CollectedPluginProviderIconRegistration = Omit< + PluginProviderIconRegistration, + "providerKind" +> & { + providerKind: PluginProviderIconRegistration["providerKind"] | "all"; +}; + /** Validated registrations produced by one plugin app setup execution. */ export interface CollectedPluginAppRegistrations { homepageSections: PluginHomepageSectionRegistration[]; @@ -302,9 +310,10 @@ export interface CollectedPluginAppRegistrations { messageDirectives: PluginMessageDirectiveRegistration[]; messageActions: PluginMessageActionRegistration[]; commandPaletteActions: PluginCommandPaletteActionRegistration[]; - providerIcons: PluginProviderIconRegistration[]; + providerIcons: CollectedPluginProviderIconRegistration[]; timelineRenderers: PluginTimelineRendererRegistration[]; environmentProviderInputs: PluginEnvironmentProviderInputsRegistration[]; + machineProviderInputs: PluginMachineProviderInputsRegistration[]; contentScripts: PluginContentScriptRegistration[]; } @@ -354,6 +363,7 @@ export function collectPluginAppRegistrations( providerIcons: [], timelineRenderers: [], environmentProviderInputs: [], + machineProviderInputs: [], contentScripts: [], }; sidebarFooterItemsByRegistrationSet.set(collected, sidebarFooterItems); @@ -379,6 +389,7 @@ export function collectPluginAppRegistrations( providerIcon: new Set(), timelineRenderer: new Set(), environmentProviderInputs: new Set(), + machineProviderInputs: new Set(), contentScript: new Set(), }; @@ -771,8 +782,23 @@ export function collectPluginAppRegistrations( experimental_providerIcon(registration) { const kind = "slots.experimental_providerIcon"; const providerId = requireProviderId(kind, registration?.providerId); - requireUniqueId(kind, seenIds.providerIcon, providerId); + const declaredKind = registration.providerKind; + if ( + declaredKind !== undefined && + declaredKind !== "agent" && + declaredKind !== "machine" && + declaredKind !== "environment" + ) { + throw new Error(`${kind}: invalid providerKind`); + } + const providerKind = declaredKind ?? "all"; + requireUniqueId( + kind, + seenIds.providerIcon, + `${providerKind}:${providerId}`, + ); collected.providerIcons.push({ + providerKind, providerId, icon: requireComponent(kind, registration.icon), }); @@ -802,6 +828,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..7131868d14 --- /dev/null +++ b/packages/plugin-sdk/src/machine-bootstrap.ts @@ -0,0 +1,24 @@ +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 }>; +} + +export interface MachineBootstrapRequest { + key: string; + 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..a2f5161eae --- /dev/null +++ b/packages/plugin-sdk/src/machine-provider.ts @@ -0,0 +1,106 @@ +import type { + JsonValue, + PluginMachineValidateDecision, + StandardSchemaV1, + StandardSchemaV1InferOutput, +} from "@get-bb/plugin-sdk"; + +export type PluginMachineProviderResource = Exclude; + +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: PluginMachineProviderResource): 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: PluginMachineProviderResource } + | { status: "failed"; message: string }; + +type PluginMachineProviderResourceLifecycleContext = + PluginMachineProviderLifecycleContext & { + hostId: string; + resource: PluginMachineProviderResource; + }; + +type PluginMachineProviderRemoveContext = Omit< + PluginMachineProviderResourceLifecycleContext, + "checkpoint" +>; + +export interface PluginMachineProviderResourceResult { + resource: PluginMachineProviderResource; +} + +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; + 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__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index 578425b096..c2038aa9a5 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -1233,10 +1233,56 @@ describe("loadPluginApp", () => { ).rejects.toThrow('slots.messageAction: duplicate id "dup"'); }); + it("collects separate provider kinds and the legacy all-kinds registration", async () => { + const captured = await loadPluginApp( + definePluginApp((builder) => { + for (const providerKind of [ + "agent", + "machine", + "environment", + ] as const) { + builder.slots.experimental_providerIcon({ + providerKind, + providerId: "shared", + icon: () => null, + }); + } + // @ts-expect-error legacy plugin declaration + builder.slots.experimental_providerIcon({ + providerId: "shared", + icon: () => null, + }); + }), + ); + expect( + captured.providerIcons.map(({ providerKind }) => providerKind), + ).toEqual(["agent", "machine", "environment", "all"]); + }); + + it.each([null, "all", "unknown", 7])( + "rejects an explicit invalid provider kind %j", + async (providerKind) => { + await expect( + loadPluginApp( + definePluginApp((builder) => { + const registration = { + providerKind: "agent" as const, + providerId: "shared", + icon: () => null, + }; + Reflect.set(registration, "providerKind", providerKind); + builder.slots.experimental_providerIcon(registration); + }), + ), + ).rejects.toThrow("providerKind"); + }, + ); + it("validates experimental_providerIcon registrations like the host", async () => { const captured = await loadPluginApp( definePluginApp((builder) => { builder.slots.experimental_providerIcon({ + providerKind: "agent", providerId: "acp-cursor", icon: () => null, }); @@ -1248,6 +1294,7 @@ describe("loadPluginApp", () => { loadPluginApp( definePluginApp((builder) => { builder.slots.experimental_providerIcon({ + providerKind: "agent", providerId: "bb-plugin-x/codex", icon: () => null, }); @@ -1260,16 +1307,20 @@ describe("loadPluginApp", () => { loadPluginApp( definePluginApp((builder) => { builder.slots.experimental_providerIcon({ + providerKind: "agent", providerId: "codex", icon: () => null, }); builder.slots.experimental_providerIcon({ + providerKind: "agent", providerId: "codex", icon: () => null, }); }), ), - ).rejects.toThrow('slots.experimental_providerIcon: duplicate id "codex"'); + ).rejects.toThrow( + 'slots.experimental_providerIcon: duplicate id "agent:codex"', + ); }); it("invokes a captured messageAction run with a plugin-authored context", () => { 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..7e41cba7e0 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,117 @@ describe("experimental_aiServices.register", () => { }); describe("environment targets", () => { + it("accepts legacy environment declarations without presentation fields", () => { + const { bb, harness } = createFakePluginHost(); + bb.experimental_environments.register( + // @ts-expect-error legacy plugin declaration + { + id: "legacy-workspace", + displayName: "Legacy workspace", + create: async () => ({ + status: "created", + path: "/workspace", + ownsPath: true, + }), + remove: async () => ({ status: "removed" }), + }, + ); + bb.experimental_environments.register( + // @ts-expect-error legacy plugin declaration + { + id: "legacy-composition", + displayName: "Legacy composition", + machineProviderId: "cloud-machine", + environmentProviderId: "legacy-workspace", + }, + ); + expect( + harness.registrations.environmentProviders.get("legacy-workspace"), + ).toMatchObject({ description: null, icon: null }); + expect( + harness.registrations.environmentCompositions.get("legacy-composition"), + ).toMatchObject({ description: null, icon: null }); + }); + + it.each([ + ["description", null], + ["description", ""], + ["description", " "], + ["description", "x".repeat(201)], + ["icon", null], + ["icon", ""], + ["icon", " "], + ])( + "rejects invalid %s (%j) for concrete and composed environments", + (field, value) => { + const { bb, harness } = createFakePluginHost(); + const presentation = { + id: "workspace", + displayName: "Workspace", + description: "Prepare a workspace.", + icon: "Folder", + }; + for (const declaration of [ + { + ...presentation, + create: async () => ({ + status: "created" as const, + path: "/workspace", + ownsPath: true, + }), + remove: async () => ({ status: "removed" as const }), + }, + { + ...presentation, + machineProviderId: "cloud-machine", + environmentProviderId: "project-checkout", + }, + ]) { + Reflect.set(declaration, field, value); + expect(() => + bb.experimental_environments.register(declaration), + ).toThrow(); + expect(harness.registrations.environmentProviders.size).toBe(0); + expect(harness.registrations.environmentCompositions.size).toBe(0); + } + }, + ); + + it("keeps compositions separate from concrete lifecycle providers", () => { + const { bb, harness } = createFakePluginHost(); + bb.experimental_environments.register({ + id: "sandbox", + displayName: "Sandbox", + description: "Prepare a workspace for this thread.", + icon: "Cloud", + 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", + description: "Prepare a workspace for this thread.", + icon: "Folder", + 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 () => ({ @@ -1823,6 +1939,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "container", displayName: " Docker container ", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { gitRemote: true }, inputs, create, @@ -1832,7 +1950,8 @@ describe("environment targets", () => { expect(target).toMatchObject({ id: "container", displayName: "Docker container", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { projectCheckout: false, gitCheckout: false, @@ -1863,6 +1982,7 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "env", displayName: "Environment", + description: "Prepare a workspace for this thread.", icon, create: async () => ({ status: "failed", @@ -1885,6 +2005,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "plain", displayName: "Plain", + description: "Prepare a workspace for this thread.", + icon: "Folder", create: async () => ({ status: "failed", failure: "transient", @@ -1913,6 +2035,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "ok", displayName: "x", + description: "Prepare a workspace for this thread.", + icon: "Folder", // @ts-expect-error deliberately not a schema inputs: { type: "object" }, create: async () => ({ @@ -1931,6 +2055,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "bad id!", displayName: "x", + description: "Prepare a workspace for this thread.", + icon: "Folder", create: async () => ({ status: "failed", failure: "transient", @@ -1944,6 +2070,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id, displayName: "x", + description: "Prepare a workspace for this thread.", + icon: "Folder", create: async () => ({ status: "failed", failure: "transient", @@ -1959,6 +2087,8 @@ describe("environment targets", () => { { id: "ok", displayName: "x", + description: "Prepare a workspace for this thread.", + icon: "Folder", }, ), ).toThrow(/create/); @@ -1970,6 +2100,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "ok", displayName: "x", + description: "Prepare a workspace for this thread.", + icon: "Folder", // @ts-expect-error deliberately not a boolean requires: { gitRemote: "yes" }, create: async () => ({ @@ -1988,6 +2120,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "scratch", displayName: "Scratch", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { gitRemote: true, projectless: true }, create: async () => ({ status: "failed", @@ -2005,6 +2139,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "scratch", displayName: "Scratch", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { projectCheckout: true, projectless: true }, create: async () => ({ status: "failed", @@ -2021,6 +2157,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "syncy", displayName: "Syncy", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { projectCheckout: true }, create: async () => ({ status: "failed", @@ -2046,6 +2184,8 @@ describe("environment targets", () => { bb.experimental_environments.register({ id: "branchy", displayName: "Branchy", + description: "Prepare a workspace for this thread.", + icon: "Folder", requires: { gitCheckout: true }, create: async () => ({ status: "failed", @@ -2066,6 +2206,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: {}, + }), + 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: {}, + }), + 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: {}, + }); + const remove = async () => ({ status: "removed" as const }); + const lifecycle = async () => ({ resource: {} }); + 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..c5f5ab3209 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -37,7 +37,6 @@ import { type PluginNavPanelRegistration, type PluginNewThreadPanelActionRegistration, type PluginPendingInteractionRegistration, - type PluginProviderIconRegistration, type PluginTimelineRendererRegistration, type PluginRealtimeConnectionState, type PluginRpcClient, @@ -73,6 +72,7 @@ import { type ExperimentalPermissionModePickerProps, type ExperimentalProviderModelPickerProps, type PluginEnvironmentProviderInputsRegistration, + type PluginMachineProviderInputsRegistration, type ThreadChatProps, type DiffProps, type SourceCodeProps, @@ -83,6 +83,7 @@ import { normalizePluginThreadRowStatus } from "../internal/composer-customizati import { normalizeExperimentalFileOpenOptions } from "../internal/file-navigation-validation.js"; import { collectPluginAppRegistrations, + type CollectedPluginProviderIconRegistration, type CollectedExperimentalSidebarFooterItem, } from "../internal/plugin-app-collector.js"; @@ -963,9 +964,10 @@ export interface CapturedPluginApp { diffRenderers: PluginDiffRendererRegistration[]; messageDirectives: PluginMessageDirectiveRegistration[]; messageActions: PluginMessageActionRegistration[]; - providerIcons: PluginProviderIconRegistration[]; + providerIcons: CollectedPluginProviderIconRegistration[]; 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..bbbc5283a0 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -1,3 +1,9 @@ +import { + environmentCompositionSchema, + validateServerAccessProviderDeclaration, + 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 +27,9 @@ import { isZodSchemaLike, storePluginHook, validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, type NormalizedPluginEnvironmentProvider, + type NormalizedPluginMachineProvider, KV_VALUE_MAX_BYTES, MENTION_PROVIDER_ID_PATTERN, normalizeMentionProviderTriggers, @@ -65,6 +73,7 @@ import type { PluginCliResult, PluginHookHandler, PluginEnvironments, + PluginMachines, PluginHookName, PluginHooks, PluginEvents, @@ -284,10 +293,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 +496,8 @@ export interface FakePluginHarness } export interface CreateFakePluginHostOptions { + machineBootstrap?: MachineBootstrapApi; + machineResource?: (hostId: string) => Promise; /** Defaults to "test-plugin". */ pluginId?: string; /** @@ -1779,6 +1799,8 @@ function createFakePluginHostInternal( const threadEventHandlers: { [E in PluginThreadEventName]: Array>; } = { + "experimental_thread.events": [], + "experimental_terminal.input": [], "thread.created": [], "thread.active": [], "thread.idle": [], @@ -1797,10 +1819,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 +2143,35 @@ 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); + const problem = + composition.icon === null + ? null + : undeclaredIconProblem( + pluginId, + declaredIconNames, + composition.icon, + ); + if (problem !== null) + throw new Error(providerIconRefusalMessage(composition.id, problem)); + 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 +2187,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 +2230,22 @@ function createFakePluginHostInternal( events, experimental_hooks, experimental_environments, + experimental_machines, + experimental_serverAccess: { + register(declaration) { + assertLive(); + validateServerAccessProviderDeclaration(declaration); + if (serverAccessProviders.has(declaration.id)) + throw new Error( + `Server access provider "${declaration.id}" is already registered`, + ); + serverAccessProviders.set(declaration.id, declaration); + }, + recheck() { + assertLive(); + requestedDrains += 1; + }, + }, status, server, hosts, @@ -2236,6 +2337,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 +2360,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..9be7913af9 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({ @@ -648,7 +771,8 @@ describe("@bb/sdk", () => { { id: "project-checkout", displayName: "Project checkout", - icon: null, + description: "Prepare a workspace for this thread.", + icon: "Folder", logoUrl: null, pluginId: "environment-project-checkout", requires: { 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..bc95e2360e 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,36 @@ 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(), + availability: z + .discriminatedUnion("status", [ + z.object({ + status: z.literal("available"), + serverUrl: z.string().url().optional(), + }), + z.object({ + status: z.literal("setup-required"), + message: z.string(), + }), + z.object({ status: z.literal("unavailable"), message: z.string() }), + ]) + .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,8 +318,11 @@ 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), + description: z.string().min(1).nullable(), icon: z.string().min(1).nullable(), logoUrl: z.string().min(1).nullable(), pluginId: z.string().min(1), @@ -279,6 +339,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 +371,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..6489c4d3e8 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,11 +1933,61 @@ 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, + description: "Prepare a workspace for this thread.", + icon: "Folder", logoUrl: null, pluginId: "sandbox", acceptsEmptyInputs: false, diff --git a/packages/shared-ui/src/components/ui/icon.tsx b/packages/shared-ui/src/components/ui/icon.tsx index 83e3ddfe38..33cfb71e08 100644 --- a/packages/shared-ui/src/components/ui/icon.tsx +++ b/packages/shared-ui/src/components/ui/icon.tsx @@ -28,6 +28,7 @@ import { Folder02Icon, FolderIcon, FolderSyncIcon, + FolderUnknownIcon, HelpCircleIcon, InformationCircleIcon, Loading03Icon, @@ -124,6 +125,7 @@ const CORE_ICON_MAP = { FolderGit: FolderGitTwoIcon, FolderPlus: FolderAddIcon, FolderSync: FolderSyncIcon, + FolderUnknown: FolderUnknownIcon, Folder02: Folder02Icon, Info: InformationCircleIcon, ListTodo: CheckListIcon, diff --git a/packages/templates/src/templates/bb-guide-environments.md b/packages/templates/src/templates/bb-guide-environments.md index caefa423b0..b141060e46 100644 --- a/packages/templates/src/templates/bb-guide-environments.md +++ b/packages/templates/src/templates/bb-guide-environments.md @@ -240,6 +240,8 @@ Core owns environment retirement and teardown. After the last live thread is arc Explicit environment or project deletion bypasses the retirement grace, including the never-retire policy. Provider cleanup retains the host, path and resource until removal completes; inspect progress with `bb environment show `. +`bb environment providers --json` includes each choice’s `description` and `icon`, as well as its label, inputs, and availability. + `bb environment providers --project ` omits providers whose declared requirements are unmet on every persistent machine, and reports each provider's `machineAvailability` per machine in `--json`. Add `--machine ` to scope structural eligibility to that machine and print its availability: `available`, `setup-required`, `unavailable` with the plugin's reason, or `unknown` while the background probe has not answered. Listing never waits on a machine; probes run in the background, are cached for ten minutes per project and machine, and are checked afresh for the selected provider and machine during thread creation. BB source checkout startup diff --git a/packages/templates/src/templates/bb-guide-machines.md b/packages/templates/src/templates/bb-guide-machines.md index 4b14019ea6..c4cd1356d2 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,32 @@ 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 persistent machines with ID, +type, connection status, and +relative last-seen time +--all Include disposable provider +sandboxes +--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 +78,38 @@ 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. Omit inputs to use the +provider defaults; supply JSON when its schema requires additional values. 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. `bb machine list` includes machines still being created. It lists persistent +machines only; pass `--all` to include the disposable sandboxes that +environment providers create per thread. +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 +118,189 @@ 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 and remains available until explicitly removed. +Machines created for threads retire after their last live thread is archived +when the provider declares them ephemeral. 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 +the first registered access provider, or direct when none is registered. An +unpaired provider reports setup required. `bb settings show --json` includes +fresh provider availability and the effective selection; failed or timed-out +checks report unavailable without acquiring a grant. Settings and creation +banners refresh this status when the access provider signals a change. 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` waits for a private enrollment command, +prints it once, 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..caf3dd35c2 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 --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/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index 8431381f93..6545b28e4c 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -1499,20 +1499,17 @@ describe("Account Pool plugin", () => { name: "ANTHROPIC_BASE_URL", value: { serverPath: "/api/v1/plugins/account-pool/http" }, reason: "Routed through the Account Pooler hub", - secret: false, }, { name: "ANTHROPIC_AUTH_TOKEN", value: fixture.key, reason: "Account Pooler hub token for this machine", - secret: true, }, { name: "ENABLE_TOOL_SEARCH", value: "true", reason: "Claude Code turns tool search off behind a custom base URL; the hub forwards tool_reference blocks", - secret: false, }, ]); await expect( diff --git a/plugins/account-pool/src/server.ts b/plugins/account-pool/src/server.ts index 200f084fdb..a4c4dc9892 100644 --- a/plugins/account-pool/src/server.ts +++ b/plugins/account-pool/src/server.ts @@ -185,20 +185,17 @@ export function createAccountPoolPlugin( serverPath: "/api/v1/plugins/account-pool/http", }, reason: "Routed through the Account Pooler hub", - secret: false, }, { name: "ANTHROPIC_AUTH_TOKEN", value: token, reason: "Account Pooler hub token for this machine", - secret: true, }, { name: "ENABLE_TOOL_SEARCH", value: "true", reason: "Claude Code turns tool search off behind a custom base URL; the hub forwards tool_reference blocks", - secret: false, }, ]; }); @@ -228,13 +225,11 @@ export function createAccountPoolPlugin( serverPath: "/api/v1/plugins/account-pool/http/v1", }, reason: "Routed through the Account Pooler hub", - secret: false, }, { name: "CODEX_POOL_AUTH_TOKEN", value: token, reason: "Account Pooler hub token for this machine", - secret: true, }, ]; }); 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..5f6706d072 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,21 @@ 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 the first registered access provider, +or direct when none is registered. An unpaired provider remains selected and +reports setup required. `bb settings show --json` includes serverAccess with the +effective direct URL, its source and provider availability. Availability is refreshed +on each read, with failed or timed-out checks reported as unavailable. It does +not acquire a machine grant. 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..96a3432d54 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,10 @@ 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 lists and name/ID selectors include machines still being created. 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`. + +Standalone `bb machine create` machines remain until explicitly removed. diff --git a/plugins/bb-guide/skills/bb-cli/references/configuration.md b/plugins/bb-guide/skills/bb-cli/references/configuration.md index c4cd7bdf9e..5e0a5cee52 100644 --- a/plugins/bb-guide/skills/bb-cli/references/configuration.md +++ b/plugins/bb-guide/skills/bb-cli/references/configuration.md @@ -101,3 +101,34 @@ 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 +uses the first registered access provider, or direct when none are registered. +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..8b1130f464 100644 --- a/plugins/bb-guide/skills/bb-cli/references/thread-creation.md +++ b/plugins/bb-guide/skills/bb-cli/references/thread-creation.md @@ -10,11 +10,20 @@ - Select a target with `--environment`, `--new-environment`, `--base-branch`, or `--machine`. Select execution with `--provider`, `--model`, `--reasoning-level`, `--service-tier`, and `--permission-mode`. -- List plugin-provisioned environment choices with `bb environment providers`. Add `--project ` and optionally `--machine ` to omit providers whose declared requirements are unmet. Without a machine, the project listing includes providers structurally eligible on any persistent machine. Git inspection and plugin availability run only for the selected provider and machine during thread creation. `--json` includes each provider's `requires` facts and its `inputs` JSON Schema or null. +- List plugin-provisioned environment choices with `bb environment providers`. Add `--project ` and optionally `--machine ` to omit providers whose declared requirements are unmet. Without a machine, the project listing includes providers structurally eligible on any persistent machine. Git inspection and plugin availability run only for the selected provider and machine during thread creation. `--json` includes each provider's `description` and `icon`, its `requires` facts and its `inputs` JSON Schema or null. Pass the selected ID to `--environment-provider`. Add `--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`. @@ -60,8 +69,10 @@ worktree` only; a provider takes its branch through `--environment-inputs`. launchd/systemd restart the daemon. Auto-update never downgrades. To bypass a transient backoff, use `bb machine retry-update `. Remove `--auto-update` from the service definition and reload it to opt out. -- Run `bb machine list` to see machine names, IDs, connection status, and last - seen time (`--json` returns the raw host list). Use `--machine ` +- Run `bb machine list` to see machine names, IDs, type, connection status, and + last seen time (`--json` returns the raw host list). It shows persistent + machines; pass `--all` to include the disposable sandboxes environment + providers create per thread. Use `--machine ` (alias `--host`) on `bb thread spawn` to run in a personal or unmanaged workspace, or combine it with `--new-environment worktree`. Do not combine a machine selector with an existing environment ID, which already owns its @@ -74,8 +85,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 +173,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` waits for the enrollment command to become ready, prints it once, 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..e3055a59e7 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` +- `PluginMachineProviderResource` — non-null JSON persisted by machine checkpoints and lifecycle results +- `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..76b7403fd7 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 @@ -170,12 +183,13 @@ Register resource operations with `bb.experimental_environments.register`. `icon` accepts host glyphs, plugin-relative assets, and this plugin's declared namespaced icons, just like agent providers. The provider listing includes a hashed `logoUrl` for assets. `app.slots.experimental_providerIcon` can override -an environment provider's icon by its provider ID. +an environment provider's icon with `providerKind: "environment"` and its `providerId`. ```ts bb.experimental_environments.register({ id: "personal-workspace", displayName: "Personal workspace", + description: "Create a personal directory without a project.", icon: "Folder", requires: { projectless: true }, async create({ host, pathKey, report, signal }) { 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..445daf28fb --- /dev/null +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-machines.md @@ -0,0 +1,223 @@ +# 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 +import type { BbPluginApi, MachineExecutor } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +export function registerMachine( + bb: BbPluginApi, + targets: { + allocate(request: { + target: string; + key: string; + signal: AbortSignal; + }): Promise<{ id: string; executor: MachineExecutor }>; + remove(id: string, signal: AbortSignal): Promise; + removeByKey(key: string, signal: AbortSignal): Promise; + }, +) { + 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 targets.allocate({ + 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 reconcileCleanup({ key, signal }) { + await targets.removeByKey(key, signal); + return { status: "removed" }; + }, + async remove({ resource, signal }) { + const owned = z.object({ target: z.string() }).parse(resource); + await targets.remove(owned.target, signal); + 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 opaque JSON +resource for later lifecycle operations; core uses the host identity reserved +on the launch. `PluginMachineProviderResource` excludes top-level null; use `{}` +when no custom metadata is needed. The example’s `targets` adapter supplies +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. +Without a checkpoint, `reconcileCleanup` discovers and removes allocations by key. +With a checkpoint, `remove` receives the stored resource. Failed cleanup remains +recorded and retries on the core one-minute interval until it succeeds. + +Machine registration does not contribute environment-picker entries. Register +an environment composition with required `id`, `displayName`, `icon`, +`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 an awaitable `checkpoint(resource)`, which +persists a recoverable opaque 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 bundles live in server memory. +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 private plugin storage. +Never put credentials in machine inputs, resources, or progress output. + +Machines settings select the default. Without a saved selection, core uses the +first registered provider, or direct when none are registered. Core retains the +selected provider for subsequent enrollment of the same machine. The direct provider reads +machineServerUrl, falling back to BB_EXTERNAL_URL. Declaring a URL does not +prove reachability from a sandbox. + +Call `recheck()` when access is gained or lost. It broadcasts a configuration-change +notification. Clients reload configuration, which checks provider availability +in parallel with a five-second deadline per check. Invalid output, exceptions, +and timeouts appear unavailable. Machines settings, manual setup, and promptbox +banners use that status; a registered provider alone is not ready. +Availability's optional public `serverUrl` is validated and displayed in Machines +settings when available. An available result without a URL is valid. Reading +configuration never acquires access; the grant's URL is the one used by machines. + +### Machine enrollment and bootstrap + +`bb.experimental_machines` implements `MachineBootstrapApi` alongside register: + +- `bootstrap({ key, executor, report, signal })` prepares or recovers enrollment, + installs or starts the daemon, waits for its connection, and returns `{ hostId }`. + Reuse the create key on recovery. Initial installation needs Node, npm, and curl; + the helper does not install OS packages. Manual setup is built into core and + uses internal enrollment operations. + +A `MachineExecutor` implements `exec({ command, stdin, timeoutMs, signal, onOutput })` +returning `{ exitCode }`. Execute argv through the provider's transport, honor timeout +and cancellation, and keep stdin private. Stream command output through `onOutput`; +core forwards it into progress logs and includes its last 20 lines on nonzero exit. +Do not emit credentials. The helper 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. + +Standalone SDK creation with `wait: false` returns before the manual command is +necessarily ready. Poll `experimental_getEnrollmentCommand({ hostId })` while the +host is creating; null means there is no current command. Stop on connection or +creation failure. Reading does not renew a command; regenerate expired setup explicitly. + +### 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..99c7588087 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 @@ -118,20 +118,18 @@ providerId }`) and `Original`, the host's declarative base for the body — glyph when the name is no longer declared. With no renderer registered, the declarative base renders, so a row never goes blank; a crash in the component is contained to that row. -- `experimental_providerIcon` → the React component bb draws as one agent, +- `experimental_providerIcon` → the React component bb draws as one agent, machine, or environment provider's icon. Registration: - `{ providerId, icon }`, where `providerId` is the provider's id (`"codex"`, + `{ providerKind, providerId, icon }`, with required `providerKind` (`"agent"`, `"machine"`, or `"environment"`); `providerId` is the provider's id (`"codex"`, `"git-worktree"`) — not the plugin id — and `icon` is a component receiving only `className` (host sizing plus the - provider color class). Use it for a theme-aware mark: a file logo - (`bb.branding.icon`, or a path-shaped provider declaration `icon`) is drawn - through ``, a separate document where `currentColor` resolves to black - and is invisible on dark themes, so keep files for intentionally colored - logos and register a component for anything that should follow the theme. + provider color class). Provider logo assets render as a `currentColor` + mask; use an inline component when the mark needs custom theme-aware or + multicolor rendering. A component beats the file logo for that provider; disabling the plugin falls back to it, and so does every surface shown before the plugin's deferred `app.tsx` has loaded. Read `references/providers.md` for provider icon declaration and registration details. - 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 + One registration per provider kind and id per plugin; if two plugins claim one + provider kind and 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..05d21abcb0 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/providers.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/providers.md @@ -87,7 +87,7 @@ mask, so a monochrome mark follows the bb theme (and the declared ships gets its brand mark; core vendors none. A full-colour logo renders as a silhouette. A glyph name carries no bytes, so there is no `logoUrl` and clients draw the glyph from the shared icon set. A plugin that wants custom inline React for its mark can still -register `app.slots.experimental_providerIcon({ providerId, icon })` from +register `app.slots.experimental_providerIcon({ providerKind, providerId, icon })` from an `app.tsx`. Example: ```tsx @@ -104,6 +104,7 @@ function EchoIcon({ className }: { className?: string }) { export default definePluginApp((app) => { app.slots.experimental_providerIcon({ + providerKind: "agent", providerId: "echo-agent", icon: EchoIcon, }); @@ -155,7 +156,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 +163,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 +171,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/connect.test.ts b/plugins/connect/src/connect.test.ts index 7a00b29716..4e9e4d19f6 100644 --- a/plugins/connect/src/connect.test.ts +++ b/plugins/connect/src/connect.test.ts @@ -2249,6 +2249,7 @@ describe("connect plugin", () => { expect(call?.[1]).toEqual({ method: "POST", headers: { "x-bb-connect-machine": "bbcred_durable" }, + signal: expect.any(AbortSignal), }); const result = (await harness.callRpc("createMachineCode")) as { expiresAt: number; @@ -2614,6 +2615,7 @@ describe("connect CLI", () => { expect(call?.[1]).toEqual({ method: "POST", headers: { "x-bb-connect-machine": "bbcred_live" }, + signal: expect.any(AbortSignal), }); }); diff --git a/plugins/connect/src/machine-code.ts b/plugins/connect/src/machine-code.ts index 9442027546..1b06c9702b 100644 --- a/plugins/connect/src/machine-code.ts +++ b/plugins/connect/src/machine-code.ts @@ -25,6 +25,7 @@ export class MachineCodeError extends Error { export async function fetchMachineCode( credential: ConnectCredential, + signal: AbortSignal, ): Promise { const url = `${deriveConnectBaseUrl(credential.serverUrl).replace(/\/$/u, "")}/api/connect/machine-code`; let response: Response; @@ -32,8 +33,10 @@ export async function fetchMachineCode( response = await fetch(url, { method: "POST", headers: { "x-bb-connect-machine": credential.credential }, + signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]), }); } catch { + signal.throwIfAborted(); throw new MachineCodeError("network"); } if (!response.ok) { @@ -49,3 +52,26 @@ export async function fetchMachineCode( serverUrl: parsed.data.serverUrl, }; } + +export async function lookupMachineCode( + credential: ConnectCredential, + code: string, + signal: AbortSignal, +) { + 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.any([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..c6408a39b9 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,28 @@ export async function redeemConnectCode(args: { const data = (await res.json()) as RedeemedCredential; return { credential: data.credential, handle: data.handle }; } + +export async function redeemMachineCode(args: { + signal: AbortSignal; + 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.any([args.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..992e838f54 --- /dev/null +++ b/plugins/connect/src/server-access.test.ts @@ -0,0 +1,474 @@ +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); + }); +}); + +it("aborts pending code issuance and lets a later acquisition proceed", async () => { + const host = await setup(); + const controller = new AbortController(); + let started = false; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const signal = init?.signal; + if (!signal) throw new Error("Missing acquisition signal"); + started = true; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + }), + ); + const pending = provider(host).acquire({ + ...request, + signal: controller.signal, + }); + await vi.waitFor(() => expect(started).toBe(true)); + controller.abort(new Error("cancelled")); + await expect(pending).rejects.toThrow("cancelled"); + expect(await host.bb.storage.kv.get(key)).toBeUndefined(); + const api = cloud(); + await provider(host).acquire(request); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(api.active()).toBe(false); +}); + +it("retains interrupted redemption intent and revokes the committed device", async () => { + const host = await setup(); + const controller = new AbortController(); + let active = false; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/machine-code") && init?.method === "POST") + return Response.json({ + code: "PENDING-CODE", + expiresInMs: 600000, + serverUrl: credential.serverUrl, + }); + if (url.endsWith("/redeem-machine")) { + const signal = init?.signal; + if (!signal) throw new Error("Missing redemption signal"); + active = true; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + } + if (url.endsWith("/machine-code") && init?.method === "GET") + return Response.json({ consumed: true, machineId: "committed-device" }); + expect(url).toContain("/revoke-machine"); + expect(JSON.parse(String(init?.body))).toEqual({ + machineId: "committed-device", + }); + active = false; + return Response.json({ ok: true }); + }), + ); + const pending = provider(host).acquire({ + ...request, + signal: controller.signal, + }); + await vi.waitFor(() => expect(active).toBe(true)); + controller.abort(new Error("cancelled")); + await expect(pending).rejects.toThrow("cancelled"); + expect(await host.bb.storage.kv.get(key)).toMatchObject({ + intent: { code: "PENDING-CODE" }, + }); + const restarted = await host.harness.lifecycle.reload((bb) => + registerServerAccess(bb, tunnel), + ); + hosts.push(restarted); + await provider(restarted).release({ + key: request.key, + hostId: request.hostId, + grantId: null, + }); + expect(active).toBe(false); + expect(await restarted.bb.storage.kv.get(key)).toBeUndefined(); +}); + +it("keeps an unexpired acquisition intent until a late redemption can be ruled out", async () => { + const host = await setup(async (host) => { + await host.bb.storage.kv.set(key, { + intent: { + key: request.key, + hostId: request.hostId, + code: "UNSETTLED-CODE", + expiresAt: Date.now() + 60_000, + serverUrl: credential.serverUrl, + }, + }); + }); + let consumed = false; + let revoked = false; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "GET") + return Response.json({ + consumed, + machineId: consumed ? "late-device" : null, + }); + revoked = true; + return Response.json({ ok: true }); + }), + ); + const release = { key: request.key, hostId: request.hostId, grantId: null }; + await expect(provider(host).release(release)).rejects.toThrow( + "still unsettled", + ); + expect(await host.bb.storage.kv.get(key)).toMatchObject({ + intent: { code: "UNSETTLED-CODE" }, + }); + consumed = true; + await provider(host).release(release); + expect(revoked).toBe(true); + expect(await host.bb.storage.kv.get(key)).toBeUndefined(); +}); diff --git a/plugins/connect/src/server-access.ts b/plugins/connect/src/server-access.ts new file mode 100644 index 0000000000..422e6fe24f --- /dev/null +++ b/plugins/connect/src/server-access.ts @@ -0,0 +1,184 @@ +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, + signal: AbortSignal, + ) { + 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, signal); + if (status.consumed && !status.machineId) + throw new Error("Device identity unavailable"); + if (status.machineId) await revokeMachine(credential, status.machineId); + return status.consumed; + } catch { + signal.throwIfAborted(); + 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, signal); + if (typeof reconciliation !== "boolean") return reconciliation; + if (reconciliation || intent.expiresAt <= Date.now()) + intent = undefined; + } + if (!intent) { + const code = await fetchMachineCode(credential, signal); + intent = { + key, + hostId, + code: code.code, + serverUrl: code.serverUrl, + expiresAt: code.expiresAt, + }; + } + signal.throwIfAborted(); + await bb.storage.kv.set(grantKey(hostId), { intent }); + signal.throwIfAborted(); + const pending = intent; + const redeemed = await redeemMachineCode({ ...pending, signal }).catch( + () => { + signal.throwIfAborted(); + return 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, + AbortSignal.timeout(30_000), + ); + if (typeof reconciliation !== "boolean") + throw new Error(reconciliation.message); + if (!reconciliation && stored.intent.expiresAt > Date.now()) + throw new Error( + "Machine access acquisition is still unsettled; cleanup will retry after redemption or code expiry", + ); + } 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/connect/src/tunnel.ts b/plugins/connect/src/tunnel.ts index 4fbedebe58..9f7a2e9c5b 100644 --- a/plugins/connect/src/tunnel.ts +++ b/plugins/connect/src/tunnel.ts @@ -208,7 +208,7 @@ export class ConnectTunnel { if (this.credential === null) { throw new MachineCodeError("not_paired"); } - return fetchMachineCode(this.credential); + return fetchMachineCode(this.credential, AbortSignal.timeout(10_000)); } async revokeMachine(machineId: string): Promise { 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-git-worktree/server.test.ts b/plugins/environment-git-worktree/server.test.ts index 61aea7fdfc..700b7c8d02 100644 --- a/plugins/environment-git-worktree/server.test.ts +++ b/plugins/environment-git-worktree/server.test.ts @@ -66,7 +66,7 @@ async function setup( thread: makeThreadResponse({ id: THREAD_ID, projectId: PROJECT_ID }), project: PROJECT, host: PROVISION_HOST, - projectCheckout: { path: SOURCE_PATH }, + projectCheckout: { experimental_ownsPath: false, path: SOURCE_PATH }, gitRemote: null, inputs: { branch: { kind: "default" } }, suggestedBranchName: "bb/test", diff --git a/plugins/environment-git-worktree/server.ts b/plugins/environment-git-worktree/server.ts index 193e3371e2..76f5bb6d01 100644 --- a/plugins/environment-git-worktree/server.ts +++ b/plugins/environment-git-worktree/server.ts @@ -38,6 +38,7 @@ export default async function worktreePlugin(bb: BbPluginApi): Promise { bb.experimental_environments.register({ id: GIT_WORKTREE_ENVIRONMENT_PROVIDER_ID, displayName: "Worktree", + description: "Create an isolated Git worktree for your changes.", icon: "FolderGit", requires: { gitCheckout: true }, inputs: worktreeInputsSchema, diff --git a/plugins/environment-modal-sandbox/Dockerfile b/plugins/environment-modal-sandbox/Dockerfile new file mode 100644 index 0000000000..3fdf157312 --- /dev/null +++ b/plugins/environment-modal-sandbox/Dockerfile @@ -0,0 +1,30 @@ +# 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 \ + bubblewrap \ + 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..bd58427b1a --- /dev/null +++ b/plugins/environment-modal-sandbox/README.md @@ -0,0 +1,169 @@ +# 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, bubblewrap, 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" ? ( +